mirror of
https://gitee.com/zhangsken/AndroidIDE.git
synced 2026-10-16 07:49:44 +08:00
feat: integrate full Termux application
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,71 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
val packageVariant = System.getenv("TERMUX_PACKAGE_VARIANT") ?: "apt-android-7" // Default: "apt-android-7"
|
||||
|
||||
android {
|
||||
namespace = "com.termux"
|
||||
ndkVersion = BuildConfig.ndkVersion
|
||||
|
||||
defaultConfig {
|
||||
|
||||
buildConfigField("String", "TERMUX_PACKAGE_VARIANT", "\"" + packageVariant + "\"") // Used by TermuxApplication class
|
||||
|
||||
manifestPlaceholders["TERMUX_PACKAGE_NAME"] = BuildConfig.packageName
|
||||
manifestPlaceholders["TERMUX_APP_NAME"] = "AndroidIDE"
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
cFlags("-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
path = file("src/main/cpp/Android.mk")
|
||||
}
|
||||
}
|
||||
|
||||
lint.disable += "ProtectedPermissions"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
packaging.jniLibs.useLegacyPackaging = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.drawer)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.viewpager)
|
||||
implementation(libs.google.material)
|
||||
implementation(libs.google.guava)
|
||||
implementation(libs.common.markwon.core)
|
||||
implementation(libs.common.markwon.extStrikethrough)
|
||||
implementation(libs.common.markwon.linkify)
|
||||
implementation(libs.common.markwon.recycler)
|
||||
|
||||
implementation(projects.common)
|
||||
implementation(projects.preferences)
|
||||
implementation(projects.resources)
|
||||
implementation(projects.termux.termuxView)
|
||||
implementation(projects.termux.termuxShared)
|
||||
|
||||
testImplementation(projects.testing.unit)
|
||||
}
|
||||
|
||||
tasks.register("versionName") {
|
||||
doLast {
|
||||
print(project.rootProject.version)
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# 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
|
||||
|
||||
-dontobfuscate
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Temp fix for androidx.window:window:1.0.0-alpha09 imported by termux-shared
|
||||
# https://issuetracker.google.com/issues/189001730
|
||||
# https://android-review.googlesource.com/c/platform/frameworks/support/+/1757630
|
||||
-keep class androidx.window.** { *; }
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:sharedUserLabel="@string/shared_user_label">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.READ_LOGS" />
|
||||
<uses-permission android:name="android.permission.DUMP" />
|
||||
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<application>
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|density|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:name="com.termux.app.TermuxActivity"
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity
|
||||
android:documentLaunchMode="intoExisting"
|
||||
android:name=".shared.activities.ReportActivity" />
|
||||
|
||||
<activity
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:name=".app.api.file.FileReceiverActivity"
|
||||
android:noHistory="true"
|
||||
android:resizeableActivity="true"
|
||||
android:taskAffinity="${TERMUX_PACKAGE_NAME}.filereceiver" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.max_aspect"
|
||||
android:value="10.0" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.keepalive.density"
|
||||
android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||
android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.android.support.multiwindow"
|
||||
android:value="true" />
|
||||
|
||||
|
||||
<!-- https://developer.samsung.com/samsung-dex/modify-optimizing.html -->
|
||||
|
||||
<!-- Version < 3.0. DeX Mode and Screen Mirroring support -->
|
||||
<receiver
|
||||
android:exported="false"
|
||||
android:name=".shared.activities.ReportActivity$ReportActivityBroadcastReceiver" />
|
||||
|
||||
<!-- Version >= 3.0. DeX Dual Mode support -->
|
||||
<service
|
||||
android:exported="false"
|
||||
android:name=".app.TermuxService" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
LOCAL_PATH:= $(call my-dir)
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libtermux-bootstrap
|
||||
LOCAL_SRC_FILES := termux-bootstrap-zip.S termux-bootstrap.c
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
.global blob
|
||||
.global blob_size
|
||||
.section .rodata
|
||||
blob:
|
||||
#if defined __aarch64__
|
||||
.incbin "bootstrap-aarch64.zip"
|
||||
#elif defined __arm__
|
||||
.incbin "bootstrap-arm.zip"
|
||||
#else
|
||||
# error Unsupported arch
|
||||
#endif
|
||||
1:
|
||||
blob_size:
|
||||
.int 1b - blob
|
||||
@@ -0,0 +1,11 @@
|
||||
#include <jni.h>
|
||||
|
||||
extern jbyte blob[];
|
||||
extern int blob_size;
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL Java_com_termux_app_TermuxInstaller_getZip(JNIEnv *env, __attribute__((__unused__)) jobject This)
|
||||
{
|
||||
jbyteArray ret = (*env)->NewByteArray(env, blob_size);
|
||||
(*env)->SetByteArrayRegion(env, ret, 0, blob_size, blob);
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
/*
|
||||
* This file is part of AndroidIDE.
|
||||
*
|
||||
* AndroidIDE is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* AndroidIDE is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.termux.app;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.ContextMenu.ContextMenuInfo;
|
||||
import android.view.Gravity;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.view.autofill.AutofillManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
import com.itsaky.androidide.app.BaseIDEActivity;
|
||||
import com.termux.R;
|
||||
import com.termux.app.api.file.FileReceiverActivity;
|
||||
import com.termux.app.terminal.TermuxActivityRootView;
|
||||
import com.termux.app.terminal.TermuxSessionsListViewController;
|
||||
import com.termux.app.terminal.TermuxTerminalSessionActivityClient;
|
||||
import com.termux.app.terminal.TermuxTerminalViewClient;
|
||||
import com.termux.app.terminal.io.TerminalToolbarViewPager;
|
||||
import com.termux.app.terminal.io.TermuxTerminalExtraKeys;
|
||||
import com.termux.databinding.ActivityTermuxBinding;
|
||||
import com.termux.shared.activities.ReportActivity;
|
||||
import com.termux.shared.activity.ActivityUtils;
|
||||
import com.termux.shared.android.PermissionUtils;
|
||||
import com.termux.shared.data.DataUtils;
|
||||
import com.termux.shared.data.IntentUtils;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY;
|
||||
import com.termux.shared.termux.crash.TermuxCrashUtils;
|
||||
import com.termux.shared.termux.extrakeys.ExtraKeysView;
|
||||
import com.termux.shared.termux.interact.TextInputDialogUtils;
|
||||
import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences;
|
||||
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
|
||||
import com.termux.shared.termux.theme.TermuxThemeUtils;
|
||||
import com.termux.shared.view.KeyboardUtils;
|
||||
import com.termux.shared.view.ViewUtils;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
import com.termux.terminal.TerminalSessionClient;
|
||||
import com.termux.view.TerminalView;
|
||||
import com.termux.view.TerminalViewClient;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A terminal emulator activity.
|
||||
* <p/>
|
||||
* See
|
||||
* <ul>
|
||||
* <li>http://www.mongrel-phones.com.au/default/how_to_make_a_local_service_and_bind_to_it_in_android</li>
|
||||
* <li>https://code.google.com/p/android/issues/detail?id=6426</li>
|
||||
* </ul>
|
||||
* about memory leaks.
|
||||
*/
|
||||
public class TermuxActivity extends BaseIDEActivity implements ServiceConnection {
|
||||
|
||||
protected ActivityTermuxBinding binding;
|
||||
|
||||
/**
|
||||
* The connection to the {@link TermuxService}. Requested in {@link #onCreate(Bundle)} with a call to
|
||||
* {@link #bindService(Intent, ServiceConnection, int)}, and obtained and stored in
|
||||
* {@link #onServiceConnected(ComponentName, IBinder)}.
|
||||
*/
|
||||
TermuxService mTermuxService;
|
||||
|
||||
/**
|
||||
* The {@link TerminalViewClient} interface implementation to allow for communication between
|
||||
* {@link TerminalView} and {@link TermuxActivity}.
|
||||
*/
|
||||
TermuxTerminalViewClient mTermuxTerminalViewClient;
|
||||
|
||||
/**
|
||||
* The {@link TerminalSessionClient} interface implementation to allow for communication between
|
||||
* {@link TerminalSession} and {@link TermuxActivity}.
|
||||
*/
|
||||
TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
|
||||
|
||||
/**
|
||||
* Termux app shared preferences manager.
|
||||
*/
|
||||
private TermuxAppSharedPreferences mPreferences;
|
||||
|
||||
/**
|
||||
* Termux app SharedProperties loaded from termux.properties
|
||||
*/
|
||||
private TermuxAppSharedProperties mProperties;
|
||||
|
||||
/**
|
||||
* The terminal extra keys view.
|
||||
*/
|
||||
ExtraKeysView mExtraKeysView;
|
||||
|
||||
/**
|
||||
* The client for the {@link #mExtraKeysView}.
|
||||
*/
|
||||
TermuxTerminalExtraKeys mTermuxTerminalExtraKeys;
|
||||
|
||||
/**
|
||||
* The termux sessions list controller.
|
||||
*/
|
||||
TermuxSessionsListViewController mTermuxSessionListViewController;
|
||||
|
||||
/**
|
||||
* The {@link TermuxActivity} broadcast receiver for various things like terminal style configuration changes.
|
||||
*/
|
||||
private final BroadcastReceiver mTermuxActivityBroadcastReceiver = new TermuxActivityBroadcastReceiver();
|
||||
|
||||
/**
|
||||
* The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}.
|
||||
*/
|
||||
Toast mLastToast;
|
||||
|
||||
/**
|
||||
* If between onResume() and onStop(). Note that only one session is in the foreground of the terminal view at the
|
||||
* time, so if the session causing a change is not in the foreground it should probably be treated as background.
|
||||
*/
|
||||
private boolean mIsVisible;
|
||||
|
||||
/**
|
||||
* If onResume() was called after onCreate().
|
||||
*/
|
||||
private boolean mIsOnResumeAfterOnCreate = false;
|
||||
|
||||
/**
|
||||
* If activity was restarted like due to call to {@link #recreate()} after receiving
|
||||
* {@link TERMUX_ACTIVITY#ACTION_RELOAD_STYLE}, system dark night mode was changed or activity
|
||||
* was killed by android.
|
||||
*/
|
||||
private boolean mIsActivityRecreated = false;
|
||||
|
||||
/**
|
||||
* The {@link TermuxActivity} is in an invalid state and must not be run.
|
||||
*/
|
||||
private boolean mIsInvalidState;
|
||||
|
||||
private int mNavBarHeight;
|
||||
|
||||
private float mTerminalToolbarDefaultHeight;
|
||||
|
||||
|
||||
private static final int CONTEXT_MENU_SELECT_URL_ID = 0;
|
||||
private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1;
|
||||
private static final int CONTEXT_MENU_SHARE_SELECTED_TEXT = 10;
|
||||
private static final int CONTEXT_MENU_AUTOFILL_ID = 2;
|
||||
private static final int CONTEXT_MENU_RESET_TERMINAL_ID = 3;
|
||||
private static final int CONTEXT_MENU_KILL_PROCESS_ID = 4;
|
||||
private static final int CONTEXT_MENU_STYLING_ID = 5;
|
||||
private static final int CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON = 6;
|
||||
private static final int CONTEXT_MENU_HELP_ID = 7;
|
||||
private static final int CONTEXT_MENU_SETTINGS_ID = 8;
|
||||
private static final int CONTEXT_MENU_REPORT_ID = 9;
|
||||
|
||||
private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input";
|
||||
private static final String ARG_ACTIVITY_RECREATED = "activity_recreated";
|
||||
|
||||
private static final String LOG_TAG = "TermuxActivity";
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected View bindLayout() {
|
||||
this.binding = ActivityTermuxBinding.inflate(getLayoutInflater());
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Logger.logDebug(LOG_TAG, "onCreate");
|
||||
mIsOnResumeAfterOnCreate = true;
|
||||
|
||||
if (savedInstanceState != null)
|
||||
mIsActivityRecreated = savedInstanceState.getBoolean(ARG_ACTIVITY_RECREATED, false);
|
||||
|
||||
// Delete ReportInfo serialized object files from cache older than 14 days
|
||||
ReportActivity.deleteReportInfoFilesOlderThanXDays(this, 14, false);
|
||||
|
||||
// Load Termux app SharedProperties from disk
|
||||
mProperties = TermuxAppSharedProperties.getProperties();
|
||||
reloadProperties();
|
||||
|
||||
setActivityTheme();
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Load termux shared preferences
|
||||
// This will also fail if TermuxConstants.TERMUX_PACKAGE_NAME does not equal applicationId
|
||||
mPreferences = TermuxAppSharedPreferences.build(this, true);
|
||||
if (mPreferences == null) {
|
||||
// An AlertDialog should have shown to kill the app, so we don't continue running activity code
|
||||
mIsInvalidState = true;
|
||||
return;
|
||||
}
|
||||
|
||||
setMargins();
|
||||
|
||||
binding.activityTermuxRootView.setActivity(this);
|
||||
binding.activityTermuxRootView.setOnApplyWindowInsetsListener(new TermuxActivityRootView.WindowInsetsListener());
|
||||
|
||||
View content = findViewById(android.R.id.content);
|
||||
content.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
mNavBarHeight = insets.getSystemWindowInsetBottom();
|
||||
return insets;
|
||||
});
|
||||
|
||||
if (mProperties.isUsingFullScreen()) {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
}
|
||||
|
||||
setTermuxTerminalViewAndClients();
|
||||
|
||||
setTerminalToolbarView(savedInstanceState);
|
||||
|
||||
setNewSessionButtonView();
|
||||
|
||||
setToggleKeyboardView();
|
||||
|
||||
registerForContextMenu(binding.terminalView);
|
||||
|
||||
FileReceiverActivity.updateFileReceiverActivityComponentsState(this);
|
||||
|
||||
try {
|
||||
// Start the {@link TermuxService} and make it run regardless of who is bound to it
|
||||
Intent serviceIntent = new Intent(this, TermuxService.class);
|
||||
startService(serviceIntent);
|
||||
|
||||
// Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)}
|
||||
// callback if it succeeds.
|
||||
if (!bindService(serviceIntent, this, 0))
|
||||
throw new RuntimeException("bindService() failed");
|
||||
} catch (Exception e) {
|
||||
Logger.logStackTraceWithMessage(LOG_TAG,"TermuxActivity failed to start TermuxService", e);
|
||||
Logger.showToast(this,
|
||||
getString(e.getMessage() != null && e.getMessage().contains("app is in background") ?
|
||||
R.string.error_termux_service_start_failed_bg : R.string.error_termux_service_start_failed_general),
|
||||
true);
|
||||
mIsInvalidState = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
|
||||
Logger.logDebug(LOG_TAG, "onStart");
|
||||
|
||||
if (mIsInvalidState) return;
|
||||
|
||||
mIsVisible = true;
|
||||
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onStart();
|
||||
|
||||
if (mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onStart();
|
||||
|
||||
if (mPreferences.isTerminalMarginAdjustmentEnabled())
|
||||
addTermuxActivityRootViewGlobalLayoutListener();
|
||||
|
||||
registerTermuxActivityBroadcastReceiver();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
Logger.logVerbose(LOG_TAG, "onResume");
|
||||
|
||||
if (mIsInvalidState) return;
|
||||
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onResume();
|
||||
|
||||
if (mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onResume();
|
||||
|
||||
// Check if a crash happened on last run of the app or if a plugin crashed and show a
|
||||
// notification with the crash details if it did
|
||||
TermuxCrashUtils.notifyAppCrashFromCrashLogFile(this, LOG_TAG);
|
||||
|
||||
mIsOnResumeAfterOnCreate = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
|
||||
Logger.logDebug(LOG_TAG, "onStop");
|
||||
|
||||
if (mIsInvalidState) return;
|
||||
|
||||
mIsVisible = false;
|
||||
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onStop();
|
||||
|
||||
if (mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onStop();
|
||||
|
||||
removeTermuxActivityRootViewGlobalLayoutListener();
|
||||
|
||||
unregisterTermuxActivityBroadcastReceiver();
|
||||
getDrawer().closeDrawers();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
Logger.logDebug(LOG_TAG, "onDestroy");
|
||||
|
||||
if (mIsInvalidState) return;
|
||||
|
||||
this.binding = null;
|
||||
this.mLastToast = null;
|
||||
|
||||
if (mTermuxService != null) {
|
||||
// Do not leave service and session clients with references to activity.
|
||||
mTermuxService.unsetTermuxTerminalSessionClient();
|
||||
mTermuxService = null;
|
||||
}
|
||||
|
||||
try {
|
||||
unbindService(this);
|
||||
} catch (Exception e) {
|
||||
// ignore.
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
|
||||
Logger.logVerbose(LOG_TAG, "onSaveInstanceState");
|
||||
|
||||
super.onSaveInstanceState(savedInstanceState);
|
||||
saveTerminalToolbarTextInput(savedInstanceState);
|
||||
savedInstanceState.putBoolean(ARG_ACTIVITY_RECREATED, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Part of the {@link ServiceConnection} interface. The service is bound with
|
||||
* {@link #bindService(Intent, ServiceConnection, int)} in {@link #onCreate(Bundle)} which will cause a call to this
|
||||
* callback method.
|
||||
*/
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName componentName, IBinder service) {
|
||||
Logger.logDebug(LOG_TAG, "onServiceConnected");
|
||||
|
||||
mTermuxService = ((TermuxService.LocalBinder) service).service;
|
||||
|
||||
setTermuxSessionsListView();
|
||||
|
||||
final Intent intent = getIntent();
|
||||
setIntent(null);
|
||||
|
||||
if (mTermuxService.isTermuxSessionsEmpty()) {
|
||||
if (mIsVisible) {
|
||||
TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> {
|
||||
if (mTermuxService == null) return; // Activity might have been destroyed.
|
||||
try {
|
||||
boolean launchFailsafe = false;
|
||||
if (intent != null && intent.getExtras() != null) {
|
||||
launchFailsafe = intent.getExtras().getBoolean(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false);
|
||||
}
|
||||
mTermuxTerminalSessionActivityClient.addNewSession(launchFailsafe, null);
|
||||
} catch (WindowManager.BadTokenException e) {
|
||||
// Activity finished - ignore.
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// The service connected while not in foreground - just bail out.
|
||||
finishActivityIfNotFinishing();
|
||||
}
|
||||
} else {
|
||||
// If termux was started from launcher "New session" shortcut and activity is recreated,
|
||||
// then the original intent will be re-delivered, resulting in a new session being re-added
|
||||
// each time.
|
||||
if (!mIsActivityRecreated && intent != null && Intent.ACTION_RUN.equals(intent.getAction())) {
|
||||
// Android 7.1 app shortcut from res/xml/shortcuts.xml.
|
||||
boolean isFailSafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false);
|
||||
mTermuxTerminalSessionActivityClient.addNewSession(isFailSafe, null);
|
||||
} else {
|
||||
mTermuxTerminalSessionActivityClient.setCurrentSession(mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast());
|
||||
}
|
||||
}
|
||||
|
||||
// Update the {@link TerminalSession} and {@link TerminalEmulator} clients.
|
||||
mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionActivityClient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
Logger.logDebug(LOG_TAG, "onServiceDisconnected");
|
||||
|
||||
// Respect being stopped from the {@link TermuxService} notification action.
|
||||
finishActivityIfNotFinishing();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private void reloadProperties() {
|
||||
mProperties.loadTermuxPropertiesFromDisk();
|
||||
|
||||
if (mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onReloadProperties();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void setActivityTheme() {
|
||||
// Update NightMode.APP_NIGHT_MODE
|
||||
TermuxThemeUtils.setAppNightMode(mProperties.getNightMode());
|
||||
}
|
||||
|
||||
private void setMargins() {
|
||||
RelativeLayout relativeLayout = binding.activityTermuxRootRelativeLayout;
|
||||
int marginHorizontal = mProperties.getTerminalMarginHorizontal();
|
||||
int marginVertical = mProperties.getTerminalMarginVertical();
|
||||
ViewUtils.setLayoutMarginsInDp(relativeLayout, marginHorizontal, marginVertical, marginHorizontal, marginVertical);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void addTermuxActivityRootViewGlobalLayoutListener() {
|
||||
getTermuxActivityRootView().getViewTreeObserver().addOnGlobalLayoutListener(getTermuxActivityRootView());
|
||||
}
|
||||
|
||||
public void removeTermuxActivityRootViewGlobalLayoutListener() {
|
||||
if (getTermuxActivityRootView() != null)
|
||||
getTermuxActivityRootView().getViewTreeObserver().removeOnGlobalLayoutListener(getTermuxActivityRootView());
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void setTermuxTerminalViewAndClients() {
|
||||
// Set termux terminal view and session clients
|
||||
mTermuxTerminalSessionActivityClient = new TermuxTerminalSessionActivityClient(this);
|
||||
mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionActivityClient);
|
||||
|
||||
// Set termux terminal view
|
||||
binding.terminalView.setTerminalViewClient(mTermuxTerminalViewClient);
|
||||
|
||||
if (mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onCreate();
|
||||
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onCreate();
|
||||
}
|
||||
|
||||
private void setTermuxSessionsListView() {
|
||||
ListView termuxSessionsListView = binding.terminalSessionsList;
|
||||
mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions());
|
||||
termuxSessionsListView.setAdapter(mTermuxSessionListViewController);
|
||||
termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController);
|
||||
termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void setTerminalToolbarView(Bundle savedInstanceState) {
|
||||
mTermuxTerminalExtraKeys = new TermuxTerminalExtraKeys(this, binding.terminalView,
|
||||
mTermuxTerminalViewClient, mTermuxTerminalSessionActivityClient);
|
||||
|
||||
final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager();
|
||||
if (mPreferences.shouldShowTerminalToolbar()) terminalToolbarViewPager.setVisibility(View.VISIBLE);
|
||||
|
||||
ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams();
|
||||
mTerminalToolbarDefaultHeight = layoutParams.height;
|
||||
|
||||
setTerminalToolbarHeight();
|
||||
|
||||
String savedTextInput = null;
|
||||
if (savedInstanceState != null)
|
||||
savedTextInput = savedInstanceState.getString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT);
|
||||
|
||||
terminalToolbarViewPager.setAdapter(new TerminalToolbarViewPager.PageAdapter(this, savedTextInput));
|
||||
terminalToolbarViewPager.addOnPageChangeListener(new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager));
|
||||
}
|
||||
|
||||
private void setTerminalToolbarHeight() {
|
||||
final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager();
|
||||
if (terminalToolbarViewPager == null) return;
|
||||
|
||||
ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams();
|
||||
layoutParams.height = Math.round(mTerminalToolbarDefaultHeight *
|
||||
(mTermuxTerminalExtraKeys.getExtraKeysInfo() == null ? 0 : mTermuxTerminalExtraKeys.getExtraKeysInfo().getMatrix().length) *
|
||||
mProperties.getTerminalToolbarHeightScaleFactor());
|
||||
terminalToolbarViewPager.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
public void toggleTerminalToolbar() {
|
||||
final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager();
|
||||
if (terminalToolbarViewPager == null) return;
|
||||
|
||||
final boolean showNow = mPreferences.toogleShowTerminalToolbar();
|
||||
Logger.showToast(this, (showNow ? getString(R.string.msg_enabling_terminal_toolbar) : getString(R.string.msg_disabling_terminal_toolbar)), true);
|
||||
terminalToolbarViewPager.setVisibility(showNow ? View.VISIBLE : View.GONE);
|
||||
if (showNow && isTerminalToolbarTextInputViewSelected()) {
|
||||
// Focus the text input view if just revealed.
|
||||
findViewById(R.id.terminal_toolbar_text_input).requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveTerminalToolbarTextInput(Bundle savedInstanceState) {
|
||||
if (savedInstanceState == null) return;
|
||||
|
||||
final EditText textInputView = findViewById(R.id.terminal_toolbar_text_input);
|
||||
if (textInputView != null) {
|
||||
String textInput = textInputView.getText().toString();
|
||||
if (!textInput.isEmpty()) savedInstanceState.putString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT, textInput);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNewSessionButtonView() {
|
||||
binding.newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionActivityClient.addNewSession(false, null));
|
||||
binding.newSessionButton.setOnLongClickListener(v -> {
|
||||
TextInputDialogUtils.textInput(TermuxActivity.this, R.string.title_create_named_session, null,
|
||||
R.string.action_create_named_session_confirm, text -> mTermuxTerminalSessionActivityClient.addNewSession(false, text),
|
||||
R.string.action_new_session_failsafe, text -> mTermuxTerminalSessionActivityClient.addNewSession(true, text),
|
||||
-1, null, null);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void setToggleKeyboardView() {
|
||||
binding.toggleKeyboardButton.setOnClickListener(v -> {
|
||||
mTermuxTerminalViewClient.onToggleSoftKeyboardRequest();
|
||||
getDrawer().closeDrawers();
|
||||
});
|
||||
|
||||
binding.toggleKeyboardButton.setOnLongClickListener(v -> {
|
||||
toggleTerminalToolbar();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (KeyboardUtils.isSoftKeyboardVisible(this)) {
|
||||
KeyboardUtils.showSoftKeyboard(this, getTerminalView());
|
||||
} else if (getDrawer().isDrawerOpen(Gravity.LEFT)) {
|
||||
getDrawer().closeDrawers();
|
||||
} else {
|
||||
finishActivityIfNotFinishing();
|
||||
}
|
||||
}
|
||||
|
||||
public void finishActivityIfNotFinishing() {
|
||||
// prevent duplicate calls to finish() if called from multiple places
|
||||
if (!TermuxActivity.this.isFinishing()) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
/** Show a toast and dismiss the last one if still visible. */
|
||||
public void showToast(String text, boolean longDuration) {
|
||||
if (text == null || text.isEmpty()) return;
|
||||
if (mLastToast != null) mLastToast.cancel();
|
||||
mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT);
|
||||
mLastToast.setGravity(Gravity.TOP, 0, 0);
|
||||
mLastToast.show();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
|
||||
TerminalSession currentSession = getCurrentSession();
|
||||
if (currentSession == null) return;
|
||||
|
||||
boolean addAutoFillMenu = false;
|
||||
AutofillManager autofillManager = getSystemService(AutofillManager.class);
|
||||
if (autofillManager != null && autofillManager.isEnabled()) {
|
||||
addAutoFillMenu = true;
|
||||
}
|
||||
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.action_select_url);
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.action_share_transcript);
|
||||
if (!DataUtils.isNullOrEmpty(binding.terminalView.getStoredSelectedText()))
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_SHARE_SELECTED_TEXT, Menu.NONE, R.string.action_share_selected_text);
|
||||
if (addAutoFillMenu)
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.action_autofill_password);
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.action_reset_terminal);
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.action_kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning());
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.action_toggle_keep_screen_on).setCheckable(true).setChecked(mPreferences.shouldKeepScreenOn());
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_SETTINGS_ID, Menu.NONE, R.string.action_open_settings);
|
||||
menu.add(Menu.NONE, CONTEXT_MENU_REPORT_ID, Menu.NONE, R.string.action_report_issue);
|
||||
}
|
||||
|
||||
/** Hook system menu to show context menu instead. */
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
binding.terminalView.showContextMenu();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onContextItemSelected(MenuItem item) {
|
||||
TerminalSession session = getCurrentSession();
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case CONTEXT_MENU_SELECT_URL_ID:
|
||||
mTermuxTerminalViewClient.showUrlSelection();
|
||||
return true;
|
||||
case CONTEXT_MENU_SHARE_TRANSCRIPT_ID:
|
||||
mTermuxTerminalViewClient.shareSessionTranscript();
|
||||
return true;
|
||||
case CONTEXT_MENU_SHARE_SELECTED_TEXT:
|
||||
mTermuxTerminalViewClient.shareSelectedText();
|
||||
return true;
|
||||
case CONTEXT_MENU_AUTOFILL_ID:
|
||||
requestAutoFill();
|
||||
return true;
|
||||
case CONTEXT_MENU_RESET_TERMINAL_ID:
|
||||
onResetTerminalSession(session);
|
||||
return true;
|
||||
case CONTEXT_MENU_KILL_PROCESS_ID:
|
||||
showKillSessionDialog(session);
|
||||
return true;
|
||||
case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON:
|
||||
toggleKeepScreenOn();
|
||||
return true;
|
||||
case CONTEXT_MENU_REPORT_ID:
|
||||
mTermuxTerminalViewClient.reportIssueFromTranscript();
|
||||
return true;
|
||||
default:
|
||||
return super.onContextItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContextMenuClosed(Menu menu) {
|
||||
super.onContextMenuClosed(menu);
|
||||
// onContextMenuClosed() is triggered twice if back button is pressed to dismiss instead of tap for some reason
|
||||
binding.terminalView.onContextMenuClosed(menu);
|
||||
}
|
||||
|
||||
private void showKillSessionDialog(TerminalSession session) {
|
||||
if (session == null) return;
|
||||
|
||||
final AlertDialog.Builder b = new AlertDialog.Builder(this);
|
||||
b.setIcon(android.R.drawable.ic_dialog_alert);
|
||||
b.setMessage(R.string.title_confirm_kill_process);
|
||||
b.setPositiveButton(android.R.string.yes, (dialog, id) -> {
|
||||
dialog.dismiss();
|
||||
session.finishIfRunning();
|
||||
});
|
||||
b.setNegativeButton(android.R.string.no, null);
|
||||
b.show();
|
||||
}
|
||||
|
||||
private void onResetTerminalSession(TerminalSession session) {
|
||||
if (session != null) {
|
||||
session.reset();
|
||||
showToast(getResources().getString(R.string.msg_terminal_reset), true);
|
||||
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onResetTerminalSession();
|
||||
}
|
||||
}
|
||||
|
||||
private void toggleKeepScreenOn() {
|
||||
if (binding.terminalView.getKeepScreenOn()) {
|
||||
binding.terminalView.setKeepScreenOn(false);
|
||||
mPreferences.setKeepScreenOn(false);
|
||||
} else {
|
||||
binding.terminalView.setKeepScreenOn(true);
|
||||
mPreferences.setKeepScreenOn(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestAutoFill() {
|
||||
AutofillManager autofillManager = getSystemService(AutofillManager.class);
|
||||
if (autofillManager != null && autofillManager.isEnabled()) {
|
||||
autofillManager.requestAutofill(binding.terminalView);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* For processes to access primary external storage (/sdcard, /storage/emulated/0, ~/storage/shared),
|
||||
* termux needs to be granted legacy WRITE_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE permissions
|
||||
* if targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) and higher.
|
||||
*/
|
||||
public void requestStoragePermission(boolean isPermissionCallback) {
|
||||
new Thread(() -> {
|
||||
// Do not ask for permission again
|
||||
int requestCode = isPermissionCallback ? -1 : PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION;
|
||||
|
||||
// If permission is granted, then also setup storage symlinks.
|
||||
if(PermissionUtils.checkAndRequestLegacyOrManageExternalStoragePermission(
|
||||
TermuxActivity.this, requestCode, !isPermissionCallback)) {
|
||||
if (isPermissionCallback)
|
||||
Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG,
|
||||
getString(com.termux.shared.R.string.msg_storage_permission_granted_on_request));
|
||||
|
||||
TermuxInstaller.setupStorageSymlinks(TermuxActivity.this);
|
||||
} else {
|
||||
if (isPermissionCallback)
|
||||
Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG,
|
||||
getString(com.termux.shared.R.string.msg_storage_permission_not_granted_on_request));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data));
|
||||
if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) {
|
||||
requestStoragePermission(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
Logger.logVerbose(LOG_TAG, "onRequestPermissionsResult: requestCode: " + requestCode + ", permissions: " + Arrays.toString(permissions) + ", grantResults: " + Arrays.toString(grantResults));
|
||||
if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) {
|
||||
requestStoragePermission(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public int getNavBarHeight() {
|
||||
return mNavBarHeight;
|
||||
}
|
||||
|
||||
public TermuxActivityRootView getTermuxActivityRootView() {
|
||||
return binding.activityTermuxRootView;
|
||||
}
|
||||
|
||||
public View getTermuxActivityBottomSpaceView() {
|
||||
return binding.activityTermuxBottomSpaceView;
|
||||
}
|
||||
|
||||
public ExtraKeysView getExtraKeysView() {
|
||||
return mExtraKeysView;
|
||||
}
|
||||
|
||||
public TermuxTerminalExtraKeys getTermuxTerminalExtraKeys() {
|
||||
return mTermuxTerminalExtraKeys;
|
||||
}
|
||||
|
||||
public void setExtraKeysView(ExtraKeysView extraKeysView) {
|
||||
mExtraKeysView = extraKeysView;
|
||||
}
|
||||
|
||||
public DrawerLayout getDrawer() {
|
||||
return (DrawerLayout) findViewById(R.id.drawer_layout);
|
||||
}
|
||||
|
||||
|
||||
public ViewPager getTerminalToolbarViewPager() {
|
||||
return binding.terminalToolbarViewPager;
|
||||
}
|
||||
|
||||
public float getTerminalToolbarDefaultHeight() {
|
||||
return mTerminalToolbarDefaultHeight;
|
||||
}
|
||||
|
||||
public boolean isTerminalViewSelected() {
|
||||
return getTerminalToolbarViewPager().getCurrentItem() == 0;
|
||||
}
|
||||
|
||||
public boolean isTerminalToolbarTextInputViewSelected() {
|
||||
return getTerminalToolbarViewPager().getCurrentItem() == 1;
|
||||
}
|
||||
|
||||
|
||||
public void termuxSessionListNotifyUpdated() {
|
||||
mTermuxSessionListViewController.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return mIsVisible;
|
||||
}
|
||||
|
||||
public boolean isOnResumeAfterOnCreate() {
|
||||
return mIsOnResumeAfterOnCreate;
|
||||
}
|
||||
|
||||
public boolean isActivityRecreated() {
|
||||
return mIsActivityRecreated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public TermuxService getTermuxService() {
|
||||
return mTermuxService;
|
||||
}
|
||||
|
||||
public TerminalView getTerminalView() {
|
||||
return binding.terminalView;
|
||||
}
|
||||
|
||||
public TermuxTerminalViewClient getTermuxTerminalViewClient() {
|
||||
return mTermuxTerminalViewClient;
|
||||
}
|
||||
|
||||
public TermuxTerminalSessionActivityClient getTermuxTerminalSessionClient() {
|
||||
return mTermuxTerminalSessionActivityClient;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public TerminalSession getCurrentSession() {
|
||||
if (binding != null)
|
||||
return binding.terminalView.getCurrentSession();
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
public TermuxAppSharedPreferences getPreferences() {
|
||||
return mPreferences;
|
||||
}
|
||||
|
||||
public TermuxAppSharedProperties getProperties() {
|
||||
return mProperties;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static void updateTermuxActivityStyling(Context context, boolean recreateActivity) {
|
||||
// Make sure that terminal styling is always applied.
|
||||
Intent stylingIntent = new Intent(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE);
|
||||
stylingIntent.putExtra(TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY, recreateActivity);
|
||||
context.sendBroadcast(stylingIntent);
|
||||
}
|
||||
|
||||
private void registerTermuxActivityBroadcastReceiver() {
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH);
|
||||
intentFilter.addAction(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE);
|
||||
intentFilter.addAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS);
|
||||
|
||||
registerReceiver(mTermuxActivityBroadcastReceiver, intentFilter);
|
||||
}
|
||||
|
||||
private void unregisterTermuxActivityBroadcastReceiver() {
|
||||
unregisterReceiver(mTermuxActivityBroadcastReceiver);
|
||||
}
|
||||
|
||||
private void fixTermuxActivityBroadcastReceiverIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
|
||||
String extraReloadStyle = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE);
|
||||
if ("storage".equals(extraReloadStyle)) {
|
||||
intent.removeExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE);
|
||||
intent.setAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
class TermuxActivityBroadcastReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
|
||||
if (mIsVisible) {
|
||||
fixTermuxActivityBroadcastReceiverIntent(intent);
|
||||
|
||||
switch (intent.getAction()) {
|
||||
case TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH:
|
||||
Logger.logDebug(LOG_TAG, "Received intent to notify app crash");
|
||||
TermuxCrashUtils.notifyAppCrashFromCrashLogFile(context, LOG_TAG);
|
||||
return;
|
||||
case TERMUX_ACTIVITY.ACTION_RELOAD_STYLE:
|
||||
Logger.logDebug(LOG_TAG, "Received intent to reload styling");
|
||||
reloadActivityStyling(intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY, true));
|
||||
return;
|
||||
case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS:
|
||||
Logger.logDebug(LOG_TAG, "Received intent to request storage permissions");
|
||||
requestStoragePermission(false);
|
||||
return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reloadActivityStyling(boolean recreateActivity) {
|
||||
if (mProperties != null) {
|
||||
reloadProperties();
|
||||
|
||||
if (mExtraKeysView != null) {
|
||||
mExtraKeysView.setButtonTextAllCaps(mProperties.shouldExtraKeysTextBeAllCaps());
|
||||
mExtraKeysView.reload(mTermuxTerminalExtraKeys.getExtraKeysInfo(), mTerminalToolbarDefaultHeight);
|
||||
}
|
||||
|
||||
// Update NightMode.APP_NIGHT_MODE
|
||||
TermuxThemeUtils.setAppNightMode(mProperties.getNightMode());
|
||||
}
|
||||
|
||||
setMargins();
|
||||
setTerminalToolbarHeight();
|
||||
|
||||
FileReceiverActivity.updateFileReceiverActivityComponentsState(this);
|
||||
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onReloadActivityStyling();
|
||||
|
||||
if (mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onReloadActivityStyling();
|
||||
|
||||
// To change the activity and drawer theme, activity needs to be recreated.
|
||||
// It will destroy the activity, including all stored variables and views, and onCreate()
|
||||
// will be called again. Extra keys input text, terminal sessions and transcripts will be preserved.
|
||||
if (recreateActivity) {
|
||||
Logger.logDebug(LOG_TAG, "Recreating activity");
|
||||
TermuxActivity.this.recreate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static void startTermuxActivity(@NonNull final Context context) {
|
||||
ActivityUtils.startActivity(context, newInstance(context));
|
||||
}
|
||||
|
||||
public static Intent newInstance(@NonNull final Context context) {
|
||||
Intent intent = new Intent(context, TermuxActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
return intent;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.termux.app;
|
||||
|
||||
import android.content.Context;
|
||||
import com.itsaky.androidide.app.BaseApplication;
|
||||
import com.termux.shared.errors.Error;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.shared.termux.crash.TermuxCrashUtils;
|
||||
import com.termux.shared.termux.file.TermuxFileUtils;
|
||||
import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences;
|
||||
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
|
||||
import com.termux.shared.termux.shell.TermuxShellManager;
|
||||
import com.termux.shared.termux.shell.am.TermuxAmSocketServer;
|
||||
import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
|
||||
import com.termux.shared.termux.theme.TermuxThemeUtils;
|
||||
|
||||
public class TermuxApplication extends BaseApplication {
|
||||
|
||||
private static final String LOG_TAG = "TermuxApplication";
|
||||
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
Context context = getApplicationContext();
|
||||
|
||||
// Set crash handler for the app
|
||||
TermuxCrashUtils.setDefaultCrashHandler(this);
|
||||
|
||||
// Set log config for the app
|
||||
setLogConfig(context);
|
||||
|
||||
Logger.logDebug("Starting Application");
|
||||
|
||||
// Init app wide SharedProperties loaded from termux.properties
|
||||
TermuxAppSharedProperties properties = TermuxAppSharedProperties.init(context);
|
||||
|
||||
// Init app wide shell manager
|
||||
TermuxShellManager shellManager = TermuxShellManager.init(context);
|
||||
|
||||
// Set NightMode.APP_NIGHT_MODE
|
||||
TermuxThemeUtils.setAppNightMode(properties.getNightMode());
|
||||
|
||||
// Check and create termux files directory. If failed to access it like in case of secondary
|
||||
// user or external sd card installation, then don't run files directory related code
|
||||
Error error = TermuxFileUtils.isTermuxFilesDirectoryAccessible(this, true, true);
|
||||
boolean isTermuxFilesDirectoryAccessible = error == null;
|
||||
if (isTermuxFilesDirectoryAccessible) {
|
||||
Logger.logInfo(LOG_TAG, "Termux files directory is accessible");
|
||||
|
||||
error = TermuxFileUtils.isAppsTermuxAppDirectoryAccessible(true, true);
|
||||
if (error != null) {
|
||||
Logger.logErrorExtended(LOG_TAG, "Create apps/termux-app directory failed\n" + error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup termux-am-socket server
|
||||
TermuxAmSocketServer.setupTermuxAmSocketServer(context);
|
||||
} else {
|
||||
Logger.logErrorExtended(LOG_TAG, "Termux files directory is not accessible\n" + error);
|
||||
}
|
||||
|
||||
// Init TermuxShellEnvironment constants and caches after everything has been setup including termux-am-socket server
|
||||
TermuxShellEnvironment.init(this);
|
||||
|
||||
if (isTermuxFilesDirectoryAccessible) {
|
||||
TermuxShellEnvironment.writeEnvironmentToFile(this);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setLogConfig(Context context) {
|
||||
Logger.setDefaultLogTag(TermuxConstants.TERMUX_APP_NAME);
|
||||
|
||||
// Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL}
|
||||
TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context);
|
||||
if (preferences == null) return;
|
||||
preferences.setLogLevel(null, preferences.getLogLevel());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.termux.app;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.system.Os;
|
||||
import android.util.Pair;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.termux.R;
|
||||
import com.termux.shared.file.FileUtils;
|
||||
import com.termux.shared.termux.crash.TermuxCrashUtils;
|
||||
import com.termux.shared.termux.file.TermuxFileUtils;
|
||||
import com.termux.shared.interact.MessageDialogUtils;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.markdown.MarkdownUtils;
|
||||
import com.termux.shared.errors.Error;
|
||||
import com.termux.shared.android.PackageUtils;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.shared.termux.TermuxUtils;
|
||||
import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR;
|
||||
import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR_PATH;
|
||||
import static com.termux.shared.termux.TermuxConstants.TERMUX_STAGING_PREFIX_DIR;
|
||||
import static com.termux.shared.termux.TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH;
|
||||
|
||||
/**
|
||||
* 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 directory below.
|
||||
* <p/>
|
||||
* (2) A progress dialog is shown with "Installing..." message and a spinner.
|
||||
* <p/>
|
||||
* (3) A staging directory, $STAGING_PREFIX, is cleared 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.
|
||||
*/
|
||||
public final class TermuxInstaller {
|
||||
|
||||
private static final String LOG_TAG = "TermuxInstaller";
|
||||
|
||||
/** Performs bootstrap setup if necessary. */
|
||||
static void setupBootstrapIfNeeded(final Activity activity, final Runnable whenDone) {
|
||||
String bootstrapErrorMessage;
|
||||
Error filesDirectoryAccessibleError;
|
||||
|
||||
// This will also call Context.getFilesDir(), which should ensure that termux files directory
|
||||
// is created if it does not already exist
|
||||
filesDirectoryAccessibleError = TermuxFileUtils.isTermuxFilesDirectoryAccessible(activity, true, true);
|
||||
boolean isFilesDirectoryAccessible = filesDirectoryAccessibleError == null;
|
||||
|
||||
// Termux can only be run as the primary user (device owner) since only that
|
||||
// account has the expected file system paths. Verify that:
|
||||
if (!PackageUtils.isCurrentUserThePrimaryUser(activity)) {
|
||||
bootstrapErrorMessage = activity.getString(R.string.bootstrap_error_not_primary_user_message,
|
||||
MarkdownUtils.getMarkdownCodeForString(TERMUX_PREFIX_DIR_PATH, false));
|
||||
Logger.logError(LOG_TAG, "isFilesDirectoryAccessible: " + isFilesDirectoryAccessible);
|
||||
Logger.logError(LOG_TAG, bootstrapErrorMessage);
|
||||
sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage);
|
||||
MessageDialogUtils.exitAppWithErrorMessage(activity,
|
||||
activity.getString(R.string.bootstrap_error_title),
|
||||
bootstrapErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFilesDirectoryAccessible) {
|
||||
bootstrapErrorMessage = Error.getMinimalErrorString(filesDirectoryAccessibleError);
|
||||
//noinspection SdCardPath
|
||||
if (PackageUtils.isAppInstalledOnExternalStorage(activity) &&
|
||||
!TermuxConstants.TERMUX_FILES_DIR_PATH.equals(activity.getFilesDir().getAbsolutePath().replaceAll("^/data/user/0/", "/data/data/"))) {
|
||||
bootstrapErrorMessage += "\n\n" + activity.getString(R.string.bootstrap_error_installed_on_portable_sd,
|
||||
MarkdownUtils.getMarkdownCodeForString(TERMUX_PREFIX_DIR_PATH, false));
|
||||
}
|
||||
|
||||
Logger.logError(LOG_TAG, bootstrapErrorMessage);
|
||||
sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage);
|
||||
MessageDialogUtils.showMessage(activity,
|
||||
activity.getString(R.string.bootstrap_error_title),
|
||||
bootstrapErrorMessage, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If prefix directory exists, even if its a symlink to a valid directory and symlink is not broken/dangling
|
||||
if (FileUtils.directoryFileExists(TERMUX_PREFIX_DIR_PATH, true)) {
|
||||
if (TermuxFileUtils.isTermuxPrefixDirectoryEmpty()) {
|
||||
Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" exists but is empty or only contains specific unimportant files.");
|
||||
} else {
|
||||
whenDone.run();
|
||||
return;
|
||||
}
|
||||
} else if (FileUtils.fileExists(TERMUX_PREFIX_DIR_PATH, false)) {
|
||||
Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" does not exist but another file exists at its destination.");
|
||||
}
|
||||
|
||||
final ProgressDialog progress = ProgressDialog.show(activity, null, activity.getString(R.string.bootstrap_installer_body), true, false);
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
Logger.logInfo(LOG_TAG, "Installing " + TermuxConstants.TERMUX_APP_NAME + " bootstrap packages.");
|
||||
|
||||
Error error;
|
||||
|
||||
// Delete prefix staging directory or any file at its destination
|
||||
error = FileUtils.deleteFile("termux prefix staging directory", TERMUX_STAGING_PREFIX_DIR_PATH, true);
|
||||
if (error != null) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete prefix directory or any file at its destination
|
||||
error = FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true);
|
||||
if (error != null) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create prefix staging directory if it does not already exist and set required permissions
|
||||
error = TermuxFileUtils.isTermuxPrefixStagingDirectoryAccessible(true, true);
|
||||
if (error != null) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create prefix directory if it does not already exist and set required permissions
|
||||
error = TermuxFileUtils.isTermuxPrefixDirectoryAccessible(true, true);
|
||||
if (error != null) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.logInfo(LOG_TAG, "Extracting bootstrap zip to prefix staging directory \"" + TERMUX_STAGING_PREFIX_DIR_PATH + "\".");
|
||||
|
||||
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 = TERMUX_STAGING_PREFIX_DIR_PATH + "/" + parts[1];
|
||||
symlinks.add(Pair.create(oldPath, newPath));
|
||||
|
||||
error = ensureDirectoryExists(new File(newPath).getParentFile());
|
||||
if (error != null) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String zipEntryName = zipEntry.getName();
|
||||
File targetFile = new File(TERMUX_STAGING_PREFIX_DIR_PATH, zipEntryName);
|
||||
boolean isDirectory = zipEntry.isDirectory();
|
||||
|
||||
error = ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile());
|
||||
if (error != null) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
|
||||
return;
|
||||
}
|
||||
|
||||
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/apt-helper") || 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);
|
||||
}
|
||||
|
||||
Logger.logInfo(LOG_TAG, "Moving termux prefix staging to prefix directory.");
|
||||
|
||||
if (!TERMUX_STAGING_PREFIX_DIR.renameTo(TERMUX_PREFIX_DIR)) {
|
||||
throw new RuntimeException("Moving termux prefix staging to prefix directory failed");
|
||||
}
|
||||
|
||||
Logger.logInfo(LOG_TAG, "Bootstrap packages installed successfully.");
|
||||
|
||||
// Recreate env file since termux prefix was wiped earlier
|
||||
TermuxShellEnvironment.writeEnvironmentToFile(activity);
|
||||
|
||||
activity.runOnUiThread(whenDone);
|
||||
|
||||
} catch (final Exception e) {
|
||||
showBootstrapErrorDialog(activity, whenDone, Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)));
|
||||
|
||||
} finally {
|
||||
activity.runOnUiThread(() -> {
|
||||
try {
|
||||
progress.dismiss();
|
||||
} catch (RuntimeException e) {
|
||||
// Activity already dismissed - ignore.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
public static void showBootstrapErrorDialog(Activity activity, Runnable whenDone, String message) {
|
||||
Logger.logErrorExtended(LOG_TAG, "Bootstrap Error:\n" + message);
|
||||
|
||||
// Send a notification with the exception so that the user knows why bootstrap setup failed
|
||||
sendBootstrapCrashReportNotification(activity, message);
|
||||
|
||||
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();
|
||||
FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true);
|
||||
TermuxInstaller.setupBootstrapIfNeeded(activity, whenDone);
|
||||
}).show();
|
||||
} catch (WindowManager.BadTokenException e1) {
|
||||
// Activity already dismissed - ignore.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void sendBootstrapCrashReportNotification(Activity activity, String message) {
|
||||
final String title = TermuxConstants.TERMUX_APP_NAME + " Bootstrap Error";
|
||||
|
||||
// Add info of all install Termux plugin apps as well since their target sdk or installation
|
||||
// on external/portable sd card can affect Termux app files directory access or exec.
|
||||
TermuxCrashUtils.sendCrashReportNotification(activity, LOG_TAG,
|
||||
title, null, "## " + title + "\n\n" + message + "\n\n" +
|
||||
TermuxUtils.getTermuxDebugMarkdownString(activity),
|
||||
true, false, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES, true);
|
||||
}
|
||||
|
||||
static void setupStorageSymlinks(final Context context) {
|
||||
final String LOG_TAG = "termux-storage";
|
||||
final String title = TermuxConstants.TERMUX_APP_NAME + " Setup Storage Error";
|
||||
|
||||
Logger.logInfo(LOG_TAG, "Setting up storage symlinks.");
|
||||
|
||||
new Thread() {
|
||||
public void run() {
|
||||
try {
|
||||
Error error;
|
||||
File storageDir = TermuxConstants.TERMUX_STORAGE_HOME_DIR;
|
||||
|
||||
error = FileUtils.clearDirectory("~/storage", storageDir.getAbsolutePath());
|
||||
if (error != null) {
|
||||
Logger.logErrorAndShowToast(context, LOG_TAG, error.getMessage());
|
||||
Logger.logErrorExtended(LOG_TAG, "Setup Storage Error\n" + error.toString());
|
||||
TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null,
|
||||
"## " + title + "\n\n" + Error.getErrorMarkdownString(error),
|
||||
true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/shared, ~/storage/downloads, ~/storage/dcim, ~/storage/pictures, ~/storage/music and ~/storage/movies for directories in \"" + Environment.getExternalStorageDirectory().getAbsolutePath() + "\".");
|
||||
|
||||
// Get primary storage root "/storage/emulated/0" symlink
|
||||
File sharedDir = Environment.getExternalStorageDirectory();
|
||||
Os.symlink(sharedDir.getAbsolutePath(), new File(storageDir, "shared").getAbsolutePath());
|
||||
|
||||
File documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
|
||||
Os.symlink(documentsDir.getAbsolutePath(), new File(storageDir, "documents").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());
|
||||
|
||||
File podcastsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PODCASTS);
|
||||
Os.symlink(podcastsDir.getAbsolutePath(), new File(storageDir, "podcasts").getAbsolutePath());
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
File audiobooksDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_AUDIOBOOKS);
|
||||
Os.symlink(audiobooksDir.getAbsolutePath(), new File(storageDir, "audiobooks").getAbsolutePath());
|
||||
}
|
||||
|
||||
// Dir 0 should ideally be for primary storage
|
||||
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ContextImpl.java;l=818
|
||||
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java;l=219
|
||||
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java;l=181
|
||||
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/StorageManagerService.java;l=3796
|
||||
// https://cs.android.com/android/platform/superproject/+/android-7.0.0_r36:frameworks/base/services/core/java/com/android/server/MountService.java;l=3053
|
||||
|
||||
// Create "Android/data/com.termux" symlinks
|
||||
File[] dirs = context.getExternalFilesDirs(null);
|
||||
if (dirs != null && dirs.length > 0) {
|
||||
for (int i = 0; i < dirs.length; i++) {
|
||||
File dir = dirs[i];
|
||||
if (dir == null) continue;
|
||||
String symlinkName = "external-" + i;
|
||||
Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\".");
|
||||
Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
// Create "Android/media/com.termux" symlinks
|
||||
dirs = context.getExternalMediaDirs();
|
||||
if (dirs != null && dirs.length > 0) {
|
||||
for (int i = 0; i < dirs.length; i++) {
|
||||
File dir = dirs[i];
|
||||
if (dir == null) continue;
|
||||
String symlinkName = "media-" + i;
|
||||
Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\".");
|
||||
Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
Logger.logInfo(LOG_TAG, "Storage symlinks created successfully.");
|
||||
} catch (Exception e) {
|
||||
Logger.logErrorAndShowToast(context, LOG_TAG, e.getMessage());
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "Setup Storage Error: Error setting up link", e);
|
||||
TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null,
|
||||
"## " + title + "\n\n" + Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)),
|
||||
true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true);
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
private static Error ensureDirectoryExists(File directory) {
|
||||
return FileUtils.createDirectoryFile(directory.getAbsolutePath());
|
||||
}
|
||||
|
||||
public static byte[] loadZipBytes() {
|
||||
// Only load the shared library when necessary to save memory usage.
|
||||
System.loadLibrary("termux-bootstrap");
|
||||
return getZip();
|
||||
}
|
||||
|
||||
public static native byte[] getZip();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
package com.termux.app;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Resources;
|
||||
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 androidx.annotation.Nullable;
|
||||
import com.termux.R;
|
||||
import com.termux.app.terminal.TermuxTerminalSessionActivityClient;
|
||||
import com.termux.app.terminal.TermuxTerminalSessionServiceClient;
|
||||
import com.termux.shared.android.PermissionUtils;
|
||||
import com.termux.shared.data.DataUtils;
|
||||
import com.termux.shared.data.IntentUtils;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.notification.NotificationUtils;
|
||||
import com.termux.shared.shell.command.ExecutionCommand;
|
||||
import com.termux.shared.shell.command.ExecutionCommand.Runner;
|
||||
import com.termux.shared.shell.command.runner.app.AppShell;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE;
|
||||
import com.termux.shared.termux.plugins.TermuxPluginUtils;
|
||||
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
|
||||
import com.termux.shared.termux.shell.TermuxShellManager;
|
||||
import com.termux.shared.termux.shell.TermuxShellUtils;
|
||||
import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
|
||||
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
|
||||
import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase;
|
||||
import com.termux.terminal.TerminalEmulator;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
import com.termux.terminal.TerminalSessionClient;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A service holding a list of {@link TermuxSession} in {@link TermuxShellManager#mTermuxSessions} and background {@link AppShell}
|
||||
* in {@link TermuxShellManager#mTermuxTasks}, showing a foreground notification while running so that it is not terminated.
|
||||
* The user interacts with the session through {@link TermuxActivity}, but this service may outlive
|
||||
* the activity when the user or the system disposes of the activity. In that case the user may
|
||||
* restart {@link TermuxActivity} later to yet again access the sessions.
|
||||
* <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 AppShell.AppShellClient, TermuxSession.TermuxSessionClient {
|
||||
|
||||
/** 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 full implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
|
||||
* that holds activity references for activity related functions.
|
||||
* Note that the service may often outlive the activity, so need to clear this reference.
|
||||
*/
|
||||
private TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
|
||||
|
||||
/** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
|
||||
* that does not hold activity references and only a service reference.
|
||||
*/
|
||||
private final TermuxTerminalSessionServiceClient mTermuxTerminalSessionServiceClient = new TermuxTerminalSessionServiceClient(this);
|
||||
|
||||
/**
|
||||
* Termux app shared properties manager, loaded from termux.properties
|
||||
*/
|
||||
private TermuxAppSharedProperties mProperties;
|
||||
|
||||
/**
|
||||
* Termux app shell manager
|
||||
*/
|
||||
private TermuxShellManager mShellManager;
|
||||
|
||||
/** 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 TERMUX_SERVICE#ACTION_STOP_SERVICE} intent. */
|
||||
boolean mWantsToStop = false;
|
||||
|
||||
private static final String LOG_TAG = "TermuxService";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
Logger.logVerbose(LOG_TAG, "onCreate");
|
||||
|
||||
// Get Termux app SharedProperties without loading from disk since TermuxApplication handles
|
||||
// load and TermuxActivity handles reloads
|
||||
mProperties = TermuxAppSharedProperties.getProperties();
|
||||
|
||||
mShellManager = TermuxShellManager.getShellManager();
|
||||
|
||||
runStartForeground();
|
||||
}
|
||||
|
||||
@SuppressLint("Wakelock")
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
Logger.logDebug(LOG_TAG, "onStartCommand");
|
||||
|
||||
// Run again in case service is already started and onCreate() is not called
|
||||
runStartForeground();
|
||||
|
||||
String action = null;
|
||||
if (intent != null) {
|
||||
Logger.logVerboseExtended(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent));
|
||||
action = intent.getAction();
|
||||
}
|
||||
|
||||
if (action != null) {
|
||||
switch (action) {
|
||||
case TERMUX_SERVICE.ACTION_STOP_SERVICE:
|
||||
Logger.logDebug(LOG_TAG, "ACTION_STOP_SERVICE intent received");
|
||||
actionStopService();
|
||||
break;
|
||||
case TERMUX_SERVICE.ACTION_WAKE_LOCK:
|
||||
Logger.logDebug(LOG_TAG, "ACTION_WAKE_LOCK intent received");
|
||||
actionAcquireWakeLock();
|
||||
break;
|
||||
case TERMUX_SERVICE.ACTION_WAKE_UNLOCK:
|
||||
Logger.logDebug(LOG_TAG, "ACTION_WAKE_UNLOCK intent received");
|
||||
actionReleaseWakeLock(true);
|
||||
break;
|
||||
default:
|
||||
Logger.logError(LOG_TAG, "Invalid action: \"" + action + "\"");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 void onDestroy() {
|
||||
Logger.logVerbose(LOG_TAG, "onDestroy");
|
||||
|
||||
TermuxShellUtils.clearTermuxTMPDIR(true);
|
||||
|
||||
actionReleaseWakeLock(false);
|
||||
if (!mWantsToStop)
|
||||
killAllTermuxExecutionCommands();
|
||||
|
||||
TermuxShellManager.onAppExit(this);
|
||||
|
||||
runStopForeground();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
Logger.logVerbose(LOG_TAG, "onBind");
|
||||
return mBinder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onUnbind(Intent intent) {
|
||||
Logger.logVerbose(LOG_TAG, "onUnbind");
|
||||
|
||||
// Since we cannot rely on {@link TermuxActivity.onDestroy()} to always complete,
|
||||
// we unset clients here as well if it failed, so that we do not leave service and session
|
||||
// clients with references to the activity.
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
unsetTermuxTerminalSessionClient();
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Make service run in foreground mode. */
|
||||
private void runStartForeground() {
|
||||
setupNotificationChannel();
|
||||
startForeground(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification());
|
||||
}
|
||||
|
||||
/** Make service leave foreground mode. */
|
||||
private void runStopForeground() {
|
||||
stopForeground(true);
|
||||
}
|
||||
|
||||
/** Request to stop service. */
|
||||
private void requestStopService() {
|
||||
Logger.logDebug(LOG_TAG, "Requesting to stop service");
|
||||
runStopForeground();
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
/** Process action to stop service. */
|
||||
private void actionStopService() {
|
||||
mWantsToStop = true;
|
||||
killAllTermuxExecutionCommands();
|
||||
requestStopService();
|
||||
}
|
||||
|
||||
/** Kill all TermuxSessions and TermuxTasks by sending SIGKILL to their processes.
|
||||
*
|
||||
* For TermuxSessions, all sessions will be killed, whether user manually exited Termux or if
|
||||
* onDestroy() was directly called because of unintended shutdown. The processing of results
|
||||
* will only be done if user manually exited termux or if the session was started by a plugin
|
||||
* which **expects** the result back via a pending intent.
|
||||
*
|
||||
* For TermuxTasks, only tasks that were started by a plugin which **expects** the result
|
||||
* back via a pending intent will be killed, whether user manually exited Termux or if
|
||||
* onDestroy() was directly called because of unintended shutdown. The processing of results
|
||||
* will always be done for the tasks that are killed. The remaining processes will keep on
|
||||
* running until the termux app process is killed by android, like by OOM, so we let them run
|
||||
* as long as they can.
|
||||
*
|
||||
* Some plugin execution commands may not have been processed and added to mTermuxSessions and
|
||||
* mTermuxTasks lists before the service is killed, so we maintain a separate
|
||||
* mPendingPluginExecutionCommands list for those, so that we can notify the pending intent
|
||||
* creators that execution was cancelled.
|
||||
*
|
||||
* Note that if user didn't manually exit Termux and if onDestroy() was directly called because
|
||||
* of unintended shutdown, like android deciding to kill the service, then there will be no
|
||||
* guarantee that onDestroy() will be allowed to finish and termux app process may be killed before
|
||||
* it has finished. This means that in those cases some results may not be sent back to their
|
||||
* creators for plugin commands but we still try to process whatever results can be processed
|
||||
* despite the unreliable behaviour of onDestroy().
|
||||
*
|
||||
* Note that if don't kill the processes started by plugins which **expect** the result back
|
||||
* and notify their creators that they have been killed, then they may get stuck waiting for
|
||||
* the results forever like in case of commands started by Termux:Tasker or RUN_COMMAND intent,
|
||||
* since once TermuxService has been killed, no result will be sent back. They may still get
|
||||
* stuck if termux app process gets killed, so for this case reasonable timeout values should
|
||||
* be used, like in Tasker for the Termux:Tasker actions.
|
||||
*
|
||||
* We make copies of each list since items are removed inside the loop.
|
||||
*/
|
||||
private synchronized void killAllTermuxExecutionCommands() {
|
||||
boolean processResult;
|
||||
|
||||
Logger.logDebug(LOG_TAG, "Killing TermuxSessions=" + mShellManager.mTermuxSessions.size() +
|
||||
", TermuxTasks=" + mShellManager.mTermuxTasks.size());
|
||||
|
||||
List<TermuxSession> termuxSessions = new ArrayList<>(mShellManager.mTermuxSessions);
|
||||
List<AppShell> termuxTasks = new ArrayList<>(mShellManager.mTermuxTasks);
|
||||
|
||||
for (int i = 0; i < termuxSessions.size(); i++) {
|
||||
ExecutionCommand executionCommand = termuxSessions.get(i).getExecutionCommand();
|
||||
processResult = mWantsToStop || executionCommand.isPluginExecutionCommandWithPendingResult();
|
||||
termuxSessions.get(i).killIfExecuting(this, processResult);
|
||||
if (!processResult)
|
||||
mShellManager.mTermuxSessions.remove(termuxSessions.get(i));
|
||||
}
|
||||
|
||||
|
||||
for (int i = 0; i < termuxTasks.size(); i++) {
|
||||
ExecutionCommand executionCommand = termuxTasks.get(i).getExecutionCommand();
|
||||
if (executionCommand.isPluginExecutionCommandWithPendingResult())
|
||||
termuxTasks.get(i).killIfExecuting(this, true);
|
||||
else
|
||||
mShellManager.mTermuxTasks.remove(termuxTasks.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Process action to acquire Power and Wi-Fi WakeLocks. */
|
||||
@SuppressLint({"WakelockTimeout", "BatteryLife"})
|
||||
private void actionAcquireWakeLock() {
|
||||
if (mWakeLock != null) {
|
||||
Logger.logDebug(LOG_TAG, "Ignoring acquiring WakeLocks since they are already held");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.logDebug(LOG_TAG, "Acquiring WakeLocks");
|
||||
|
||||
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TermuxConstants.TERMUX_APP_NAME.toLowerCase() + ":service-wakelock");
|
||||
mWakeLock.acquire();
|
||||
|
||||
// http://tools.android.com/tech-docs/lint-in-studio-2-3#TOC-WifiManager-Leak
|
||||
WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
|
||||
mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, TermuxConstants.TERMUX_APP_NAME.toLowerCase());
|
||||
mWifiLock.acquire();
|
||||
|
||||
if (!PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) {
|
||||
PermissionUtils.requestDisableBatteryOptimizations(this);
|
||||
}
|
||||
|
||||
updateNotification();
|
||||
|
||||
Logger.logDebug(LOG_TAG, "WakeLocks acquired successfully");
|
||||
|
||||
}
|
||||
|
||||
/** Process action to release Power and Wi-Fi WakeLocks. */
|
||||
private void actionReleaseWakeLock(boolean updateNotification) {
|
||||
if (mWakeLock == null && mWifiLock == null) {
|
||||
Logger.logDebug(LOG_TAG, "Ignoring releasing WakeLocks since none are already held");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.logDebug(LOG_TAG, "Releasing WakeLocks");
|
||||
|
||||
if (mWakeLock != null) {
|
||||
mWakeLock.release();
|
||||
mWakeLock = null;
|
||||
}
|
||||
|
||||
if (mWifiLock != null) {
|
||||
mWifiLock.release();
|
||||
mWifiLock = null;
|
||||
}
|
||||
|
||||
if (updateNotification)
|
||||
updateNotification();
|
||||
|
||||
Logger.logDebug(LOG_TAG, "WakeLocks released successfully");
|
||||
}
|
||||
|
||||
|
||||
/** Create a TermuxTask. */
|
||||
@Nullable
|
||||
public AppShell createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) {
|
||||
return createTermuxTask(new ExecutionCommand(TermuxShellManager.getNextShellId(), executablePath,
|
||||
arguments, stdin, workingDirectory, Runner.APP_SHELL.getName(), false));
|
||||
}
|
||||
|
||||
/** Create a TermuxTask. */
|
||||
@Nullable
|
||||
public synchronized AppShell createTermuxTask(ExecutionCommand executionCommand) {
|
||||
if (executionCommand == null) return null;
|
||||
|
||||
Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask");
|
||||
|
||||
if (!Runner.APP_SHELL.equalsRunner(executionCommand.runner)) {
|
||||
Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxTask()");
|
||||
return null;
|
||||
}
|
||||
|
||||
executionCommand.setShellCommandShellEnvironment = true;
|
||||
|
||||
if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE)
|
||||
Logger.logVerboseExtended(LOG_TAG, executionCommand.toString());
|
||||
|
||||
AppShell newTermuxTask = AppShell.execute(this, executionCommand, this,
|
||||
new TermuxShellEnvironment(), null,false);
|
||||
if (newTermuxTask == null) {
|
||||
Logger.logError(LOG_TAG, "Failed to execute new TermuxTask command for:\n" + executionCommand.getCommandIdAndLabelLogString());
|
||||
// If the execution command was started for a plugin, then process the error
|
||||
if (executionCommand.isPluginExecutionCommand)
|
||||
TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false);
|
||||
else {
|
||||
Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs");
|
||||
Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
mShellManager.mTermuxTasks.add(newTermuxTask);
|
||||
|
||||
updateNotification();
|
||||
|
||||
return newTermuxTask;
|
||||
}
|
||||
|
||||
/** Callback received when a TermuxTask finishes. */
|
||||
@Override
|
||||
public void onAppShellExited(final AppShell termuxTask) {
|
||||
mHandler.post(() -> {
|
||||
if (termuxTask != null) {
|
||||
ExecutionCommand executionCommand = termuxTask.getExecutionCommand();
|
||||
|
||||
Logger.logVerbose(LOG_TAG, "The onTermuxTaskExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command");
|
||||
|
||||
// If the execution command was started for a plugin, then process the results
|
||||
if (executionCommand != null && executionCommand.isPluginExecutionCommand)
|
||||
TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand);
|
||||
|
||||
mShellManager.mTermuxTasks.remove(termuxTask);
|
||||
}
|
||||
|
||||
updateNotification();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a {@link TermuxSession}.
|
||||
* Currently called by {@link TermuxTerminalSessionActivityClient#addNewSession(boolean, String)} to add a new {@link TermuxSession}.
|
||||
*/
|
||||
@Nullable
|
||||
public TermuxSession createTermuxSession(String executablePath, String[] arguments, String stdin,
|
||||
String workingDirectory, boolean isFailSafe, String sessionName) {
|
||||
ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId(),
|
||||
executablePath, arguments, stdin, workingDirectory, Runner.TERMINAL_SESSION.getName(), isFailSafe);
|
||||
executionCommand.shellName = sessionName;
|
||||
return createTermuxSession(executionCommand);
|
||||
}
|
||||
|
||||
/** Create a {@link TermuxSession}. */
|
||||
@Nullable
|
||||
public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand) {
|
||||
if (executionCommand == null) return null;
|
||||
|
||||
Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession");
|
||||
|
||||
if (!Runner.TERMINAL_SESSION.equalsRunner(executionCommand.runner)) {
|
||||
Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxSession()");
|
||||
return null;
|
||||
}
|
||||
|
||||
executionCommand.setShellCommandShellEnvironment = true;
|
||||
executionCommand.terminalTranscriptRows = mProperties.getTerminalTranscriptRows();
|
||||
|
||||
if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE)
|
||||
Logger.logVerboseExtended(LOG_TAG, executionCommand.toString());
|
||||
|
||||
// If the execution command was started for a plugin, only then will the stdout be set
|
||||
// Otherwise if command was manually started by the user like by adding a new terminal session,
|
||||
// then no need to set stdout
|
||||
TermuxSession newTermuxSession = TermuxSession.execute(this, executionCommand, getTermuxTerminalSessionClient(),
|
||||
this, new TermuxShellEnvironment(), null, executionCommand.isPluginExecutionCommand);
|
||||
if (newTermuxSession == null) {
|
||||
Logger.logError(LOG_TAG, "Failed to execute new TermuxSession command for:\n" + executionCommand.getCommandIdAndLabelLogString());
|
||||
// If the execution command was started for a plugin, then process the error
|
||||
if (executionCommand.isPluginExecutionCommand)
|
||||
TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false);
|
||||
else {
|
||||
Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs");
|
||||
Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
mShellManager.mTermuxSessions.add(newTermuxSession);
|
||||
|
||||
// Notify {@link TermuxSessionsListViewController} that sessions list has been updated if
|
||||
// activity in is foreground
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated();
|
||||
|
||||
updateNotification();
|
||||
|
||||
// No need to recreate the activity since it likely just started and theme should already have applied
|
||||
TermuxActivity.updateTermuxActivityStyling(this, false);
|
||||
|
||||
return newTermuxSession;
|
||||
}
|
||||
|
||||
/** Remove a TermuxSession. */
|
||||
public synchronized int removeTermuxSession(TerminalSession sessionToRemove) {
|
||||
int index = getIndexOfSession(sessionToRemove);
|
||||
|
||||
if (index >= 0)
|
||||
mShellManager.mTermuxSessions.get(index).finish();
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Callback received when a {@link TermuxSession} finishes. */
|
||||
@Override
|
||||
public void onTermuxSessionExited(final TermuxSession termuxSession) {
|
||||
if (termuxSession != null) {
|
||||
ExecutionCommand executionCommand = termuxSession.getExecutionCommand();
|
||||
|
||||
Logger.logVerbose(LOG_TAG, "The onTermuxSessionExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command");
|
||||
|
||||
// If the execution command was started for a plugin, then process the results
|
||||
if (executionCommand != null && executionCommand.isPluginExecutionCommand)
|
||||
TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand);
|
||||
|
||||
mShellManager.mTermuxSessions.remove(termuxSession);
|
||||
|
||||
// Notify {@link TermuxSessionsListViewController} that sessions list has been updated if
|
||||
// activity in is foreground
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated();
|
||||
}
|
||||
|
||||
updateNotification();
|
||||
}
|
||||
|
||||
|
||||
/** If {@link TermuxActivity} has not bound to the {@link TermuxService} yet or is destroyed, then
|
||||
* interface functions requiring the activity should not be available to the terminal sessions,
|
||||
* so we just return the {@link #mTermuxTerminalSessionServiceClient}. Once {@link TermuxActivity} bind
|
||||
* callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the
|
||||
* {@link TermuxService#mTermuxTerminalSessionActivityClient} so that further terminal sessions are directly
|
||||
* passed the {@link TermuxTerminalSessionActivityClient} object which fully implements the
|
||||
* {@link TerminalSessionClient} interface.
|
||||
*
|
||||
* @return Returns the {@link TermuxTerminalSessionActivityClient} if {@link TermuxActivity} has bound with
|
||||
* {@link TermuxService}, otherwise {@link TermuxTerminalSessionServiceClient}.
|
||||
*/
|
||||
public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() {
|
||||
if (mTermuxTerminalSessionActivityClient != null)
|
||||
return mTermuxTerminalSessionActivityClient;
|
||||
else
|
||||
return mTermuxTerminalSessionServiceClient;
|
||||
}
|
||||
|
||||
/** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the
|
||||
* {@link TermuxService#mTermuxTerminalSessionActivityClient} variable and update the {@link TerminalSession}
|
||||
* and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionServiceClient}
|
||||
* earlier.
|
||||
*
|
||||
* @param termuxTerminalSessionActivityClient The {@link TermuxTerminalSessionActivityClient} object that fully
|
||||
* implements the {@link TerminalSessionClient} interface.
|
||||
*/
|
||||
public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
|
||||
mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
|
||||
|
||||
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++)
|
||||
mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionActivityClient);
|
||||
}
|
||||
|
||||
/** This should be called when {@link TermuxActivity} has been destroyed and in {@link #onUnbind(Intent)}
|
||||
* so that the {@link TermuxService} and {@link TerminalSession} and {@link TerminalEmulator}
|
||||
* clients do not hold an activity references.
|
||||
*/
|
||||
public synchronized void unsetTermuxTerminalSessionClient() {
|
||||
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++)
|
||||
mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionServiceClient);
|
||||
|
||||
mTermuxTerminalSessionActivityClient = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private Notification buildNotification() {
|
||||
Resources res = getResources();
|
||||
|
||||
// Set pending intent to be launched when notification is clicked
|
||||
Intent notificationIntent = TermuxActivity.newInstance(this);
|
||||
PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
|
||||
|
||||
|
||||
// Set notification text
|
||||
int sessionCount = getTermuxSessionsSize();
|
||||
int taskCount = mShellManager.mTermuxTasks.size();
|
||||
String notificationText = sessionCount + " session" + (sessionCount == 1 ? "" : "s");
|
||||
if (taskCount > 0) {
|
||||
notificationText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
final boolean wakeLockHeld = mWakeLock != null;
|
||||
if (wakeLockHeld) notificationText += " (wake lock held)";
|
||||
|
||||
|
||||
// Set notification priority
|
||||
// If holding a wake or wifi lock consider the notification of high priority since it's using power,
|
||||
// otherwise use a low priority
|
||||
int priority = (wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_LOW;
|
||||
|
||||
|
||||
// Build the notification
|
||||
Notification.Builder builder = NotificationUtils.geNotificationBuilder(this,
|
||||
TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, priority,
|
||||
TermuxConstants.TERMUX_APP_NAME, notificationText, null,
|
||||
contentIntent, null, NotificationUtils.NOTIFICATION_MODE_SILENT);
|
||||
if (builder == null) return null;
|
||||
|
||||
// No need to show a timestamp:
|
||||
builder.setShowWhen(false);
|
||||
|
||||
// Set notification icon
|
||||
builder.setSmallIcon(R.drawable.ic_service_notification);
|
||||
|
||||
// Set background color for small notification icon
|
||||
builder.setColor(0xFF607D8B);
|
||||
|
||||
// TermuxSessions are always ongoing
|
||||
builder.setOngoing(true);
|
||||
|
||||
|
||||
// Set Exit button action
|
||||
Intent exitIntent = new Intent(this, TermuxService.class).setAction(TERMUX_SERVICE.ACTION_STOP_SERVICE);
|
||||
builder.addAction(android.R.drawable.ic_delete, res.getString(R.string.notification_action_exit), PendingIntent.getService(this, 0, exitIntent, 0));
|
||||
|
||||
|
||||
// Set Wakelock button actions
|
||||
String newWakeAction = wakeLockHeld ? TERMUX_SERVICE.ACTION_WAKE_UNLOCK : TERMUX_SERVICE.ACTION_WAKE_LOCK;
|
||||
Intent toggleWakeLockIntent = new Intent(this, TermuxService.class).setAction(newWakeAction);
|
||||
String actionTitle = res.getString(wakeLockHeld ? R.string.notification_action_wake_unlock : R.string.notification_action_wake_lock);
|
||||
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();
|
||||
}
|
||||
|
||||
private void setupNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
|
||||
NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID,
|
||||
TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW);
|
||||
}
|
||||
|
||||
/** Update the shown foreground service notification after making any changes that affect it. */
|
||||
private synchronized void updateNotification() {
|
||||
if (mWakeLock == null && mShellManager.mTermuxSessions.isEmpty() && mShellManager.mTermuxTasks.isEmpty()) {
|
||||
// Exit if we are updating after the user disabled all locks with no sessions or tasks running.
|
||||
requestStopService();
|
||||
} else {
|
||||
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public synchronized boolean isTermuxSessionsEmpty() {
|
||||
return mShellManager.mTermuxSessions.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized int getTermuxSessionsSize() {
|
||||
return mShellManager.mTermuxSessions.size();
|
||||
}
|
||||
|
||||
public synchronized List<TermuxSession> getTermuxSessions() {
|
||||
return mShellManager.mTermuxSessions;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public synchronized TermuxSession getTermuxSession(int index) {
|
||||
if (index >= 0 && index < mShellManager.mTermuxSessions.size())
|
||||
return mShellManager.mTermuxSessions.get(index);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public synchronized TermuxSession getTermuxSessionForTerminalSession(TerminalSession terminalSession) {
|
||||
if (terminalSession == null) return null;
|
||||
|
||||
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) {
|
||||
if (mShellManager.mTermuxSessions.get(i).getTerminalSession().equals(terminalSession))
|
||||
return mShellManager.mTermuxSessions.get(i);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public synchronized TermuxSession getLastTermuxSession() {
|
||||
return mShellManager.mTermuxSessions.isEmpty() ? null : mShellManager.mTermuxSessions.get(mShellManager.mTermuxSessions.size() - 1);
|
||||
}
|
||||
|
||||
public synchronized int getIndexOfSession(TerminalSession terminalSession) {
|
||||
if (terminalSession == null) return -1;
|
||||
|
||||
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) {
|
||||
if (mShellManager.mTermuxSessions.get(i).getTerminalSession().equals(terminalSession))
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public synchronized TerminalSession getTerminalSessionForHandle(String sessionHandle) {
|
||||
TerminalSession terminalSession;
|
||||
for (int i = 0, len = mShellManager.mTermuxSessions.size(); i < len; i++) {
|
||||
terminalSession = mShellManager.mTermuxSessions.get(i).getTerminalSession();
|
||||
if (terminalSession.mHandle.equals(sessionHandle))
|
||||
return terminalSession;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public synchronized AppShell getTermuxTaskForShellName(String name) {
|
||||
if (DataUtils.isNullOrEmpty(name)) return null;
|
||||
AppShell appShell;
|
||||
for (int i = 0, len = mShellManager.mTermuxTasks.size(); i < len; i++) {
|
||||
appShell = mShellManager.mTermuxTasks.get(i);
|
||||
String shellName = appShell.getExecutionCommand().shellName;
|
||||
if (shellName != null && shellName.equals(name))
|
||||
return appShell;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public synchronized TermuxSession getTermuxSessionForShellName(String name) {
|
||||
if (DataUtils.isNullOrEmpty(name)) return null;
|
||||
TermuxSession termuxSession;
|
||||
for (int i = 0, len = mShellManager.mTermuxSessions.size(); i < len; i++) {
|
||||
termuxSession = mShellManager.mTermuxSessions.get(i);
|
||||
String shellName = termuxSession.getExecutionCommand().shellName;
|
||||
if (shellName != null && shellName.equals(name))
|
||||
return termuxSession;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public boolean wantsToStop() {
|
||||
return mWantsToStop;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.termux.app.api.file;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Patterns;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.termux.R;
|
||||
import com.termux.shared.android.PackageUtils;
|
||||
import com.termux.shared.data.DataUtils;
|
||||
import com.termux.shared.data.IntentUtils;
|
||||
import com.termux.shared.net.uri.UriUtils;
|
||||
import com.termux.shared.interact.MessageDialogUtils;
|
||||
import com.termux.shared.net.uri.UriScheme;
|
||||
import com.termux.shared.termux.interact.TextInputDialogUtils;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.shared.termux.TermuxConstants.TERMUX_APP;
|
||||
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE;
|
||||
import com.termux.app.TermuxService;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
|
||||
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
|
||||
|
||||
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 FileReceiverActivity extends AppCompatActivity {
|
||||
|
||||
static final String TERMUX_RECEIVEDIR = TermuxConstants.TERMUX_FILES_DIR_PATH + "/home/downloads";
|
||||
static final String EDITOR_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-file-editor";
|
||||
static final String URL_OPENER_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-url-opener";
|
||||
|
||||
/**
|
||||
* If the activity should be finished when the name input dialog is dismissed. This is disabled
|
||||
* 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;
|
||||
|
||||
private static final String API_TAG = TermuxConstants.TERMUX_APP_NAME + "FileReceiver";
|
||||
|
||||
private static final String LOG_TAG = "FileReceiverActivity";
|
||||
|
||||
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();
|
||||
|
||||
Logger.logVerbose(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent));
|
||||
|
||||
final String sharedTitle = IntentUtils.getStringExtraIfSet(intent, Intent.EXTRA_TITLE, null);
|
||||
|
||||
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 (sharedUri != null) {
|
||||
handleContentUri(sharedUri, sharedTitle);
|
||||
} else if (sharedText != null) {
|
||||
if (isSharedTextAnUrl(sharedText)) {
|
||||
handleUrlAndFinish(sharedText);
|
||||
} else {
|
||||
String subject = IntentUtils.getStringExtraIfSet(intent, Intent.EXTRA_SUBJECT, null);
|
||||
if (subject == null) subject = sharedTitle;
|
||||
if (subject != null) subject += ".txt";
|
||||
promptNameAndSave(new ByteArrayInputStream(sharedText.getBytes(StandardCharsets.UTF_8)), subject);
|
||||
}
|
||||
} else {
|
||||
showErrorDialogAndQuit("Send action without content - nothing to save.");
|
||||
}
|
||||
} else {
|
||||
Uri dataUri = intent.getData();
|
||||
|
||||
if (dataUri == null) {
|
||||
showErrorDialogAndQuit("Data uri not passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (UriScheme.SCHEME_CONTENT.equals(scheme)) {
|
||||
handleContentUri(dataUri, sharedTitle);
|
||||
} else if (UriScheme.SCHEME_FILE.equals(scheme)) {
|
||||
Logger.logVerbose(LOG_TAG, "uri: \"" + dataUri + "\", path: \"" + dataUri.getPath() + "\", fragment: \"" + dataUri.getFragment() + "\"");
|
||||
|
||||
// Get full path including fragment (anything after last "#")
|
||||
String path = UriUtils.getUriFilePathWithFragment(dataUri);
|
||||
if (DataUtils.isNullOrEmpty(path)) {
|
||||
showErrorDialogAndQuit("File path from data uri is null, empty or invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
MessageDialogUtils.showMessage(this,
|
||||
API_TAG, message,
|
||||
null, (dialog, which) -> finish(),
|
||||
null, null,
|
||||
dialog -> finish());
|
||||
}
|
||||
|
||||
void handleContentUri(@NonNull final Uri uri, String subjectFromIntent) {
|
||||
try {
|
||||
Logger.logVerbose(LOG_TAG, "uri: \"" + uri + "\", path: \"" + uri.getPath() + "\", fragment: \"" + uri.getFragment() + "\"");
|
||||
|
||||
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;
|
||||
if (attachmentFileName == null) attachmentFileName = UriUtils.getUriFileBasename(uri, true);
|
||||
|
||||
InputStream in = getContentResolver().openInputStream(uri);
|
||||
promptNameAndSave(in, attachmentFileName);
|
||||
} catch (Exception e) {
|
||||
showErrorDialogAndQuit("Unable to handle shared content:\n\n" + e.getMessage());
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "handleContentUri(uri=" + uri + ") failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
void promptNameAndSave(final InputStream in, final String attachmentFileName) {
|
||||
TextInputDialogUtils.textInput(this, R.string.title_file_received, attachmentFileName,
|
||||
R.string.action_file_received_edit, 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 = UriUtils.getFileUri(EDITOR_PROGRAM);
|
||||
|
||||
Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, scriptUri);
|
||||
executeIntent.setClass(FileReceiverActivity.this, TermuxService.class);
|
||||
executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()});
|
||||
startService(executeIntent);
|
||||
finish();
|
||||
},
|
||||
R.string.action_file_received_open_directory, text -> {
|
||||
if (saveStreamWithName(in, text) == null) return;
|
||||
|
||||
Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE);
|
||||
executeIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, TERMUX_RECEIVEDIR);
|
||||
executeIntent.setClass(FileReceiverActivity.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 (DataUtils.isNullOrEmpty(attachmentFileName)) {
|
||||
showErrorDialogAndQuit("File name cannot be null or empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "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 the first argument.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Do this for the user if necessary:
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
urlOpenerProgramFile.setExecutable(true);
|
||||
|
||||
final Uri urlOpenerProgramUri = UriUtils.getFileUri(URL_OPENER_PROGRAM);
|
||||
|
||||
Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, urlOpenerProgramUri);
|
||||
executeIntent.setClass(FileReceiverActivity.this, TermuxService.class);
|
||||
executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{url});
|
||||
startService(executeIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update {@link TERMUX_APP#FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME} component state depending on
|
||||
* {@link TermuxPropertyConstants#KEY_DISABLE_FILE_SHARE_RECEIVER} value and
|
||||
* {@link TERMUX_APP#FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME} component state depending on
|
||||
* {@link TermuxPropertyConstants#KEY_DISABLE_FILE_VIEW_RECEIVER} value.
|
||||
*/
|
||||
public static void updateFileReceiverActivityComponentsState(@NonNull Context context) {
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
TermuxAppSharedProperties properties = TermuxAppSharedProperties.getProperties();
|
||||
|
||||
String errmsg;
|
||||
boolean state;
|
||||
|
||||
state = !properties.isFileShareReceiverDisabled();
|
||||
Logger.logVerbose(LOG_TAG, "Setting " + TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME + " component state to " + state);
|
||||
errmsg = PackageUtils.setComponentState(context,TermuxConstants.TERMUX_PACKAGE_NAME,
|
||||
TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME,
|
||||
state, null, false, false);
|
||||
if (errmsg != null)
|
||||
Logger.logError(LOG_TAG, errmsg);
|
||||
|
||||
state = !properties.isFileViewReceiverDisabled();
|
||||
Logger.logVerbose(LOG_TAG, "Setting " + TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME + " component state to " + state);
|
||||
errmsg = PackageUtils.setComponentState(context,TermuxConstants.TERMUX_PACKAGE_NAME,
|
||||
TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME,
|
||||
state, null, false, false);
|
||||
if (errmsg != null)
|
||||
Logger.logError(LOG_TAG, errmsg);
|
||||
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.termux.app.models;
|
||||
|
||||
public enum UserAction {
|
||||
REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript");
|
||||
|
||||
private final String name;
|
||||
|
||||
UserAction(final String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.termux.app.terminal;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.inputmethodservice.InputMethodService;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.termux.app.TermuxActivity;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.view.ViewUtils;
|
||||
|
||||
|
||||
/**
|
||||
* The {@link TermuxActivity} relies on {@link android.view.WindowManager.LayoutParams#SOFT_INPUT_ADJUST_RESIZE)}
|
||||
* set by {@link TermuxTerminalViewClient#setSoftKeyboardState(boolean, boolean)} to automatically
|
||||
* resize the view and push the terminal up when soft keyboard is opened. However, this does not
|
||||
* always work properly. When `enforce-char-based-input=true` is set in `termux.properties`
|
||||
* and {@link com.termux.view.TerminalView#onCreateInputConnection(EditorInfo)} sets the inputType
|
||||
* to `InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS`
|
||||
* instead of the default `InputType.TYPE_NULL` for termux, some keyboards may still show suggestions.
|
||||
* Gboard does too, but only when text is copied and clipboard suggestions **and** number keys row
|
||||
* toggles are enabled in its settings. When number keys row toggle is not enabled, Gboard will still
|
||||
* show the row but will switch it with suggestions if needed. If its enabled, then number keys row
|
||||
* is always shown and suggestions are shown in an additional row on top of it. This additional row is likely
|
||||
* part of the candidates view returned by the keyboard app in {@link InputMethodService#onCreateCandidatesView()}.
|
||||
*
|
||||
* With the above configuration, the additional clipboard suggestions row partially covers the
|
||||
* extra keys/terminal. Reopening the keyboard/activity does not fix the issue. This is either a bug
|
||||
* in the Android OS where it does not consider the candidate's view height in its calculation to push
|
||||
* up the view or because Gboard does not include the candidate's view height in the height reported
|
||||
* to android that should be used, hence causing an overlap.
|
||||
*
|
||||
* Gboard logs the following entry to `logcat` when its opened with or without the suggestions bar showing:
|
||||
* I/KeyboardViewUtil: KeyboardViewUtil.calculateMaxKeyboardBodyHeight():62 leave 500 height for app when screen height:2392, header height:176 and isFullscreenMode:false, so the max keyboard body height is:1716
|
||||
* where `keyboard_height = screen_height - height_for_app - header_height` (62 is a hardcoded value in Gboard source code and may be a version number)
|
||||
* So this may in fact be due to Gboard but https://stackoverflow.com/questions/57567272 suggests
|
||||
* otherwise. Another similar report https://stackoverflow.com/questions/66761661.
|
||||
* Also check https://github.com/termux/termux-app/issues/1539.
|
||||
*
|
||||
* This overlap may happen even without `enforce-char-based-input=true` for keyboards with extended layouts
|
||||
* like number row, etc.
|
||||
*
|
||||
* To fix these issues, `activity_termux.xml` has the constant 1sp transparent
|
||||
* `activity_termux_bottom_space_view` View at the bottom. This will appear as a line matching the
|
||||
* activity theme. When {@link TermuxActivity} {@link ViewTreeObserver.OnGlobalLayoutListener} is
|
||||
* called when any of the sub view layouts change, like keyboard opening/closing keyboard,
|
||||
* extra keys/input view switched, etc, we check if the bottom space view is visible or not.
|
||||
* If its not, then we add a margin to the bottom of the root view, so that the keyboard does not
|
||||
* overlap the extra keys/terminal, since the margin will push up the view. By default the margin
|
||||
* added is equal to the height of the hidden part of extra keys/terminal. For Gboard's case, the
|
||||
* hidden part equals the `header_height`. The updates to margins may cause a jitter in some cases
|
||||
* when the view is redrawn if the margin is incorrect, but logic has been implemented to avoid that.
|
||||
*/
|
||||
public class TermuxActivityRootView extends LinearLayout implements ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
public TermuxActivity mActivity;
|
||||
public Integer marginBottom;
|
||||
public Integer lastMarginBottom;
|
||||
public long lastMarginBottomTime;
|
||||
public long lastMarginBottomExtraTime;
|
||||
|
||||
/** Log root view events. */
|
||||
private boolean ROOT_VIEW_LOGGING_ENABLED = false;
|
||||
|
||||
private static final String LOG_TAG = "TermuxActivityRootView";
|
||||
|
||||
private static int mStatusBarHeight;
|
||||
|
||||
public TermuxActivityRootView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public void setActivity(TermuxActivity activity) {
|
||||
mActivity = activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether root view logging is enabled or not.
|
||||
*
|
||||
* @param value The boolean value that defines the state.
|
||||
*/
|
||||
public void setIsRootViewLoggingEnabled(boolean value) {
|
||||
ROOT_VIEW_LOGGING_ENABLED = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
|
||||
if (marginBottom != null) {
|
||||
if (ROOT_VIEW_LOGGING_ENABLED)
|
||||
Logger.logVerbose(LOG_TAG, "onMeasure: Setting bottom margin to " + marginBottom);
|
||||
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams();
|
||||
params.setMargins(0, 0, 0, marginBottom);
|
||||
setLayoutParams(params);
|
||||
marginBottom = null;
|
||||
requestLayout();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGlobalLayout() {
|
||||
if (mActivity == null || !mActivity.isVisible()) return;
|
||||
|
||||
View bottomSpaceView = mActivity.getTermuxActivityBottomSpaceView();
|
||||
if (bottomSpaceView == null) return;
|
||||
|
||||
boolean root_view_logging_enabled = ROOT_VIEW_LOGGING_ENABLED;
|
||||
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, ":\nonGlobalLayout:");
|
||||
|
||||
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) getLayoutParams();
|
||||
|
||||
// Get the position Rects of the bottom space view and the main window holding it
|
||||
Rect[] windowAndViewRects = ViewUtils.getWindowAndViewRects(bottomSpaceView, mStatusBarHeight);
|
||||
if (windowAndViewRects == null)
|
||||
return;
|
||||
|
||||
Rect windowAvailableRect = windowAndViewRects[0];
|
||||
Rect bottomSpaceViewRect = windowAndViewRects[1];
|
||||
|
||||
// If the bottomSpaceViewRect is inside the windowAvailableRect, then it must be completely visible
|
||||
//boolean isVisible = windowAvailableRect.contains(bottomSpaceViewRect); // rect.right comparison often fails in landscape
|
||||
boolean isVisible = ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect);
|
||||
boolean isVisibleBecauseMargin = (windowAvailableRect.bottom == bottomSpaceViewRect.bottom) && params.bottomMargin > 0;
|
||||
boolean isVisibleBecauseExtraMargin = ((bottomSpaceViewRect.bottom - windowAvailableRect.bottom) < 0);
|
||||
|
||||
if (root_view_logging_enabled) {
|
||||
Logger.logVerbose(LOG_TAG, "windowAvailableRect " + ViewUtils.toRectString(windowAvailableRect) + ", bottomSpaceViewRect " + ViewUtils.toRectString(bottomSpaceViewRect));
|
||||
Logger.logVerbose(LOG_TAG, "windowAvailableRect.bottom " + windowAvailableRect.bottom +
|
||||
", bottomSpaceViewRect.bottom " +bottomSpaceViewRect.bottom +
|
||||
", diff " + (bottomSpaceViewRect.bottom - windowAvailableRect.bottom) + ", bottom " + params.bottomMargin +
|
||||
", isVisible " + windowAvailableRect.contains(bottomSpaceViewRect) + ", isRectAbove " + ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect) +
|
||||
", isVisibleBecauseMargin " + isVisibleBecauseMargin + ", isVisibleBecauseExtraMargin " + isVisibleBecauseExtraMargin);
|
||||
}
|
||||
|
||||
// If the bottomSpaceViewRect is visible, then remove the margin if needed
|
||||
if (isVisible) {
|
||||
// If visible because of margin, i.e the bottom of bottomSpaceViewRect equals that of windowAvailableRect
|
||||
// and a margin has been added
|
||||
// Necessary so that we don't get stuck in an infinite loop since setting margin
|
||||
// will call OnGlobalLayoutListener again and next time bottom space view
|
||||
// will be visible and margin will be set to 0, which again will call
|
||||
// OnGlobalLayoutListener...
|
||||
// Calling addTermuxActivityRootViewGlobalLayoutListener with a delay fails to
|
||||
// set appropriate margins when views are changed quickly since some changes
|
||||
// may be missed.
|
||||
if (isVisibleBecauseMargin) {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Visible due to margin");
|
||||
|
||||
// Once the view has been redrawn with new margin, we set margin back to 0 so that
|
||||
// when next time onMeasure() is called, margin 0 is used. This is necessary for
|
||||
// cases when view has been redrawn with new margin because bottom space view was
|
||||
// hidden by keyboard and then view was redrawn again due to layout change (like
|
||||
// keyboard symbol view is switched to), android will add margin below its new position
|
||||
// if its greater than 0, which was already above the keyboard creating x2x margin.
|
||||
// Adding time check since moving split screen divider in landscape causes jitter
|
||||
// and prevents some infinite loops
|
||||
if ((System.currentTimeMillis() - lastMarginBottomTime) > 40) {
|
||||
lastMarginBottomTime = System.currentTimeMillis();
|
||||
marginBottom = 0;
|
||||
} else {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Ignoring restoring marginBottom to 0 since called to quickly");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
boolean setMargin = params.bottomMargin != 0;
|
||||
|
||||
// If visible because of extra margin, i.e the bottom of bottomSpaceViewRect is above that of windowAvailableRect
|
||||
// onGlobalLayout: windowAvailableRect 1408, bottomSpaceViewRect 1232, diff -176, bottom 0, isVisible true, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false
|
||||
// onGlobalLayout: Bottom margin already equals 0
|
||||
if (isVisibleBecauseExtraMargin) {
|
||||
// Adding time check since prevents infinite loops, like in landscape mode in freeform mode in Taskbar
|
||||
if ((System.currentTimeMillis() - lastMarginBottomExtraTime) > 40) {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Resetting margin since visible due to extra margin");
|
||||
lastMarginBottomExtraTime = System.currentTimeMillis();
|
||||
// lastMarginBottom must be invalid. May also happen when keyboards are changed.
|
||||
lastMarginBottom = null;
|
||||
setMargin = true;
|
||||
} else {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Ignoring resetting margin since visible due to extra margin since called to quickly");
|
||||
}
|
||||
}
|
||||
|
||||
if (setMargin) {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Setting bottom margin to 0");
|
||||
params.setMargins(0, 0, 0, 0);
|
||||
setLayoutParams(params);
|
||||
} else {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Bottom margin already equals 0");
|
||||
// This is done so that when next time onMeasure() is called, lastMarginBottom is used.
|
||||
// This is done since we **expect** the keyboard to have same dimensions next time layout
|
||||
// changes, so best set margin while view is drawn the first time, otherwise it will
|
||||
// cause a jitter when OnGlobalLayoutListener is called with margin 0 and it sets the
|
||||
// likely same lastMarginBottom again and requesting a redraw. Hopefully, this logic
|
||||
// works fine for all cases.
|
||||
marginBottom = lastMarginBottom;
|
||||
}
|
||||
}
|
||||
// ELse find the part of the extra keys/terminal that is hidden and add a margin accordingly
|
||||
else {
|
||||
int pxHidden = bottomSpaceViewRect.bottom - windowAvailableRect.bottom;
|
||||
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "pxHidden " + pxHidden + ", bottom " + params.bottomMargin);
|
||||
|
||||
boolean setMargin = params.bottomMargin != pxHidden;
|
||||
|
||||
// If invisible despite margin, i.e a margin was added, but the bottom of bottomSpaceViewRect
|
||||
// is still below that of windowAvailableRect, this will trigger OnGlobalLayoutListener
|
||||
// again, so that margins are set properly. May happen when toolbar/extra keys is disabled
|
||||
// and enabled from left drawer, just like case for isVisibleBecauseExtraMargin.
|
||||
// onMeasure: Setting bottom margin to 176
|
||||
// onGlobalLayout: windowAvailableRect 1232, bottomSpaceViewRect 1408, diff 176, bottom 176, isVisible false, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false
|
||||
// onGlobalLayout: Bottom margin already equals 176
|
||||
if (pxHidden > 0 && params.bottomMargin > 0) {
|
||||
if (pxHidden != params.bottomMargin) {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since not visible due to wrong margin");
|
||||
pxHidden = 0;
|
||||
} else {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Force setting margin since not visible despite required margin");
|
||||
}
|
||||
setMargin = true;
|
||||
}
|
||||
|
||||
if (pxHidden < 0) {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since new margin is negative");
|
||||
pxHidden = 0;
|
||||
}
|
||||
|
||||
|
||||
if (setMargin) {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Setting bottom margin to " + pxHidden);
|
||||
params.setMargins(0, 0, 0, pxHidden);
|
||||
setLayoutParams(params);
|
||||
lastMarginBottom = pxHidden;
|
||||
} else {
|
||||
if (root_view_logging_enabled)
|
||||
Logger.logVerbose(LOG_TAG, "Bottom margin already equals " + pxHidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class WindowInsetsListener implements View.OnApplyWindowInsetsListener {
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
|
||||
mStatusBarHeight = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.statusBars()).top;
|
||||
// Let view window handle insets however it wants
|
||||
return v.onApplyWindowInsets(insets);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.termux.app.terminal;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Typeface;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.termux.R;
|
||||
import com.termux.app.TermuxActivity;
|
||||
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
|
||||
import com.termux.shared.theme.NightMode;
|
||||
import com.termux.shared.theme.ThemeUtils;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TermuxSessionsListViewController extends ArrayAdapter<TermuxSession> implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener {
|
||||
|
||||
final TermuxActivity mActivity;
|
||||
|
||||
final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD);
|
||||
final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC);
|
||||
|
||||
public TermuxSessionsListViewController(TermuxActivity activity, List<TermuxSession> sessionList) {
|
||||
super(activity.getApplicationContext(), R.layout.item_terminal_sessions_list, sessionList);
|
||||
this.mActivity = activity;
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
@NonNull
|
||||
@Override
|
||||
public View getView(int position, View convertView, @NonNull ViewGroup parent) {
|
||||
View sessionRowView = convertView;
|
||||
if (sessionRowView == null) {
|
||||
LayoutInflater inflater = mActivity.getLayoutInflater();
|
||||
sessionRowView = inflater.inflate(R.layout.item_terminal_sessions_list, parent, false);
|
||||
}
|
||||
|
||||
TextView sessionTitleView = sessionRowView.findViewById(R.id.session_title);
|
||||
|
||||
TerminalSession sessionAtRow = getItem(position).getTerminalSession();
|
||||
if (sessionAtRow == null) {
|
||||
sessionTitleView.setText("null session");
|
||||
return sessionRowView;
|
||||
}
|
||||
|
||||
boolean shouldEnableDarkTheme = ThemeUtils.shouldEnableDarkTheme(mActivity, NightMode.getAppNightMode().getName());
|
||||
|
||||
if (shouldEnableDarkTheme) {
|
||||
sessionTitleView.setBackground(
|
||||
ContextCompat.getDrawable(mActivity, R.drawable.session_background_black_selected)
|
||||
);
|
||||
}
|
||||
|
||||
String name = sessionAtRow.mSessionName;
|
||||
String sessionTitle = sessionAtRow.getTitle();
|
||||
|
||||
String numberPart = "[" + (position + 1) + "] ";
|
||||
String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name);
|
||||
String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle));
|
||||
|
||||
String fullSessionTitle = numberPart + sessionNamePart + sessionTitlePart;
|
||||
SpannableString fullSessionTitleStyled = new SpannableString(fullSessionTitle);
|
||||
fullSessionTitleStyled.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
fullSessionTitleStyled.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), fullSessionTitle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
sessionTitleView.setText(fullSessionTitleStyled);
|
||||
|
||||
boolean sessionRunning = sessionAtRow.isRunning();
|
||||
|
||||
if (sessionRunning) {
|
||||
sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
|
||||
} else {
|
||||
sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
|
||||
}
|
||||
int defaultColor = shouldEnableDarkTheme ? Color.WHITE : Color.BLACK;
|
||||
int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED;
|
||||
sessionTitleView.setTextColor(color);
|
||||
return sessionRowView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
|
||||
TermuxSession clickedSession = getItem(position);
|
||||
mActivity.getTermuxTerminalSessionClient().setCurrentSession(clickedSession.getTerminalSession());
|
||||
mActivity.getDrawer().closeDrawers();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
|
||||
final TermuxSession selectedSession = getItem(position);
|
||||
mActivity.getTermuxTerminalSessionClient().renameSession(selectedSession.getTerminalSession());
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
+530
@@ -0,0 +1,530 @@
|
||||
package com.termux.app.terminal;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Typeface;
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.SoundPool;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.ListView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.termux.R;
|
||||
import com.termux.shared.interact.ShareUtils;
|
||||
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
|
||||
import com.termux.shared.termux.interact.TextInputDialogUtils;
|
||||
import com.termux.app.TermuxActivity;
|
||||
import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.app.TermuxService;
|
||||
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
|
||||
import com.termux.shared.termux.terminal.io.BellHandler;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.terminal.TerminalColors;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
import com.termux.terminal.TerminalSessionClient;
|
||||
import com.termux.terminal.TextStyle;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
/** The {@link TerminalSessionClient} implementation that may require an {@link Activity} for its interface methods. */
|
||||
public class TermuxTerminalSessionActivityClient extends TermuxTerminalSessionClientBase {
|
||||
|
||||
private final TermuxActivity mActivity;
|
||||
|
||||
private static final int MAX_SESSIONS = 8;
|
||||
|
||||
private SoundPool mBellSoundPool;
|
||||
|
||||
private int mBellSoundId;
|
||||
|
||||
private static final String LOG_TAG = "TermuxTerminalSessionActivityClient";
|
||||
|
||||
public TermuxTerminalSessionActivityClient(TermuxActivity activity) {
|
||||
this.mActivity = activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onCreate() is called
|
||||
*/
|
||||
public void onCreate() {
|
||||
// Set terminal fonts and colors
|
||||
checkForFontAndColors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onStart() is called
|
||||
*/
|
||||
public void onStart() {
|
||||
// The service has connected, but data may have changed since we were last in the foreground.
|
||||
// Get the session stored in shared preferences stored by {@link #onStop} if its valid,
|
||||
// otherwise get the last session currently running.
|
||||
if (mActivity.getTermuxService() != null) {
|
||||
setCurrentSession(getCurrentStoredSessionOrLast());
|
||||
termuxSessionListNotifyUpdated();
|
||||
}
|
||||
|
||||
// The current terminal session may have changed while being away, force
|
||||
// a refresh of the displayed terminal.
|
||||
mActivity.getTerminalView().onScreenUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onResume() is called
|
||||
*/
|
||||
public void onResume() {
|
||||
// Just initialize the mBellSoundPool and load the sound, otherwise bell might not run
|
||||
// the first time bell key is pressed and play() is called, since sound may not be loaded
|
||||
// quickly enough before the call to play(). https://stackoverflow.com/questions/35435625
|
||||
loadBellSoundPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onStop() is called
|
||||
*/
|
||||
public void onStop() {
|
||||
// Store current session in shared preferences so that it can be restored later in
|
||||
// {@link #onStart} if needed.
|
||||
setCurrentStoredSession();
|
||||
|
||||
// Release mBellSoundPool resources, specially to prevent exceptions like the following to be thrown
|
||||
// java.util.concurrent.TimeoutException: android.media.SoundPool.finalize() timed out after 10 seconds
|
||||
// Bell is not played in background anyways
|
||||
// Related: https://stackoverflow.com/a/28708351/14686958
|
||||
releaseBellSoundPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.reloadActivityStyling() is called
|
||||
*/
|
||||
public void onReloadActivityStyling() {
|
||||
// Set terminal fonts and colors
|
||||
checkForFontAndColors();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void onTextChanged(@NonNull TerminalSession changedSession) {
|
||||
if (!mActivity.isVisible()) return;
|
||||
|
||||
if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTitleChanged(@NonNull TerminalSession updatedSession) {
|
||||
if (!mActivity.isVisible()) return;
|
||||
|
||||
if (updatedSession != mActivity.getCurrentSession()) {
|
||||
// Only show toast for other sessions than the current one, since the user
|
||||
// probably consciously caused the title change to change in the current session
|
||||
// and don't want an annoying toast for that.
|
||||
mActivity.showToast(toToastTitle(updatedSession), true);
|
||||
}
|
||||
|
||||
termuxSessionListNotifyUpdated();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSessionFinished(@NonNull TerminalSession finishedSession) {
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
|
||||
if (service == null || service.wantsToStop()) {
|
||||
// The service wants to stop as soon as possible.
|
||||
mActivity.finishActivityIfNotFinishing();
|
||||
return;
|
||||
}
|
||||
|
||||
int index = service.getIndexOfSession(finishedSession);
|
||||
|
||||
// For plugin commands that expect the result back, we should immediately close the session
|
||||
// and send the result back instead of waiting fo the user to press enter.
|
||||
// The plugin can handle/show errors itself.
|
||||
boolean isPluginExecutionCommandWithPendingResult = false;
|
||||
TermuxSession termuxSession = service.getTermuxSession(index);
|
||||
if (termuxSession != null) {
|
||||
isPluginExecutionCommandWithPendingResult = termuxSession.getExecutionCommand().isPluginExecutionCommandWithPendingResult();
|
||||
if (isPluginExecutionCommandWithPendingResult)
|
||||
Logger.logVerbose(LOG_TAG, "The \"" + finishedSession.mSessionName + "\" session will be force finished automatically since result in pending.");
|
||||
}
|
||||
|
||||
if (mActivity.isVisible() && finishedSession != mActivity.getCurrentSession()) {
|
||||
// Show toast for non-current sessions that exit.
|
||||
// Verify that session was not removed before we got told about it finishing:
|
||||
if (index >= 0)
|
||||
mActivity.showToast(toToastTitle(finishedSession) + " - exited", true);
|
||||
}
|
||||
|
||||
if (mActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
|
||||
// On Android TV devices we need to use older behaviour because we may
|
||||
// not be able to have multiple launcher icons.
|
||||
if (service.getTermuxSessionsSize() > 1 || isPluginExecutionCommandWithPendingResult) {
|
||||
removeFinishedSession(finishedSession);
|
||||
}
|
||||
} else {
|
||||
// Once we have a separate launcher icon for the failsafe session, it
|
||||
// should be safe to auto-close session on exit code '0' or '130'.
|
||||
if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130 || isPluginExecutionCommandWithPendingResult) {
|
||||
removeFinishedSession(finishedSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyTextToClipboard(@NonNull TerminalSession session, String text) {
|
||||
if (!mActivity.isVisible()) return;
|
||||
|
||||
ShareUtils.copyTextToClipboard(mActivity, text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPasteTextFromClipboard(@Nullable TerminalSession session) {
|
||||
if (!mActivity.isVisible()) return;
|
||||
|
||||
String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true);
|
||||
if (text != null)
|
||||
mActivity.getTerminalView().mEmulator.paste(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBell(@NonNull TerminalSession session) {
|
||||
if (!mActivity.isVisible()) return;
|
||||
|
||||
switch (mActivity.getProperties().getBellBehaviour()) {
|
||||
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_VIBRATE:
|
||||
BellHandler.getInstance(mActivity).doBell();
|
||||
break;
|
||||
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_BEEP:
|
||||
loadBellSoundPool();
|
||||
if (mBellSoundPool != null)
|
||||
mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f);
|
||||
break;
|
||||
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_IGNORE:
|
||||
// Ignore the bell character.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorsChanged(@NonNull TerminalSession changedSession) {
|
||||
if (mActivity.getCurrentSession() == changedSession)
|
||||
updateBackgroundColor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTerminalCursorStateChange(boolean enabled) {
|
||||
// Do not start cursor blinking thread if activity is not visible
|
||||
if (enabled && !mActivity.isVisible()) {
|
||||
Logger.logVerbose(LOG_TAG, "Ignoring call to start cursor blinking since activity is not visible");
|
||||
return;
|
||||
}
|
||||
|
||||
// If cursor is to enabled now, then start cursor blinking if blinking is enabled
|
||||
// otherwise stop cursor blinking
|
||||
mActivity.getTerminalView().setTerminalCursorBlinkerState(enabled, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) {
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return;
|
||||
|
||||
TermuxSession termuxSession = service.getTermuxSessionForTerminalSession(terminalSession);
|
||||
if (termuxSession != null)
|
||||
termuxSession.getExecutionCommand().mPid = pid;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onResetTerminalSession() is called
|
||||
*/
|
||||
public void onResetTerminalSession() {
|
||||
// Ensure blinker starts again after reset if cursor blinking was disabled before reset like
|
||||
// with "tput civis" which would have called onTerminalCursorStateChange()
|
||||
mActivity.getTerminalView().setTerminalCursorBlinkerState(true, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Integer getTerminalCursorStyle() {
|
||||
return mActivity.getProperties().getTerminalCursorStyle();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Load mBellSoundPool */
|
||||
private synchronized void loadBellSoundPool() {
|
||||
if (mBellSoundPool == null) {
|
||||
mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes(
|
||||
new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build();
|
||||
|
||||
try {
|
||||
mBellSoundId = mBellSoundPool.load(mActivity, R.raw.bell, 1);
|
||||
} catch (Exception e){
|
||||
// Catch java.lang.RuntimeException: Unable to resume activity {com.termux/com.itsaky.androidide.activities.TermuxActivity}: android.content.res.Resources$NotFoundException: File res/raw/bell.ogg from drawable resource ID
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "Failed to load bell sound pool", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Release mBellSoundPool resources */
|
||||
private synchronized void releaseBellSoundPool() {
|
||||
if (mBellSoundPool != null) {
|
||||
mBellSoundPool.release();
|
||||
mBellSoundPool = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Try switching to session. */
|
||||
public void setCurrentSession(TerminalSession session) {
|
||||
if (session == null) return;
|
||||
|
||||
if (mActivity.getTerminalView().attachSession(session)) {
|
||||
// notify about switched session if not already displaying the session
|
||||
notifyOfSessionChange();
|
||||
}
|
||||
|
||||
// We call the following even when the session is already being displayed since config may
|
||||
// be stale, like current session not selected or scrolled to.
|
||||
checkAndScrollToSession(session);
|
||||
updateBackgroundColor();
|
||||
}
|
||||
|
||||
void notifyOfSessionChange() {
|
||||
if (!mActivity.isVisible()) return;
|
||||
|
||||
if (!mActivity.getProperties().areTerminalSessionChangeToastsDisabled()) {
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
mActivity.showToast(toToastTitle(session), false);
|
||||
}
|
||||
}
|
||||
|
||||
public void switchToSession(boolean forward) {
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return;
|
||||
|
||||
TerminalSession currentTerminalSession = mActivity.getCurrentSession();
|
||||
int index = service.getIndexOfSession(currentTerminalSession);
|
||||
int size = service.getTermuxSessionsSize();
|
||||
if (forward) {
|
||||
if (++index >= size) index = 0;
|
||||
} else {
|
||||
if (--index < 0) index = size - 1;
|
||||
}
|
||||
|
||||
TermuxSession termuxSession = service.getTermuxSession(index);
|
||||
if (termuxSession != null)
|
||||
setCurrentSession(termuxSession.getTerminalSession());
|
||||
}
|
||||
|
||||
public void switchToSession(int index) {
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return;
|
||||
|
||||
TermuxSession termuxSession = service.getTermuxSession(index);
|
||||
if (termuxSession != null)
|
||||
setCurrentSession(termuxSession.getTerminalSession());
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
public void renameSession(final TerminalSession sessionToRename) {
|
||||
if (sessionToRename == null) return;
|
||||
|
||||
TextInputDialogUtils.textInput(mActivity, R.string.title_rename_session, sessionToRename.mSessionName, R.string.action_rename_session_confirm, text -> {
|
||||
renameSession(sessionToRename, text);
|
||||
termuxSessionListNotifyUpdated();
|
||||
}, -1, null, -1, null, null);
|
||||
}
|
||||
|
||||
private void renameSession(TerminalSession sessionToRename, String text) {
|
||||
if (sessionToRename == null) return;
|
||||
sessionToRename.mSessionName = text;
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service != null) {
|
||||
TermuxSession termuxSession = service.getTermuxSessionForTerminalSession(sessionToRename);
|
||||
if (termuxSession != null)
|
||||
termuxSession.getExecutionCommand().shellName = text;
|
||||
}
|
||||
}
|
||||
|
||||
public void addNewSession(boolean isFailSafe, String sessioName) {
|
||||
addNewSession(isFailSafe, sessioName, null);
|
||||
}
|
||||
|
||||
public void addNewSession(boolean isFailSafe, String sessionName, String workingDirectory) {
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return;
|
||||
|
||||
if (service.getTermuxSessionsSize() >= MAX_SESSIONS) {
|
||||
new AlertDialog.Builder(mActivity).setTitle(R.string.title_max_terminals_reached).setMessage(R.string.msg_max_terminals_reached)
|
||||
.setPositiveButton(android.R.string.ok, null).show();
|
||||
} else {
|
||||
TerminalSession currentSession = mActivity.getCurrentSession();
|
||||
|
||||
if (workingDirectory == null) {
|
||||
if (currentSession == null) {
|
||||
workingDirectory = mActivity.getProperties().getDefaultWorkingDirectory();
|
||||
} else {
|
||||
workingDirectory = currentSession.getCwd();
|
||||
}
|
||||
}
|
||||
|
||||
TermuxSession newTermuxSession = service.createTermuxSession(null, null, null, workingDirectory, isFailSafe, sessionName);
|
||||
if (newTermuxSession == null) return;
|
||||
|
||||
TerminalSession newTerminalSession = newTermuxSession.getTerminalSession();
|
||||
setCurrentSession(newTerminalSession);
|
||||
|
||||
mActivity.getDrawer().closeDrawers();
|
||||
}
|
||||
}
|
||||
|
||||
public void setCurrentStoredSession() {
|
||||
TerminalSession currentSession = mActivity.getCurrentSession();
|
||||
if (currentSession != null)
|
||||
mActivity.getPreferences().setCurrentSession(currentSession.mHandle);
|
||||
else
|
||||
mActivity.getPreferences().setCurrentSession(null);
|
||||
}
|
||||
|
||||
/** The current session as stored or the last one if that does not exist. */
|
||||
public TerminalSession getCurrentStoredSessionOrLast() {
|
||||
TerminalSession stored = getCurrentStoredSession();
|
||||
|
||||
if (stored != null) {
|
||||
// If a stored session is in the list of currently running sessions, then return it
|
||||
return stored;
|
||||
} else {
|
||||
// Else return the last session currently running
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return null;
|
||||
|
||||
TermuxSession termuxSession = service.getLastTermuxSession();
|
||||
if (termuxSession != null)
|
||||
return termuxSession.getTerminalSession();
|
||||
else
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TerminalSession getCurrentStoredSession() {
|
||||
String sessionHandle = mActivity.getPreferences().getCurrentSession();
|
||||
|
||||
// If no session is stored in shared preferences
|
||||
if (sessionHandle == null)
|
||||
return null;
|
||||
|
||||
// Check if the session handle found matches one of the currently running sessions
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return null;
|
||||
|
||||
return service.getTerminalSessionForHandle(sessionHandle);
|
||||
}
|
||||
|
||||
public void removeFinishedSession(TerminalSession finishedSession) {
|
||||
// Return pressed with finished session - remove it.
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return;
|
||||
|
||||
int index = service.removeTermuxSession(finishedSession);
|
||||
|
||||
int size = service.getTermuxSessionsSize();
|
||||
if (size == 0) {
|
||||
// There are no sessions to show, so finish the activity.
|
||||
mActivity.finishActivityIfNotFinishing();
|
||||
} else {
|
||||
if (index >= size) {
|
||||
index = size - 1;
|
||||
}
|
||||
TermuxSession termuxSession = service.getTermuxSession(index);
|
||||
if (termuxSession != null)
|
||||
setCurrentSession(termuxSession.getTerminalSession());
|
||||
}
|
||||
}
|
||||
|
||||
public void termuxSessionListNotifyUpdated() {
|
||||
mActivity.termuxSessionListNotifyUpdated();
|
||||
}
|
||||
|
||||
public void checkAndScrollToSession(TerminalSession session) {
|
||||
if (!mActivity.isVisible()) return;
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return;
|
||||
|
||||
final int indexOfSession = service.getIndexOfSession(session);
|
||||
if (indexOfSession < 0) return;
|
||||
final ListView termuxSessionsListView = mActivity.findViewById(R.id.terminal_sessions_list);
|
||||
if (termuxSessionsListView == null) return;
|
||||
|
||||
termuxSessionsListView.setItemChecked(indexOfSession, true);
|
||||
// Delay is necessary otherwise sometimes scroll to newly added session does not happen
|
||||
termuxSessionsListView.postDelayed(() -> termuxSessionsListView.smoothScrollToPosition(indexOfSession), 1000);
|
||||
}
|
||||
|
||||
|
||||
String toToastTitle(TerminalSession session) {
|
||||
TermuxService service = mActivity.getTermuxService();
|
||||
if (service == null) return null;
|
||||
|
||||
final int indexOfSession = service.getIndexOfSession(session);
|
||||
if (indexOfSession < 0) return null;
|
||||
StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]");
|
||||
if (!TextUtils.isEmpty(session.mSessionName)) {
|
||||
toastTitle.append(" ").append(session.mSessionName);
|
||||
}
|
||||
String title = session.getTitle();
|
||||
if (!TextUtils.isEmpty(title)) {
|
||||
// Space to "[${NR}] or newline after session name:
|
||||
toastTitle.append(session.mSessionName == null ? " " : "\n");
|
||||
toastTitle.append(title);
|
||||
}
|
||||
return toastTitle.toString();
|
||||
}
|
||||
|
||||
|
||||
public void checkForFontAndColors() {
|
||||
try {
|
||||
File colorsFile = TermuxConstants.TERMUX_COLOR_PROPERTIES_FILE;
|
||||
File fontFile = TermuxConstants.TERMUX_FONT_FILE;
|
||||
|
||||
final Properties props = new Properties();
|
||||
if (colorsFile.isFile()) {
|
||||
try (InputStream in = new FileInputStream(colorsFile)) {
|
||||
props.load(in);
|
||||
}
|
||||
}
|
||||
|
||||
TerminalColors.COLOR_SCHEME.updateWith(props);
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session != null && session.getEmulator() != null) {
|
||||
session.getEmulator().mColors.reset();
|
||||
}
|
||||
updateBackgroundColor();
|
||||
|
||||
final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE;
|
||||
mActivity.getTerminalView().setTypeface(newTypeface);
|
||||
} catch (Exception e) {
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "Error in checkForFontAndColors()", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateBackgroundColor() {
|
||||
if (!mActivity.isVisible()) return;
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session != null && session.getEmulator() != null) {
|
||||
mActivity.getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.termux.app.terminal;
|
||||
|
||||
import android.app.Service;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.termux.app.TermuxService;
|
||||
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
|
||||
import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
import com.termux.terminal.TerminalSessionClient;
|
||||
|
||||
/** The {@link TerminalSessionClient} implementation that may require a {@link Service} for its interface methods. */
|
||||
public class TermuxTerminalSessionServiceClient extends TermuxTerminalSessionClientBase {
|
||||
|
||||
private static final String LOG_TAG = "TermuxTerminalSessionServiceClient";
|
||||
|
||||
private final TermuxService mService;
|
||||
|
||||
public TermuxTerminalSessionServiceClient(TermuxService service) {
|
||||
this.mService = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) {
|
||||
TermuxSession termuxSession = mService.getTermuxSessionForTerminalSession(terminalSession);
|
||||
if (termuxSession != null)
|
||||
termuxSession.getExecutionCommand().mPid = pid;
|
||||
}
|
||||
|
||||
}
|
||||
+798
@@ -0,0 +1,798 @@
|
||||
package com.termux.app.terminal;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Environment;
|
||||
import android.view.Gravity;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
|
||||
import com.termux.R;
|
||||
import com.termux.app.TermuxActivity;
|
||||
import com.termux.shared.file.FileUtils;
|
||||
import com.termux.shared.interact.MessageDialogUtils;
|
||||
import com.termux.shared.interact.ShareUtils;
|
||||
import com.termux.shared.shell.ShellUtils;
|
||||
import com.termux.shared.termux.TermuxBootstrap;
|
||||
import com.termux.shared.termux.terminal.TermuxTerminalViewClientBase;
|
||||
import com.termux.shared.termux.extrakeys.SpecialButton;
|
||||
import com.termux.shared.android.AndroidUtils;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.shared.activities.ReportActivity;
|
||||
import com.termux.shared.models.ReportInfo;
|
||||
import com.termux.app.models.UserAction;
|
||||
import com.termux.app.terminal.io.KeyboardShortcut;
|
||||
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
|
||||
import com.termux.shared.data.DataUtils;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.markdown.MarkdownUtils;
|
||||
import com.termux.shared.termux.TermuxUtils;
|
||||
import com.termux.shared.termux.data.TermuxUrlUtils;
|
||||
import com.termux.shared.view.KeyboardUtils;
|
||||
import com.termux.shared.view.ViewUtils;
|
||||
import com.termux.terminal.KeyHandler;
|
||||
import com.termux.terminal.TerminalEmulator;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
|
||||
public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
|
||||
|
||||
final TermuxActivity mActivity;
|
||||
|
||||
final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
|
||||
|
||||
/** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */
|
||||
boolean mVirtualControlKeyDown, mVirtualFnKeyDown;
|
||||
|
||||
private Runnable mShowSoftKeyboardRunnable;
|
||||
|
||||
private boolean mShowSoftKeyboardIgnoreOnce;
|
||||
private boolean mShowSoftKeyboardWithDelayOnce;
|
||||
|
||||
private boolean mTerminalCursorBlinkerStateAlreadySet;
|
||||
|
||||
private List<KeyboardShortcut> mSessionShortcuts;
|
||||
|
||||
private static final String LOG_TAG = "TermuxTerminalViewClient";
|
||||
|
||||
public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
|
||||
this.mActivity = activity;
|
||||
this.mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
|
||||
}
|
||||
|
||||
public TermuxActivity getActivity() {
|
||||
return mActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onCreate() is called
|
||||
*/
|
||||
public void onCreate() {
|
||||
onReloadProperties();
|
||||
|
||||
mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize());
|
||||
mActivity.getTerminalView().setKeepScreenOn(mActivity.getPreferences().shouldKeepScreenOn());
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onStart() is called
|
||||
*/
|
||||
public void onStart() {
|
||||
// Set {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value
|
||||
// Also required if user changed the preference from {@link TermuxSettings} activity and returns
|
||||
boolean isTerminalViewKeyLoggingEnabled = mActivity.getPreferences().isTerminalViewKeyLoggingEnabled();
|
||||
mActivity.getTerminalView().setIsTerminalViewKeyLoggingEnabled(isTerminalViewKeyLoggingEnabled);
|
||||
|
||||
// Piggyback on the terminal view key logging toggle for now, should add a separate toggle in future
|
||||
mActivity.getTermuxActivityRootView().setIsRootViewLoggingEnabled(isTerminalViewKeyLoggingEnabled);
|
||||
ViewUtils.setIsViewUtilsLoggingEnabled(isTerminalViewKeyLoggingEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onResume() is called
|
||||
*/
|
||||
public void onResume() {
|
||||
// Show the soft keyboard if required
|
||||
setSoftKeyboardState(true, mActivity.isActivityRecreated());
|
||||
|
||||
mTerminalCursorBlinkerStateAlreadySet = false;
|
||||
|
||||
if (mActivity.getTerminalView().mEmulator != null) {
|
||||
// Start terminal cursor blinking if enabled
|
||||
// If emulator is already set, then start blinker now, otherwise wait for onEmulatorSet()
|
||||
// event to start it. This is needed since onEmulatorSet() may not be called after
|
||||
// TermuxActivity is started after device display timeout with double tap and not power button.
|
||||
setTerminalCursorBlinkerState(true);
|
||||
mTerminalCursorBlinkerStateAlreadySet = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.onStop() is called
|
||||
*/
|
||||
public void onStop() {
|
||||
// Stop terminal cursor blinking if enabled
|
||||
setTerminalCursorBlinkerState(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.reloadProperties() is called
|
||||
*/
|
||||
public void onReloadProperties() {
|
||||
setSessionShortcuts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when mActivity.reloadActivityStyling() is called
|
||||
*/
|
||||
public void onReloadActivityStyling() {
|
||||
// Show the soft keyboard if required
|
||||
setSoftKeyboardState(false, true);
|
||||
|
||||
// Start terminal cursor blinking if enabled
|
||||
setTerminalCursorBlinkerState(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when {@link com.termux.view.TerminalView#mEmulator} is set
|
||||
*/
|
||||
@Override
|
||||
public void onEmulatorSet() {
|
||||
if (!mTerminalCursorBlinkerStateAlreadySet) {
|
||||
// Start terminal cursor blinking if enabled
|
||||
// We need to wait for the first session to be attached that's set in
|
||||
// TermuxActivity.onServiceConnected() and then the multiple calls to TerminalView.updateSize()
|
||||
// where the final one eventually sets the mEmulator when width/height is not 0. Otherwise
|
||||
// blinker will not start again if TermuxActivity is started again after exiting it with
|
||||
// double back press. Check TerminalView.setTerminalCursorBlinkerState().
|
||||
setTerminalCursorBlinkerState(true);
|
||||
mTerminalCursorBlinkerStateAlreadySet = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public float onScale(float scale) {
|
||||
if (scale < 0.9f || scale > 1.1f) {
|
||||
boolean increase = scale > 1.f;
|
||||
changeFontSize(increase);
|
||||
return 1.0f;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void onSingleTapUp(MotionEvent e) {
|
||||
TerminalEmulator term = mActivity.getCurrentSession().getEmulator();
|
||||
|
||||
if (mActivity.getProperties().shouldOpenTerminalTranscriptURLOnClick()) {
|
||||
int[] columnAndRow = mActivity.getTerminalView().getColumnAndRow(e, true);
|
||||
String wordAtTap = term.getScreen().getWordAtLocation(columnAndRow[0], columnAndRow[1]);
|
||||
LinkedHashSet<CharSequence> urlSet = TermuxUrlUtils.extractUrls(wordAtTap);
|
||||
|
||||
if (!urlSet.isEmpty()) {
|
||||
String url = (String) urlSet.iterator().next();
|
||||
ShareUtils.openUrl(mActivity, url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!term.isMouseTrackingActive() && !e.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
||||
if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity))
|
||||
KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
else
|
||||
Logger.logVerbose(LOG_TAG, "Not showing soft keyboard onSingleTapUp since its disabled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldBackButtonBeMappedToEscape() {
|
||||
return mActivity.getProperties().isBackKeyTheEscapeKey();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldEnforceCharBasedInput() {
|
||||
return mActivity.getProperties().isEnforcingCharBasedInput();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldUseCtrlSpaceWorkaround() {
|
||||
return mActivity.getProperties().isUsingCtrlSpaceWorkaround();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminalViewSelected() {
|
||||
return mActivity.getTerminalToolbarViewPager() == null || mActivity.isTerminalViewSelected() || mActivity.getTerminalView().hasFocus();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void copyModeChanged(boolean copyMode) {
|
||||
// Disable drawer while copying.
|
||||
mActivity.getDrawer().setDrawerLockMode(copyMode ? DrawerLayout.LOCK_MODE_LOCKED_CLOSED : DrawerLayout.LOCK_MODE_UNLOCKED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) {
|
||||
if (handleVirtualKeys(keyCode, e, true)) return true;
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) {
|
||||
mTermuxTerminalSessionActivityClient.removeFinishedSession(currentSession);
|
||||
return true;
|
||||
} else if (!mActivity.getProperties().areHardwareKeyboardShortcutsDisabled() &&
|
||||
e.isCtrlPressed() && e.isAltPressed()) {
|
||||
// Get the unmodified code point:
|
||||
int unicodeChar = e.getUnicodeChar(0);
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) {
|
||||
mTermuxTerminalSessionActivityClient.switchToSession(true);
|
||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) {
|
||||
mTermuxTerminalSessionActivityClient.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 */) {
|
||||
onToggleSoftKeyboardRequest();
|
||||
} else if (unicodeChar == 'm'/* menu */) {
|
||||
mActivity.getTerminalView().showContextMenu();
|
||||
} else if (unicodeChar == 'r'/* rename */) {
|
||||
mTermuxTerminalSessionActivityClient.renameSession(currentSession);
|
||||
} else if (unicodeChar == 'c'/* create */) {
|
||||
mTermuxTerminalSessionActivityClient.addNewSession(false, null);
|
||||
} else if (unicodeChar == 'u' /* urls */) {
|
||||
showUrlSelection();
|
||||
} else if (unicodeChar == 'v') {
|
||||
doPaste();
|
||||
} else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') {
|
||||
// We also check for the shifted char here since shift may be required to produce '+',
|
||||
// see https://github.com/termux/termux-api/issues/2
|
||||
changeFontSize(true);
|
||||
} else if (unicodeChar == '-') {
|
||||
changeFontSize(false);
|
||||
} else if (unicodeChar >= '1' && unicodeChar <= '9') {
|
||||
int index = unicodeChar - '1';
|
||||
mTermuxTerminalSessionActivityClient.switchToSession(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onKeyUp(int keyCode, KeyEvent e) {
|
||||
// If emulator is not set, like if bootstrap installation failed and user dismissed the error
|
||||
// dialog, then just exit the activity, otherwise they will be stuck in a broken state.
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && mActivity.getTerminalView().mEmulator == null) {
|
||||
mActivity.finishActivityIfNotFinishing();
|
||||
return true;
|
||||
}
|
||||
|
||||
return handleVirtualKeys(keyCode, e, false);
|
||||
}
|
||||
|
||||
/** Handle dedicated volume buttons as virtual keys if applicable. */
|
||||
private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) {
|
||||
InputDevice inputDevice = event.getDevice();
|
||||
if (mActivity.getProperties().areVirtualVolumeKeysDisabled()) {
|
||||
return false;
|
||||
} else if (inputDevice != null && inputDevice.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
|
||||
// Do not steal dedicated buttons from a full external keyboard.
|
||||
return false;
|
||||
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
mVirtualControlKeyDown = down;
|
||||
return true;
|
||||
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
mVirtualFnKeyDown = down;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public boolean readControlKey() {
|
||||
return readExtraKeysSpecialButton(SpecialButton.CTRL) || mVirtualControlKeyDown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readAltKey() {
|
||||
return readExtraKeysSpecialButton(SpecialButton.ALT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readShiftKey() {
|
||||
return readExtraKeysSpecialButton(SpecialButton.SHIFT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readFnKey() {
|
||||
return readExtraKeysSpecialButton(SpecialButton.FN);
|
||||
}
|
||||
|
||||
public boolean readExtraKeysSpecialButton(SpecialButton specialButton) {
|
||||
if (mActivity.getExtraKeysView() == null) return false;
|
||||
Boolean state = mActivity.getExtraKeysView().readSpecialButton(specialButton, true);
|
||||
if (state == null) {
|
||||
Logger.logError(LOG_TAG,"Failed to read an unregistered " + specialButton + " special button value from extra keys.");
|
||||
return false;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongPress(MotionEvent event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) {
|
||||
if (mVirtualFnKeyDown) {
|
||||
int resultingKeyCode = -1;
|
||||
int resultingCodePoint = -1;
|
||||
boolean altDown = false;
|
||||
int lowerCase = Character.toLowerCase(codePoint);
|
||||
switch (lowerCase) {
|
||||
// Arrow keys.
|
||||
case 'w':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_DPAD_UP;
|
||||
break;
|
||||
case 'a':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_DPAD_LEFT;
|
||||
break;
|
||||
case 's':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_DPAD_DOWN;
|
||||
break;
|
||||
case 'd':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_DPAD_RIGHT;
|
||||
break;
|
||||
|
||||
// Page up and down.
|
||||
case 'p':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_PAGE_UP;
|
||||
break;
|
||||
case 'n':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_PAGE_DOWN;
|
||||
break;
|
||||
|
||||
// Some special keys:
|
||||
case 't':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_TAB;
|
||||
break;
|
||||
case 'i':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_INSERT;
|
||||
break;
|
||||
case 'h':
|
||||
resultingCodePoint = '~';
|
||||
break;
|
||||
|
||||
// Special characters to input.
|
||||
case 'u':
|
||||
resultingCodePoint = '_';
|
||||
break;
|
||||
case 'l':
|
||||
resultingCodePoint = '|';
|
||||
break;
|
||||
|
||||
// Function keys.
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
case '5':
|
||||
case '6':
|
||||
case '7':
|
||||
case '8':
|
||||
case '9':
|
||||
resultingKeyCode = (codePoint - '1') + KeyEvent.KEYCODE_F1;
|
||||
break;
|
||||
case '0':
|
||||
resultingKeyCode = KeyEvent.KEYCODE_F10;
|
||||
break;
|
||||
|
||||
// Other special keys.
|
||||
case 'e':
|
||||
resultingCodePoint = /*Escape*/ 27;
|
||||
break;
|
||||
case '.':
|
||||
resultingCodePoint = /*^.*/ 28;
|
||||
break;
|
||||
|
||||
case 'b': // alt+b, jumping backward in readline.
|
||||
case 'f': // alf+f, jumping forward in readline.
|
||||
case 'x': // alt+x, common in emacs.
|
||||
resultingCodePoint = lowerCase;
|
||||
altDown = true;
|
||||
break;
|
||||
|
||||
// Volume control.
|
||||
case 'v':
|
||||
resultingCodePoint = -1;
|
||||
AudioManager audio = (AudioManager) mActivity.getSystemService(Context.AUDIO_SERVICE);
|
||||
audio.adjustSuggestedStreamVolume(AudioManager.ADJUST_SAME, AudioManager.USE_DEFAULT_STREAM_TYPE, AudioManager.FLAG_SHOW_UI);
|
||||
break;
|
||||
|
||||
// Writing mode:
|
||||
case 'q':
|
||||
case 'k':
|
||||
mActivity.toggleTerminalToolbar();
|
||||
mVirtualFnKeyDown=false; // force disable fn key down to restore keyboard input into terminal view, fixes termux/termux-app#1420
|
||||
break;
|
||||
}
|
||||
|
||||
if (resultingKeyCode != -1) {
|
||||
TerminalEmulator term = session.getEmulator();
|
||||
session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()));
|
||||
} else if (resultingCodePoint != -1) {
|
||||
session.writeCodePoint(altDown, resultingCodePoint);
|
||||
}
|
||||
return true;
|
||||
} else if (ctrlDown) {
|
||||
if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) {
|
||||
mTermuxTerminalSessionActivityClient.removeFinishedSession(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
List<KeyboardShortcut> shortcuts = mSessionShortcuts;
|
||||
if (shortcuts != null && !shortcuts.isEmpty()) {
|
||||
int codePointLowerCase = Character.toLowerCase(codePoint);
|
||||
for (int i = shortcuts.size() - 1; i >= 0; i--) {
|
||||
KeyboardShortcut shortcut = shortcuts.get(i);
|
||||
if (codePointLowerCase == shortcut.codePoint) {
|
||||
switch (shortcut.shortcutAction) {
|
||||
case TermuxPropertyConstants.ACTION_SHORTCUT_CREATE_SESSION:
|
||||
mTermuxTerminalSessionActivityClient.addNewSession(false, null);
|
||||
return true;
|
||||
case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION:
|
||||
mTermuxTerminalSessionActivityClient.switchToSession(true);
|
||||
return true;
|
||||
case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION:
|
||||
mTermuxTerminalSessionActivityClient.switchToSession(false);
|
||||
return true;
|
||||
case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION:
|
||||
mTermuxTerminalSessionActivityClient.renameSession(mActivity.getCurrentSession());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the terminal sessions shortcuts.
|
||||
*/
|
||||
private void setSessionShortcuts() {
|
||||
mSessionShortcuts = new ArrayList<>();
|
||||
|
||||
// The {@link TermuxPropertyConstants#MAP_SESSION_SHORTCUTS} stores the session shortcut key and action pair
|
||||
for (Map.Entry<String, Integer> entry : TermuxPropertyConstants.MAP_SESSION_SHORTCUTS.entrySet()) {
|
||||
// The mMap stores the code points for the session shortcuts while loading properties
|
||||
Integer codePoint = (Integer) mActivity.getProperties().getInternalPropertyValue(entry.getKey(), true);
|
||||
// If codePoint is null, then session shortcut did not exist in properties or was invalid
|
||||
// as parsed by {@link #getCodePointForSessionShortcuts(String,String)}
|
||||
// If codePoint is not null, then get the action for the MAP_SESSION_SHORTCUTS key and
|
||||
// add the code point to sessionShortcuts
|
||||
if (codePoint != null)
|
||||
mSessionShortcuts.add(new KeyboardShortcut(codePoint, entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public void changeFontSize(boolean increase) {
|
||||
mActivity.getPreferences().changeFontSize(increase);
|
||||
mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Called when user requests the soft keyboard to be toggled via "KEYBOARD" toggle button in
|
||||
* drawer or extra keys, or with ctrl+alt+k hardware keyboard shortcut.
|
||||
*/
|
||||
public void onToggleSoftKeyboardRequest() {
|
||||
// If soft keyboard toggle behaviour is enable/disabled
|
||||
if (mActivity.getProperties().shouldEnableDisableSoftKeyboardOnToggle()) {
|
||||
// If soft keyboard is visible
|
||||
if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity)) {
|
||||
Logger.logVerbose(LOG_TAG, "Disabling soft keyboard on toggle");
|
||||
mActivity.getPreferences().setSoftKeyboardEnabled(false);
|
||||
KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
} else {
|
||||
// Show with a delay, otherwise pressing keyboard toggle won't show the keyboard after
|
||||
// switching back from another app if keyboard was previously disabled by user.
|
||||
// Also request focus, since it wouldn't have been requested at startup by
|
||||
// setSoftKeyboardState if keyboard was disabled. #2112
|
||||
Logger.logVerbose(LOG_TAG, "Enabling soft keyboard on toggle");
|
||||
mActivity.getPreferences().setSoftKeyboardEnabled(true);
|
||||
KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity);
|
||||
if(mShowSoftKeyboardWithDelayOnce) {
|
||||
mShowSoftKeyboardWithDelayOnce = false;
|
||||
mActivity.getTerminalView().postDelayed(getShowSoftKeyboardRunnable(), 500);
|
||||
mActivity.getTerminalView().requestFocus();
|
||||
} else
|
||||
KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
}
|
||||
}
|
||||
// If soft keyboard toggle behaviour is show/hide
|
||||
else {
|
||||
// If soft keyboard is disabled by user for Termux
|
||||
if (!mActivity.getPreferences().isSoftKeyboardEnabled()) {
|
||||
Logger.logVerbose(LOG_TAG, "Maintaining disabled soft keyboard on toggle");
|
||||
KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
} else {
|
||||
Logger.logVerbose(LOG_TAG, "Showing/Hiding soft keyboard on toggle");
|
||||
KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity);
|
||||
KeyboardUtils.toggleSoftKeyboard(mActivity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setSoftKeyboardState(boolean isStartup, boolean isReloadTermuxProperties) {
|
||||
boolean noShowKeyboard = false;
|
||||
|
||||
// Requesting terminal view focus is necessary regardless of if soft keyboard is to be
|
||||
// disabled or hidden at startup, otherwise if hardware keyboard is attached and user
|
||||
// starts typing on hardware keyboard without tapping on the terminal first, then a colour
|
||||
// tint will be added to the terminal as highlight for the focussed view. Test with a light
|
||||
// theme. For android 8.+, the "defaultFocusHighlightEnabled" attribute is also set to false
|
||||
// in TerminalView layout to fix the issue.
|
||||
|
||||
// If soft keyboard is disabled by user for Termux (check function docs for Termux behaviour info)
|
||||
if (KeyboardUtils.shouldSoftKeyboardBeDisabled(mActivity,
|
||||
mActivity.getPreferences().isSoftKeyboardEnabled(),
|
||||
mActivity.getPreferences().isSoftKeyboardEnabledOnlyIfNoHardware())) {
|
||||
Logger.logVerbose(LOG_TAG, "Maintaining disabled soft keyboard");
|
||||
KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
mActivity.getTerminalView().requestFocus();
|
||||
noShowKeyboard = true;
|
||||
// Delay is only required if onCreate() is called like when Termux app is exited with
|
||||
// double back press, not when Termux app is switched back from another app and keyboard
|
||||
// toggle is pressed to enable keyboard
|
||||
if (isStartup && mActivity.isOnResumeAfterOnCreate())
|
||||
mShowSoftKeyboardWithDelayOnce = true;
|
||||
} else {
|
||||
// Set flag to automatically push up TerminalView when keyboard is opened instead of showing over it
|
||||
KeyboardUtils.setSoftInputModeAdjustResize(mActivity);
|
||||
|
||||
// Clear any previous flags to disable soft keyboard in case setting updated
|
||||
KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity);
|
||||
|
||||
// If soft keyboard is to be hidden on startup
|
||||
if (isStartup && mActivity.getProperties().shouldSoftKeyboardBeHiddenOnStartup()) {
|
||||
Logger.logVerbose(LOG_TAG, "Hiding soft keyboard on startup");
|
||||
// Required to keep keyboard hidden when Termux app is switched back from another app
|
||||
KeyboardUtils.setSoftKeyboardAlwaysHiddenFlags(mActivity);
|
||||
|
||||
KeyboardUtils.hideSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
mActivity.getTerminalView().requestFocus();
|
||||
noShowKeyboard = true;
|
||||
// Required to keep keyboard hidden on app startup
|
||||
mShowSoftKeyboardIgnoreOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
mActivity.getTerminalView().setOnFocusChangeListener(new View.OnFocusChangeListener() {
|
||||
@Override
|
||||
public void onFocusChange(View view, boolean hasFocus) {
|
||||
// Force show soft keyboard if TerminalView or toolbar text input view has
|
||||
// focus and close it if they don't
|
||||
boolean textInputViewHasFocus = false;
|
||||
final EditText textInputView = mActivity.findViewById(R.id.terminal_toolbar_text_input);
|
||||
if (textInputView != null) textInputViewHasFocus = textInputView.hasFocus();
|
||||
|
||||
if (hasFocus || textInputViewHasFocus) {
|
||||
if (mShowSoftKeyboardIgnoreOnce) {
|
||||
mShowSoftKeyboardIgnoreOnce = false; return;
|
||||
}
|
||||
Logger.logVerbose(LOG_TAG, "Showing soft keyboard on focus change");
|
||||
} else {
|
||||
Logger.logVerbose(LOG_TAG, "Hiding soft keyboard on focus change");
|
||||
}
|
||||
|
||||
KeyboardUtils.setSoftKeyboardVisibility(getShowSoftKeyboardRunnable(), mActivity, mActivity.getTerminalView(), hasFocus || textInputViewHasFocus);
|
||||
}
|
||||
});
|
||||
|
||||
// Do not force show soft keyboard if termux-reload-settings command was run with hardware keyboard
|
||||
// or soft keyboard is to be hidden or is disabled
|
||||
if (!isReloadTermuxProperties && !noShowKeyboard) {
|
||||
// Request focus for TerminalView
|
||||
// Also show the keyboard, since onFocusChange will not be called if TerminalView already
|
||||
// had focus on startup to show the keyboard, like when opening url with context menu
|
||||
// "Select URL" long press and returning to Termux app with back button. This
|
||||
// will also show keyboard even if it was closed before opening url. #2111
|
||||
Logger.logVerbose(LOG_TAG, "Requesting TerminalView focus and showing soft keyboard");
|
||||
mActivity.getTerminalView().requestFocus();
|
||||
mActivity.getTerminalView().postDelayed(getShowSoftKeyboardRunnable(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
private Runnable getShowSoftKeyboardRunnable() {
|
||||
if (mShowSoftKeyboardRunnable == null) {
|
||||
mShowSoftKeyboardRunnable = () -> {
|
||||
KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView());
|
||||
};
|
||||
}
|
||||
return mShowSoftKeyboardRunnable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void setTerminalCursorBlinkerState(boolean start) {
|
||||
if (start) {
|
||||
// If set/update the cursor blinking rate is successful, then enable cursor blinker
|
||||
if (mActivity.getTerminalView().setTerminalCursorBlinkerRate(mActivity.getProperties().getTerminalCursorBlinkRate()))
|
||||
mActivity.getTerminalView().setTerminalCursorBlinkerState(true, true);
|
||||
else
|
||||
Logger.logError(LOG_TAG,"Failed to start cursor blinker");
|
||||
} else {
|
||||
// Disable cursor blinker
|
||||
mActivity.getTerminalView().setTerminalCursorBlinkerState(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void shareSessionTranscript() {
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session == null) return;
|
||||
|
||||
String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true);
|
||||
if (transcriptText == null) return;
|
||||
|
||||
// See https://github.com/termux/termux-app/issues/1166.
|
||||
transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim();
|
||||
ShareUtils.shareText(mActivity, mActivity.getString(R.string.title_share_transcript),
|
||||
transcriptText, mActivity.getString(R.string.title_share_transcript_with));
|
||||
}
|
||||
|
||||
public void shareSelectedText() {
|
||||
String selectedText = mActivity.getTerminalView().getStoredSelectedText();
|
||||
if (DataUtils.isNullOrEmpty(selectedText)) return;
|
||||
ShareUtils.shareText(mActivity, mActivity.getString(R.string.title_share_selected_text),
|
||||
selectedText, mActivity.getString(R.string.title_share_selected_text_with));
|
||||
}
|
||||
|
||||
public void showUrlSelection() {
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session == null) return;
|
||||
|
||||
String text = ShellUtils.getTerminalSessionTranscriptText(session, true, true);
|
||||
|
||||
LinkedHashSet<CharSequence> urlSet = TermuxUrlUtils.extractUrls(text);
|
||||
if (urlSet.isEmpty()) {
|
||||
new AlertDialog.Builder(mActivity).setMessage(R.string.title_select_url_none_found).show();
|
||||
return;
|
||||
}
|
||||
|
||||
final CharSequence[] urls = urlSet.toArray(new CharSequence[0]);
|
||||
Collections.reverse(Arrays.asList(urls)); // Latest first.
|
||||
|
||||
// Click to copy url to clipboard:
|
||||
final AlertDialog dialog = new AlertDialog.Builder(mActivity).setItems(urls, (di, which) -> {
|
||||
String url = (String) urls[which];
|
||||
ShareUtils.copyTextToClipboard(mActivity, url, mActivity.getString(R.string.msg_select_url_copied_to_clipboard));
|
||||
}).setTitle(R.string.title_select_url_dialog).create();
|
||||
|
||||
// Long press to open URL:
|
||||
dialog.setOnShowListener(di -> {
|
||||
ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it
|
||||
lv.setOnItemLongClickListener((parent, view, position, id) -> {
|
||||
dialog.dismiss();
|
||||
String url = (String) urls[position];
|
||||
ShareUtils.openUrl(mActivity, url);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
public void reportIssueFromTranscript() {
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session == null) return;
|
||||
|
||||
final String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true);
|
||||
if (transcriptText == null) return;
|
||||
|
||||
MessageDialogUtils.showMessage(mActivity, TermuxConstants.TERMUX_APP_NAME + " Report Issue",
|
||||
mActivity.getString(R.string.msg_add_termux_debug_info),
|
||||
mActivity.getString(R.string.action_yes), (dialog, which) -> reportIssueFromTranscript(transcriptText, true),
|
||||
mActivity.getString(R.string.action_no), (dialog, which) -> reportIssueFromTranscript(transcriptText, false),
|
||||
null);
|
||||
}
|
||||
|
||||
private void reportIssueFromTranscript(String transcriptText, boolean addTermuxDebugInfo) {
|
||||
Logger.showToast(mActivity, mActivity.getString(R.string.msg_generating_report), true);
|
||||
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
StringBuilder reportString = new StringBuilder();
|
||||
|
||||
String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue";
|
||||
|
||||
reportString.append("## Transcript\n");
|
||||
reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true));
|
||||
reportString.append("\n##\n");
|
||||
|
||||
if (addTermuxDebugInfo) {
|
||||
reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES));
|
||||
} else {
|
||||
reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_PACKAGE));
|
||||
}
|
||||
|
||||
reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(mActivity, true));
|
||||
|
||||
if (TermuxBootstrap.isAppPackageManagerAPT()) {
|
||||
String termuxAptInfo = TermuxUtils.geAPTInfoMarkdownString(mActivity);
|
||||
if (termuxAptInfo != null)
|
||||
reportString.append("\n\n").append(termuxAptInfo);
|
||||
}
|
||||
|
||||
if (addTermuxDebugInfo) {
|
||||
String termuxDebugInfo = TermuxUtils.getTermuxDebugMarkdownString(mActivity);
|
||||
if (termuxDebugInfo != null)
|
||||
reportString.append("\n\n").append(termuxDebugInfo);
|
||||
}
|
||||
|
||||
String userActionName = UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName();
|
||||
|
||||
ReportInfo reportInfo = new ReportInfo(userActionName,
|
||||
TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title);
|
||||
reportInfo.setReportString(reportString.toString());
|
||||
reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity));
|
||||
reportInfo.setReportSaveFileLabelAndPath(userActionName,
|
||||
Environment.getExternalStorageDirectory() + "/" +
|
||||
FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true));
|
||||
|
||||
ReportActivity.startReportActivity(mActivity, reportInfo);
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
public void doPaste() {
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session == null) return;
|
||||
if (!session.isRunning()) return;
|
||||
|
||||
String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true);
|
||||
if (text != null)
|
||||
session.getEmulator().paste(text);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.termux.app.terminal.io;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import com.termux.app.TermuxActivity;
|
||||
|
||||
/**
|
||||
* Work around for fullscreen mode in Termux to fix ExtraKeysView not being visible.
|
||||
* This class is derived from:
|
||||
* https://stackoverflow.com/questions/7417123/android-how-to-adjust-layout-in-full-screen-mode-when-softkeyboard-is-visible
|
||||
* and has some additional tweaks
|
||||
* ---
|
||||
* For more information, see https://issuetracker.google.com/issues/36911528
|
||||
*/
|
||||
public class FullScreenWorkAround {
|
||||
private final View mChildOfContent;
|
||||
private int mUsableHeightPrevious;
|
||||
private final ViewGroup.LayoutParams mViewGroupLayoutParams;
|
||||
|
||||
private final int mNavBarHeight;
|
||||
|
||||
|
||||
public static void apply(TermuxActivity activity) {
|
||||
new FullScreenWorkAround(activity);
|
||||
}
|
||||
|
||||
private FullScreenWorkAround(TermuxActivity activity) {
|
||||
ViewGroup content = activity.findViewById(android.R.id.content);
|
||||
mChildOfContent = content.getChildAt(0);
|
||||
mViewGroupLayoutParams = mChildOfContent.getLayoutParams();
|
||||
mNavBarHeight = activity.getNavBarHeight();
|
||||
mChildOfContent.getViewTreeObserver().addOnGlobalLayoutListener(this::possiblyResizeChildOfContent);
|
||||
}
|
||||
|
||||
private void possiblyResizeChildOfContent() {
|
||||
int usableHeightNow = computeUsableHeight();
|
||||
if (usableHeightNow != mUsableHeightPrevious) {
|
||||
int usableHeightSansKeyboard = mChildOfContent.getRootView().getHeight();
|
||||
int heightDifference = usableHeightSansKeyboard - usableHeightNow;
|
||||
if (heightDifference > (usableHeightSansKeyboard / 4)) {
|
||||
// keyboard probably just became visible
|
||||
|
||||
// ensures that usable layout space does not extend behind the
|
||||
// soft keyboard, causing the extra keys to not be visible
|
||||
mViewGroupLayoutParams.height = (usableHeightSansKeyboard - heightDifference) + getNavBarHeight();
|
||||
} else {
|
||||
// keyboard probably just became hidden
|
||||
mViewGroupLayoutParams.height = usableHeightSansKeyboard;
|
||||
}
|
||||
mChildOfContent.requestLayout();
|
||||
mUsableHeightPrevious = usableHeightNow;
|
||||
}
|
||||
}
|
||||
|
||||
private int getNavBarHeight() {
|
||||
return mNavBarHeight;
|
||||
}
|
||||
|
||||
private int computeUsableHeight() {
|
||||
Rect r = new Rect();
|
||||
mChildOfContent.getWindowVisibleDisplayFrame(r);
|
||||
return (r.bottom - r.top);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.termux.app.terminal.io;
|
||||
|
||||
public class KeyboardShortcut {
|
||||
|
||||
public final int codePoint;
|
||||
public final int shortcutAction;
|
||||
|
||||
public KeyboardShortcut(int codePoint, int shortcutAction) {
|
||||
this.codePoint = codePoint;
|
||||
this.shortcutAction = shortcutAction;
|
||||
}
|
||||
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.termux.app.terminal.io;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.termux.R;
|
||||
import com.termux.app.TermuxActivity;
|
||||
import com.termux.shared.termux.extrakeys.ExtraKeysView;
|
||||
import com.termux.terminal.TerminalSession;
|
||||
|
||||
public class TerminalToolbarViewPager {
|
||||
|
||||
public static class PageAdapter extends PagerAdapter {
|
||||
|
||||
final TermuxActivity mActivity;
|
||||
String mSavedTextInput;
|
||||
|
||||
public PageAdapter(TermuxActivity activity, String savedTextInput) {
|
||||
this.mActivity = activity;
|
||||
this.mSavedTextInput = savedTextInput;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
|
||||
return view == object;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Object instantiateItem(@NonNull ViewGroup collection, int position) {
|
||||
LayoutInflater inflater = LayoutInflater.from(mActivity);
|
||||
View layout;
|
||||
if (position == 0) {
|
||||
layout = inflater.inflate(R.layout.view_terminal_toolbar_extra_keys, collection, false);
|
||||
ExtraKeysView extraKeysView = (ExtraKeysView) layout;
|
||||
extraKeysView.setExtraKeysViewClient(mActivity.getTermuxTerminalExtraKeys());
|
||||
extraKeysView.setButtonTextAllCaps(mActivity.getProperties().shouldExtraKeysTextBeAllCaps());
|
||||
mActivity.setExtraKeysView(extraKeysView);
|
||||
extraKeysView.reload(mActivity.getTermuxTerminalExtraKeys().getExtraKeysInfo(),
|
||||
mActivity.getTerminalToolbarDefaultHeight());
|
||||
|
||||
// apply extra keys fix if enabled in prefs
|
||||
if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) {
|
||||
FullScreenWorkAround.apply(mActivity);
|
||||
}
|
||||
|
||||
} else {
|
||||
layout = inflater.inflate(R.layout.view_terminal_toolbar_text_input, collection, false);
|
||||
final EditText editText = layout.findViewById(R.id.terminal_toolbar_text_input);
|
||||
|
||||
if (mSavedTextInput != null) {
|
||||
editText.setText(mSavedTextInput);
|
||||
mSavedTextInput = null;
|
||||
}
|
||||
|
||||
editText.setOnEditorActionListener((v, actionId, event) -> {
|
||||
TerminalSession session = mActivity.getCurrentSession();
|
||||
if (session != null) {
|
||||
if (session.isRunning()) {
|
||||
String textToSend = editText.getText().toString();
|
||||
if (textToSend.length() == 0) textToSend = "\r";
|
||||
session.write(textToSend);
|
||||
} else {
|
||||
mActivity.getTermuxTerminalSessionClient().removeFinishedSession(session);
|
||||
}
|
||||
editText.setText("");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
collection.addView(layout);
|
||||
return layout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) {
|
||||
collection.removeView((View) view);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
|
||||
|
||||
final TermuxActivity mActivity;
|
||||
final ViewPager mTerminalToolbarViewPager;
|
||||
|
||||
public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) {
|
||||
this.mActivity = activity;
|
||||
this.mTerminalToolbarViewPager = viewPager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageSelected(int position) {
|
||||
if (position == 0) {
|
||||
mActivity.getTerminalView().requestFocus();
|
||||
} else {
|
||||
final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input);
|
||||
if (editText != null) editText.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.termux.app.terminal.io;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
|
||||
import com.termux.app.TermuxActivity;
|
||||
import com.termux.app.terminal.TermuxTerminalSessionActivityClient;
|
||||
import com.termux.app.terminal.TermuxTerminalViewClient;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.termux.extrakeys.ExtraKeysConstants;
|
||||
import com.termux.shared.termux.extrakeys.ExtraKeysInfo;
|
||||
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
|
||||
import com.termux.shared.termux.settings.properties.TermuxSharedProperties;
|
||||
import com.termux.shared.termux.terminal.io.TerminalExtraKeys;
|
||||
import com.termux.view.TerminalView;
|
||||
|
||||
import org.json.JSONException;
|
||||
|
||||
public class TermuxTerminalExtraKeys extends TerminalExtraKeys {
|
||||
|
||||
private ExtraKeysInfo mExtraKeysInfo;
|
||||
|
||||
final TermuxActivity mActivity;
|
||||
final TermuxTerminalViewClient mTermuxTerminalViewClient;
|
||||
final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
|
||||
|
||||
private static final String LOG_TAG = "TermuxTerminalExtraKeys";
|
||||
|
||||
public TermuxTerminalExtraKeys(TermuxActivity activity, @NonNull TerminalView terminalView,
|
||||
TermuxTerminalViewClient termuxTerminalViewClient,
|
||||
TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
|
||||
super(terminalView);
|
||||
|
||||
mActivity = activity;
|
||||
mTermuxTerminalViewClient = termuxTerminalViewClient;
|
||||
mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
|
||||
|
||||
setExtraKeys();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the terminal extra keys and style.
|
||||
*/
|
||||
private void setExtraKeys() {
|
||||
mExtraKeysInfo = null;
|
||||
|
||||
try {
|
||||
// The mMap stores the extra key and style string values while loading properties
|
||||
// Check {@link #getExtraKeysInternalPropertyValueFromValue(String)} and
|
||||
// {@link #getExtraKeysStyleInternalPropertyValueFromValue(String)}
|
||||
String extrakeys = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true);
|
||||
String extraKeysStyle = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true);
|
||||
|
||||
ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap = ExtraKeysInfo.getCharDisplayMapForStyle(extraKeysStyle);
|
||||
if (ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY.equals(extraKeyDisplayMap) && !TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE.equals(extraKeysStyle)) {
|
||||
Logger.logError(TermuxSharedProperties.LOG_TAG, "The style \"" + extraKeysStyle + "\" for the key \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE + "\" is invalid. Using default style instead.");
|
||||
extraKeysStyle = TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE;
|
||||
}
|
||||
|
||||
mExtraKeysInfo = new ExtraKeysInfo(extrakeys, extraKeysStyle, ExtraKeysConstants.CONTROL_CHARS_ALIASES);
|
||||
} catch (JSONException e) {
|
||||
Logger.showToast(mActivity, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: " + e.toString(), true);
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: ", e);
|
||||
|
||||
try {
|
||||
mExtraKeysInfo = new ExtraKeysInfo(TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE, ExtraKeysConstants.CONTROL_CHARS_ALIASES);
|
||||
} catch (JSONException e2) {
|
||||
Logger.showToast(mActivity, "Can't create default extra keys",true);
|
||||
Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e);
|
||||
mExtraKeysInfo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ExtraKeysInfo getExtraKeysInfo() {
|
||||
return mExtraKeysInfo;
|
||||
}
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
@Override
|
||||
public void onTerminalExtraKeyButtonClick(View view, String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) {
|
||||
if ("KEYBOARD".equals(key)) {
|
||||
if(mTermuxTerminalViewClient != null)
|
||||
mTermuxTerminalViewClient.onToggleSoftKeyboardRequest();
|
||||
} else if ("DRAWER".equals(key)) {
|
||||
DrawerLayout drawerLayout = mTermuxTerminalViewClient.getActivity().getDrawer();
|
||||
if (drawerLayout.isDrawerOpen(Gravity.LEFT))
|
||||
drawerLayout.closeDrawer(Gravity.LEFT);
|
||||
else
|
||||
drawerLayout.openDrawer(Gravity.LEFT);
|
||||
} else if ("PASTE".equals(key)) {
|
||||
if(mTermuxTerminalSessionActivityClient != null)
|
||||
mTermuxTerminalSessionActivityClient.onPasteTextFromClipboard(null);
|
||||
} else if ("SCROLL".equals(key)) {
|
||||
TerminalView terminalView = mTermuxTerminalViewClient.getActivity().getTerminalView();
|
||||
if (terminalView != null && terminalView.mEmulator != null)
|
||||
terminalView.mEmulator.toggleAutoScrollDisabled();
|
||||
} else {
|
||||
super.onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#E0E0E0" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#212325" />
|
||||
</shape>
|
||||
@@ -0,0 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Keep in sync with non-adaptive ic_launcher.xml -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M34,38
|
||||
h6
|
||||
l12,16
|
||||
l-12,16
|
||||
h-6
|
||||
l12,-16
|
||||
"
|
||||
/>
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M56,66
|
||||
h18
|
||||
v4
|
||||
h-18
|
||||
"
|
||||
/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M 12, 12
|
||||
m -10.5, 0
|
||||
a 10.5,10.5 0 1,0 21,0
|
||||
a 10.5,10.5 0 1,0 -21,0"/>
|
||||
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13h-4v4h-2v-4L7,13v-2h4L11,7h2v4h4v2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,24 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!--
|
||||
Updated notification icon compliant with system icons guidelines
|
||||
https://material.io/design/iconography/system-icons.html
|
||||
-->
|
||||
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h24v24h-24z"/>
|
||||
|
||||
<path
|
||||
android:pathData="M5,4H2L8,12L2,20H5L11,12L5,4Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
|
||||
<path
|
||||
android:pathData="M13,18H22V20H13V18Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FF000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_activated="true" android:drawable="@drawable/current_session_black"/>
|
||||
<item android:state_activated="false" android:drawable="@drawable/session_ripple_black"/>
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_activated="true" android:drawable="@drawable/current_session"/>
|
||||
<item android:state_activated="false" android:drawable="@drawable/session_ripple"/>
|
||||
</selector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@android:color/darker_gray" >
|
||||
<item>
|
||||
<color android:color="@android:color/white" />
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@android:color/darker_gray" >
|
||||
<item>
|
||||
<color android:color="@android:color/background_dark" />
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||
|
||||
<solid android:color="#66FFFFFF" />
|
||||
|
||||
<size android:width="4dp" />
|
||||
|
||||
<!--
|
||||
<gradient
|
||||
android:angle="45"
|
||||
android:centerColor="#66ff5c33"
|
||||
android:endColor="#66FF3401"
|
||||
android:startColor="#66FF3401" />
|
||||
|
||||
<corners android:radius="8dp" />
|
||||
|
||||
<padding
|
||||
android:left="0.5dp"
|
||||
android:right="0.5dp" />
|
||||
-->
|
||||
|
||||
</shape>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<include
|
||||
layout="@layout/partial_primary_toolbar"
|
||||
android:id="@+id/partial_primary_toolbar"/>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/settings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,101 @@
|
||||
<com.termux.app.terminal.TermuxActivityRootView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/activity_termux_root_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/activity_termux_root_relative_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="3dp"
|
||||
android:layout_marginVertical="0dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.drawerlayout.widget.DrawerLayout
|
||||
android:id="@+id/drawer_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_above="@+id/terminal_toolbar_view_pager"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.termux.view.TerminalView
|
||||
android:id="@+id/terminal_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:defaultFocusHighlightEnabled="false"
|
||||
android:focusableInTouchMode="true"
|
||||
android:scrollbarThumbVertical="@drawable/terminal_scroll_shape"
|
||||
android:scrollbars="vertical"
|
||||
android:importantForAutofill="no"
|
||||
android:autofillHints="password"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/left_drawer"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:choiceMode="singleChoice"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="0dp"
|
||||
android:descendantFocusability="blocksDescendants"
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/termuxActivityDrawerBackground">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/terminal_sessions_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_gravity="top"
|
||||
android:layout_weight="1"
|
||||
android:choiceMode="singleChoice"
|
||||
android:longClickable="true" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/toggle_keyboard_button"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:textAllCaps="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_toggle_soft_keyboard" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/new_session_button"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:textAllCaps="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_new_session" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
|
||||
<androidx.viewpager.widget.ViewPager
|
||||
android:id="@+id/terminal_toolbar_view_pager"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="37.5dp"
|
||||
android:background="@color/black"
|
||||
android:layout_alignParentBottom="true" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/activity_termux_bottom_space_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
</com.termux.app.terminal.TermuxActivityRootView>
|
||||
@@ -0,0 +1,9 @@
|
||||
<com.google.android.material.textview.MaterialTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/session_title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="?android:attr/listPreferredItemHeight"
|
||||
android:background="@drawable/session_background_selected"
|
||||
android:ellipsize="marquee"
|
||||
android:gravity="start|center_vertical"
|
||||
android:padding="6dip"
|
||||
android:textSize="14sp" />
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-appcompat-release/preference/preference/res/layout/preference.xml
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView android:id="@android:id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
|
||||
<include android:id="@android:id/summary" layout="@layout/markdown_adapter_node_default" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.termux.shared.termux.extrakeys.ExtraKeysView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/terminal_toolbar_extra_keys"
|
||||
style="?android:attr/buttonBarStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:orientation="horizontal" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<EditText xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/terminal_toolbar_text_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:imeOptions="actionSend|flagNoFullscreen"
|
||||
android:maxLines="1"
|
||||
android:inputType="text"
|
||||
android:importantForAutofill="no"
|
||||
android:textColor="@android:color/white"
|
||||
android:textColorHighlight="@android:color/darker_gray"
|
||||
android:paddingTop="0dp"
|
||||
android:textCursorDrawable="@null"
|
||||
android:paddingBottom="0dp"
|
||||
tools:ignore="LabelFor" />
|
||||
@@ -0,0 +1,21 @@
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<Preference
|
||||
app:key="termux"
|
||||
app:title="@string/termux_preferences_title"
|
||||
app:summary="@string/termux_preferences_summary"
|
||||
app:fragment="com.termux.app.fragments.settings.TermuxPreferencesFragment"/>
|
||||
|
||||
<Preference
|
||||
app:key="about"
|
||||
app:title="@string/about_preference_title"
|
||||
app:persistent="false"/>
|
||||
<!-- app:layout="@layout/preference_markdown_text" -->
|
||||
|
||||
<Preference
|
||||
app:key="donate"
|
||||
app:title="@string/donate_preference_title"
|
||||
app:persistent="false"
|
||||
app:isPreferenceVisible="false"/>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -0,0 +1,33 @@
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<PreferenceCategory
|
||||
app:key="logging"
|
||||
app:title="@string/termux_logging_header">
|
||||
|
||||
<ListPreference
|
||||
app:defaultValue="1"
|
||||
app:key="log_level"
|
||||
app:title="@string/termux_log_level_title"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="terminal_view_key_logging_enabled"
|
||||
app:summaryOff="@string/termux_terminal_view_key_logging_enabled_off"
|
||||
app:summaryOn="@string/termux_terminal_view_key_logging_enabled_on"
|
||||
app:title="@string/termux_terminal_view_key_logging_enabled_title" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="plugin_error_notifications_enabled"
|
||||
app:summaryOff="@string/termux_plugin_error_notifications_enabled_off"
|
||||
app:summaryOn="@string/termux_plugin_error_notifications_enabled_on"
|
||||
app:title="@string/termux_plugin_error_notifications_enabled_title" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="crash_report_notifications_enabled"
|
||||
app:summaryOff="@string/termux_crash_report_notifications_enabled_off"
|
||||
app:summaryOn="@string/termux_crash_report_notifications_enabled_on"
|
||||
app:title="@string/termux_crash_report_notifications_enabled_title" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -0,0 +1,18 @@
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<Preference
|
||||
app:title="@string/termux_debugging_preferences_title"
|
||||
app:summary="@string/termux_debugging_preferences_summary"
|
||||
app:fragment="com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment"/>
|
||||
|
||||
<Preference
|
||||
app:title="@string/termux_terminal_io_preferences_title"
|
||||
app:summary="@string/termux_terminal_io_preferences_summary"
|
||||
app:fragment="com.termux.app.fragments.settings.termux.TerminalIOPreferencesFragment"/>
|
||||
|
||||
<Preference
|
||||
app:title="@string/termux_terminal_view_preferences_title"
|
||||
app:summary="@string/termux_terminal_view_preferences_summary"
|
||||
app:fragment="com.termux.app.fragments.settings.termux.TerminalViewPreferencesFragment"/>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -0,0 +1,21 @@
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<PreferenceCategory
|
||||
app:key="keyboard"
|
||||
app:title="@string/termux_keyboard_header">
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="soft_keyboard_enabled"
|
||||
app:summaryOff="@string/termux_soft_keyboard_enabled_off"
|
||||
app:summaryOn="@string/termux_soft_keyboard_enabled_on"
|
||||
app:title="@string/termux_soft_keyboard_enabled_title" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="soft_keyboard_enabled_only_if_no_hardware"
|
||||
app:summaryOff="@string/termux_soft_keyboard_enabled_only_if_no_hardware_off"
|
||||
app:summaryOn="@string/termux_soft_keyboard_enabled_only_if_no_hardware_on"
|
||||
app:title="@string/termux_soft_keyboard_enabled_only_if_no_hardware_title" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -0,0 +1,15 @@
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<PreferenceCategory
|
||||
app:key="view"
|
||||
app:title="@string/termux_terminal_view_view_header">
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="terminal_margin_adjustment"
|
||||
app:summaryOff="@string/termux_terminal_view_terminal_margin_adjustment_off"
|
||||
app:summaryOn="@string/termux_terminal_view_terminal_margin_adjustment_on"
|
||||
app:title="@string/termux_terminal_view_terminal_margin_adjustment_title" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.termux.app;
|
||||
|
||||
import com.termux.shared.termux.data.TermuxUrlUtils;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
public class TermuxActivityTest {
|
||||
|
||||
private void assertUrlsAre(String text, String... urls) {
|
||||
LinkedHashSet<String> expected = new LinkedHashSet<>();
|
||||
Collections.addAll(expected, urls);
|
||||
Assert.assertEquals(expected, TermuxUrlUtils.extractUrls(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractUrls() {
|
||||
assertUrlsAre("hello http://example.com world", "http://example.com");
|
||||
|
||||
assertUrlsAre("http://example.com\nhttp://another.com", "http://example.com", "http://another.com");
|
||||
|
||||
assertUrlsAre("hello http://example.com world and http://more.example.com with secure https://more.example.com",
|
||||
"http://example.com", "http://more.example.com", "https://more.example.com");
|
||||
|
||||
assertUrlsAre("hello https://example.com/#bar https://example.com/foo#bar",
|
||||
"https://example.com/#bar", "https://example.com/foo#bar");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.termux.app.api.file;
|
||||
|
||||
import com.termux.app.api.file.FileReceiverActivity;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
public class FileReceiverActivityTest {
|
||||
|
||||
@Test
|
||||
public void testIsSharedTextAnUrl() {
|
||||
List<String> validUrls = new ArrayList<>();
|
||||
validUrls.add("http://example.com");
|
||||
validUrls.add("https://example.com");
|
||||
validUrls.add("https://example.com/path/parameter=foo");
|
||||
validUrls.add("magnet:?xt=urn:btih:d540fc48eb12f2833163eed6421d449dd8f1ce1f&dn=Ubuntu+desktop+19.04+%2864bit%29&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=udp%3A%2F%2Ftracker.publicbt.com%3A80&tr=udp%3A%2F%2Ftracker.ccc.de%3A80");
|
||||
for (String url : validUrls) {
|
||||
Assert.assertTrue(FileReceiverActivity.isSharedTextAnUrl(url));
|
||||
}
|
||||
|
||||
List<String> invalidUrls = new ArrayList<>();
|
||||
invalidUrls.add("a test with example.com");
|
||||
for (String url : invalidUrls) {
|
||||
Assert.assertFalse(FileReceiverActivity.isSharedTextAnUrl(url));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,42 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.termux.emulator"
|
||||
ndkVersion = BuildConfig.ndkVersion
|
||||
|
||||
defaultConfig {
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
cFlags += arrayOf("-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections")
|
||||
}
|
||||
}
|
||||
|
||||
ndk {
|
||||
abiFilters += arrayOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
path = file("src/main/jni/Android.mk")
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Test::class.java) {
|
||||
testLogging {
|
||||
events("started", "passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotation)
|
||||
testImplementation(projects.testing.unit)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /Users/fornwall/lib/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
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/** A circular byte buffer allowing one producer and one consumer thread. */
|
||||
final class ByteQueue {
|
||||
|
||||
private final byte[] mBuffer;
|
||||
private int mHead;
|
||||
private int mStoredBytes;
|
||||
private boolean mOpen = true;
|
||||
|
||||
public ByteQueue(int size) {
|
||||
mBuffer = new byte[size];
|
||||
}
|
||||
|
||||
public synchronized void close() {
|
||||
mOpen = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
public synchronized int read(byte[] buffer, boolean block) {
|
||||
while (mStoredBytes == 0 && mOpen) {
|
||||
if (block) {
|
||||
try {
|
||||
wait();
|
||||
} catch (InterruptedException e) {
|
||||
// Ignore.
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (!mOpen) return -1;
|
||||
|
||||
int totalRead = 0;
|
||||
int bufferLength = mBuffer.length;
|
||||
boolean wasFull = bufferLength == mStoredBytes;
|
||||
int length = buffer.length;
|
||||
int offset = 0;
|
||||
while (length > 0 && mStoredBytes > 0) {
|
||||
int oneRun = Math.min(bufferLength - mHead, mStoredBytes);
|
||||
int bytesToCopy = Math.min(length, oneRun);
|
||||
System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy);
|
||||
mHead += bytesToCopy;
|
||||
if (mHead >= bufferLength) mHead = 0;
|
||||
mStoredBytes -= bytesToCopy;
|
||||
length -= bytesToCopy;
|
||||
offset += bytesToCopy;
|
||||
totalRead += bytesToCopy;
|
||||
}
|
||||
if (wasFull) notify();
|
||||
return totalRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to write the specified portion of the provided buffer to the queue.
|
||||
* <p/>
|
||||
* Returns whether the output was totally written, false if it was closed before.
|
||||
*/
|
||||
public boolean write(byte[] buffer, int offset, int lengthToWrite) {
|
||||
if (lengthToWrite + offset > buffer.length) {
|
||||
throw new IllegalArgumentException("length + offset > buffer.length");
|
||||
} else if (lengthToWrite <= 0) {
|
||||
throw new IllegalArgumentException("length <= 0");
|
||||
}
|
||||
|
||||
final int bufferLength = mBuffer.length;
|
||||
|
||||
synchronized (this) {
|
||||
while (lengthToWrite > 0) {
|
||||
while (bufferLength == mStoredBytes && mOpen) {
|
||||
try {
|
||||
wait();
|
||||
} catch (InterruptedException e) {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
if (!mOpen) return false;
|
||||
final boolean wasEmpty = mStoredBytes == 0;
|
||||
int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes);
|
||||
lengthToWrite -= bytesToWriteBeforeWaiting;
|
||||
|
||||
while (bytesToWriteBeforeWaiting > 0) {
|
||||
int tail = mHead + mStoredBytes;
|
||||
int oneRun;
|
||||
if (tail >= bufferLength) {
|
||||
// Buffer: [.............]
|
||||
// ________________H_______T
|
||||
// =>
|
||||
// Buffer: [.............]
|
||||
// ___________T____H
|
||||
// onRun= _____----_
|
||||
tail = tail - bufferLength;
|
||||
oneRun = mHead - tail;
|
||||
} else {
|
||||
oneRun = bufferLength - tail;
|
||||
}
|
||||
int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting);
|
||||
System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy);
|
||||
offset += bytesToCopy;
|
||||
bytesToWriteBeforeWaiting -= bytesToCopy;
|
||||
mStoredBytes += bytesToCopy;
|
||||
}
|
||||
if (wasEmpty) notify();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/**
|
||||
* Native methods for creating and managing pseudoterminal subprocesses. C code is in jni/termux.c.
|
||||
*/
|
||||
final class JNI {
|
||||
|
||||
static {
|
||||
System.loadLibrary("termux");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subprocess. Differs from {@link ProcessBuilder} in that a pseudoterminal is used to communicate with the
|
||||
* subprocess.
|
||||
* <p/>
|
||||
* Callers are responsible for calling {@link #close(int)} on the returned file descriptor.
|
||||
*
|
||||
* @param cmd The command to execute
|
||||
* @param cwd The current working directory for the executed command
|
||||
* @param args An array of arguments to the command
|
||||
* @param envVars An array of strings of the form "VAR=value" to be added to the environment of the process
|
||||
* @param processId A one-element array to which the process ID of the started process will be written.
|
||||
* @return the file descriptor resulting from opening /dev/ptmx master device. The sub process will have opened the
|
||||
* slave device counterpart (/dev/pts/$N) and have it as stdint, stdout and stderr.
|
||||
*/
|
||||
public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns);
|
||||
|
||||
/** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */
|
||||
public static native void setPtyWindowSize(int fd, int rows, int cols);
|
||||
|
||||
/**
|
||||
* Causes the calling thread to wait for the process associated with the receiver to finish executing.
|
||||
*
|
||||
* @return if >= 0, the exit status of the process. If < 0, the signal causing the process to stop negated.
|
||||
*/
|
||||
public static native int waitFor(int processId);
|
||||
|
||||
/** Close a file descriptor through the close(2) system call. */
|
||||
public static native void close(int fileDescriptor);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static android.view.KeyEvent.KEYCODE_BACK;
|
||||
import static android.view.KeyEvent.KEYCODE_BREAK;
|
||||
import static android.view.KeyEvent.KEYCODE_DEL;
|
||||
import static android.view.KeyEvent.KEYCODE_DPAD_CENTER;
|
||||
import static android.view.KeyEvent.KEYCODE_DPAD_DOWN;
|
||||
import static android.view.KeyEvent.KEYCODE_DPAD_LEFT;
|
||||
import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT;
|
||||
import static android.view.KeyEvent.KEYCODE_DPAD_UP;
|
||||
import static android.view.KeyEvent.KEYCODE_ENTER;
|
||||
import static android.view.KeyEvent.KEYCODE_ESCAPE;
|
||||
import static android.view.KeyEvent.KEYCODE_F1;
|
||||
import static android.view.KeyEvent.KEYCODE_F10;
|
||||
import static android.view.KeyEvent.KEYCODE_F11;
|
||||
import static android.view.KeyEvent.KEYCODE_F12;
|
||||
import static android.view.KeyEvent.KEYCODE_F2;
|
||||
import static android.view.KeyEvent.KEYCODE_F3;
|
||||
import static android.view.KeyEvent.KEYCODE_F4;
|
||||
import static android.view.KeyEvent.KEYCODE_F5;
|
||||
import static android.view.KeyEvent.KEYCODE_F6;
|
||||
import static android.view.KeyEvent.KEYCODE_F7;
|
||||
import static android.view.KeyEvent.KEYCODE_F8;
|
||||
import static android.view.KeyEvent.KEYCODE_F9;
|
||||
import static android.view.KeyEvent.KEYCODE_FORWARD_DEL;
|
||||
import static android.view.KeyEvent.KEYCODE_INSERT;
|
||||
import static android.view.KeyEvent.KEYCODE_MOVE_END;
|
||||
import static android.view.KeyEvent.KEYCODE_MOVE_HOME;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_0;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_1;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_2;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_3;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_4;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_5;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_6;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_7;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_8;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_9;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY;
|
||||
import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT;
|
||||
import static android.view.KeyEvent.KEYCODE_NUM_LOCK;
|
||||
import static android.view.KeyEvent.KEYCODE_PAGE_DOWN;
|
||||
import static android.view.KeyEvent.KEYCODE_PAGE_UP;
|
||||
import static android.view.KeyEvent.KEYCODE_SPACE;
|
||||
import static android.view.KeyEvent.KEYCODE_SYSRQ;
|
||||
import static android.view.KeyEvent.KEYCODE_TAB;
|
||||
|
||||
public final class KeyHandler {
|
||||
|
||||
public static final int KEYMOD_ALT = 0x80000000;
|
||||
public static final int KEYMOD_CTRL = 0x40000000;
|
||||
public static final int KEYMOD_SHIFT = 0x20000000;
|
||||
public static final int KEYMOD_NUM_LOCK = 0x10000000;
|
||||
|
||||
private static final Map<String, Integer> TERMCAP_TO_KEYCODE = new HashMap<>();
|
||||
|
||||
static {
|
||||
// terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html
|
||||
// termcap: http://man7.org/linux/man-pages/man5/termcap.5.html
|
||||
TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT);
|
||||
TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home
|
||||
TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT);
|
||||
TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key
|
||||
|
||||
TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1);
|
||||
TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2);
|
||||
TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3);
|
||||
TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4);
|
||||
TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5);
|
||||
TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6);
|
||||
TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7);
|
||||
TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8);
|
||||
TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9);
|
||||
TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10);
|
||||
TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11);
|
||||
TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12);
|
||||
TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1);
|
||||
TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2);
|
||||
TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3);
|
||||
TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4);
|
||||
TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5);
|
||||
TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6);
|
||||
TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7);
|
||||
TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8);
|
||||
TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9);
|
||||
TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10);
|
||||
TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11);
|
||||
TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12);
|
||||
|
||||
TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key
|
||||
|
||||
TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key
|
||||
TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME);
|
||||
TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT);
|
||||
TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT);
|
||||
|
||||
// K1=Upper left of keypad:
|
||||
// t_K1 <kHome> keypad home key
|
||||
// t_K3 <kPageUp> keypad page-up key
|
||||
// t_K4 <kEnd> keypad end key
|
||||
// t_K5 <kPageDown> keypad page-down key
|
||||
TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME);
|
||||
TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP);
|
||||
TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END);
|
||||
TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN);
|
||||
|
||||
TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP);
|
||||
|
||||
TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab
|
||||
TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key
|
||||
TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down
|
||||
TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key
|
||||
TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT);
|
||||
TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_UP);
|
||||
TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_DOWN);
|
||||
TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key
|
||||
TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up
|
||||
|
||||
TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END);
|
||||
TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER);
|
||||
}
|
||||
|
||||
static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) {
|
||||
Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap);
|
||||
if (keyCodeAndMod == null) return null;
|
||||
int keyCode = keyCodeAndMod;
|
||||
int keyMod = 0;
|
||||
if ((keyCode & KEYMOD_SHIFT) != 0) {
|
||||
keyMod |= KEYMOD_SHIFT;
|
||||
keyCode &= ~KEYMOD_SHIFT;
|
||||
}
|
||||
if ((keyCode & KEYMOD_CTRL) != 0) {
|
||||
keyMod |= KEYMOD_CTRL;
|
||||
keyCode &= ~KEYMOD_CTRL;
|
||||
}
|
||||
if ((keyCode & KEYMOD_ALT) != 0) {
|
||||
keyMod |= KEYMOD_ALT;
|
||||
keyCode &= ~KEYMOD_ALT;
|
||||
}
|
||||
if ((keyCode & KEYMOD_NUM_LOCK) != 0) {
|
||||
keyMod |= KEYMOD_NUM_LOCK;
|
||||
keyCode &= ~KEYMOD_NUM_LOCK;
|
||||
}
|
||||
return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication);
|
||||
}
|
||||
|
||||
public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) {
|
||||
boolean numLockOn = (keyMode & KEYMOD_NUM_LOCK) != 0;
|
||||
keyMode &= ~KEYMOD_NUM_LOCK;
|
||||
switch (keyCode) {
|
||||
case KEYCODE_DPAD_CENTER:
|
||||
return "\015";
|
||||
|
||||
case KEYCODE_DPAD_UP:
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A');
|
||||
case KEYCODE_DPAD_DOWN:
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B');
|
||||
case KEYCODE_DPAD_RIGHT:
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C');
|
||||
case KEYCODE_DPAD_LEFT:
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D');
|
||||
|
||||
case KEYCODE_MOVE_HOME:
|
||||
// Note that KEYCODE_HOME is handled by the system and never delivered to applications.
|
||||
// On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow.
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H');
|
||||
case KEYCODE_MOVE_END:
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F');
|
||||
|
||||
// An xterm can send function keys F1 to F4 in two modes: vt100 compatible or
|
||||
// not. Because Vim may not know what the xterm is sending, both types of keys
|
||||
// are recognized. The same happens for the <Home> and <End> keys.
|
||||
// normal vt100 ~
|
||||
// <F1> t_k1 <Esc>[11~ <xF1> <Esc>OP *<xF1>-xterm*
|
||||
// <F2> t_k2 <Esc>[12~ <xF2> <Esc>OQ *<xF2>-xterm*
|
||||
// <F3> t_k3 <Esc>[13~ <xF3> <Esc>OR *<xF3>-xterm*
|
||||
// <F4> t_k4 <Esc>[14~ <xF4> <Esc>OS *<xF4>-xterm*
|
||||
// <Home> t_kh <Esc>[7~ <xHome> <Esc>OH *<xHome>-xterm*
|
||||
// <End> t_@7 <Esc>[4~ <xEnd> <Esc>OF *<xEnd>-xterm*
|
||||
case KEYCODE_F1:
|
||||
return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P');
|
||||
case KEYCODE_F2:
|
||||
return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q');
|
||||
case KEYCODE_F3:
|
||||
return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R');
|
||||
case KEYCODE_F4:
|
||||
return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S');
|
||||
case KEYCODE_F5:
|
||||
return transformForModifiers("\033[15", keyMode, '~');
|
||||
case KEYCODE_F6:
|
||||
return transformForModifiers("\033[17", keyMode, '~');
|
||||
case KEYCODE_F7:
|
||||
return transformForModifiers("\033[18", keyMode, '~');
|
||||
case KEYCODE_F8:
|
||||
return transformForModifiers("\033[19", keyMode, '~');
|
||||
case KEYCODE_F9:
|
||||
return transformForModifiers("\033[20", keyMode, '~');
|
||||
case KEYCODE_F10:
|
||||
return transformForModifiers("\033[21", keyMode, '~');
|
||||
case KEYCODE_F11:
|
||||
return transformForModifiers("\033[23", keyMode, '~');
|
||||
case KEYCODE_F12:
|
||||
return transformForModifiers("\033[24", keyMode, '~');
|
||||
|
||||
case KEYCODE_SYSRQ:
|
||||
return "\033[32~"; // Sys Request / Print
|
||||
// Is this Scroll lock? case Cancel: return "\033[33~";
|
||||
case KEYCODE_BREAK:
|
||||
return "\033[34~"; // Pause/Break
|
||||
|
||||
case KEYCODE_ESCAPE:
|
||||
case KEYCODE_BACK:
|
||||
return "\033";
|
||||
|
||||
case KEYCODE_INSERT:
|
||||
return transformForModifiers("\033[2", keyMode, '~');
|
||||
case KEYCODE_FORWARD_DEL:
|
||||
return transformForModifiers("\033[3", keyMode, '~');
|
||||
|
||||
case KEYCODE_PAGE_UP:
|
||||
return transformForModifiers("\033[5", keyMode, '~');
|
||||
case KEYCODE_PAGE_DOWN:
|
||||
return transformForModifiers("\033[6", keyMode, '~');
|
||||
case KEYCODE_DEL:
|
||||
String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033";
|
||||
// Just do what xterm and gnome-terminal does:
|
||||
return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008");
|
||||
case KEYCODE_NUM_LOCK:
|
||||
if (keypadApplication) {
|
||||
return "\033OP";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
case KEYCODE_SPACE:
|
||||
// If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a
|
||||
// combining accent to be written):
|
||||
return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0";
|
||||
case KEYCODE_TAB:
|
||||
// This is back-tab when shifted:
|
||||
return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z";
|
||||
case KEYCODE_ENTER:
|
||||
return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r";
|
||||
|
||||
case KEYCODE_NUMPAD_ENTER:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n";
|
||||
case KEYCODE_NUMPAD_MULTIPLY:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*";
|
||||
case KEYCODE_NUMPAD_ADD:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+";
|
||||
case KEYCODE_NUMPAD_COMMA:
|
||||
return ",";
|
||||
case KEYCODE_NUMPAD_DOT:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? "\033On" : ".";
|
||||
} else {
|
||||
// DELETE
|
||||
return transformForModifiers("\033[3", keyMode, '~');
|
||||
}
|
||||
case KEYCODE_NUMPAD_SUBTRACT:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-";
|
||||
case KEYCODE_NUMPAD_DIVIDE:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/";
|
||||
case KEYCODE_NUMPAD_0:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0";
|
||||
} else {
|
||||
// INSERT
|
||||
return transformForModifiers("\033[2", keyMode, '~');
|
||||
}
|
||||
case KEYCODE_NUMPAD_1:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1";
|
||||
} else {
|
||||
// END
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F');
|
||||
}
|
||||
case KEYCODE_NUMPAD_2:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2";
|
||||
} else {
|
||||
// DOWN
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B');
|
||||
}
|
||||
case KEYCODE_NUMPAD_3:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3";
|
||||
} else {
|
||||
// PGDN
|
||||
return "\033[6~";
|
||||
}
|
||||
case KEYCODE_NUMPAD_4:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4";
|
||||
} else {
|
||||
// LEFT
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D');
|
||||
}
|
||||
case KEYCODE_NUMPAD_5:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5";
|
||||
case KEYCODE_NUMPAD_6:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6";
|
||||
} else {
|
||||
// RIGHT
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C');
|
||||
}
|
||||
case KEYCODE_NUMPAD_7:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7";
|
||||
} else {
|
||||
// HOME
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H');
|
||||
}
|
||||
case KEYCODE_NUMPAD_8:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8";
|
||||
} else {
|
||||
// UP
|
||||
return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A');
|
||||
}
|
||||
case KEYCODE_NUMPAD_9:
|
||||
if (numLockOn) {
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9";
|
||||
} else {
|
||||
// PGUP
|
||||
return "\033[5~";
|
||||
}
|
||||
case KEYCODE_NUMPAD_EQUALS:
|
||||
return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "=";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String transformForModifiers(String start, int keymod, char lastChar) {
|
||||
int modifier;
|
||||
switch (keymod) {
|
||||
case KEYMOD_SHIFT:
|
||||
modifier = 2;
|
||||
break;
|
||||
case KEYMOD_ALT:
|
||||
modifier = 3;
|
||||
break;
|
||||
case (KEYMOD_SHIFT | KEYMOD_ALT):
|
||||
modifier = 4;
|
||||
break;
|
||||
case KEYMOD_CTRL:
|
||||
modifier = 5;
|
||||
break;
|
||||
case KEYMOD_SHIFT | KEYMOD_CTRL:
|
||||
modifier = 6;
|
||||
break;
|
||||
case KEYMOD_ALT | KEYMOD_CTRL:
|
||||
modifier = 7;
|
||||
break;
|
||||
case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL:
|
||||
modifier = 8;
|
||||
break;
|
||||
default:
|
||||
return start + lastChar;
|
||||
}
|
||||
return start + (";" + modifier) + lastChar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
public class Logger {
|
||||
|
||||
public static void logError(TerminalSessionClient client, String logTag, String message) {
|
||||
if (client != null)
|
||||
client.logError(logTag, message);
|
||||
else
|
||||
Log.e(logTag, message);
|
||||
}
|
||||
|
||||
public static void logWarn(TerminalSessionClient client, String logTag, String message) {
|
||||
if (client != null)
|
||||
client.logWarn(logTag, message);
|
||||
else
|
||||
Log.w(logTag, message);
|
||||
}
|
||||
|
||||
public static void logInfo(TerminalSessionClient client, String logTag, String message) {
|
||||
if (client != null)
|
||||
client.logInfo(logTag, message);
|
||||
else
|
||||
Log.i(logTag, message);
|
||||
}
|
||||
|
||||
public static void logDebug(TerminalSessionClient client, String logTag, String message) {
|
||||
if (client != null)
|
||||
client.logDebug(logTag, message);
|
||||
else
|
||||
Log.d(logTag, message);
|
||||
}
|
||||
|
||||
public static void logVerbose(TerminalSessionClient client, String logTag, String message) {
|
||||
if (client != null)
|
||||
client.logVerbose(logTag, message);
|
||||
else
|
||||
Log.v(logTag, message);
|
||||
}
|
||||
|
||||
public static void logStackTraceWithMessage(TerminalSessionClient client, String tag, String message, Throwable throwable) {
|
||||
logError(client, tag, getMessageAndStackTraceString(message, throwable));
|
||||
}
|
||||
|
||||
public static String getMessageAndStackTraceString(String message, Throwable throwable) {
|
||||
if (message == null && throwable == null)
|
||||
return null;
|
||||
else if (message != null && throwable != null)
|
||||
return message + ":\n" + getStackTraceString(throwable);
|
||||
else if (throwable == null)
|
||||
return message;
|
||||
else
|
||||
return getStackTraceString(throwable);
|
||||
}
|
||||
|
||||
public static String getStackTraceString(Throwable throwable) {
|
||||
if (throwable == null) return null;
|
||||
|
||||
String stackTraceString = null;
|
||||
|
||||
try {
|
||||
StringWriter errors = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(errors);
|
||||
throwable.printStackTrace(pw);
|
||||
pw.close();
|
||||
stackTraceString = errors.toString();
|
||||
errors.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return stackTraceString;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll
|
||||
* history.
|
||||
* <p>
|
||||
* See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices.
|
||||
*/
|
||||
public final class TerminalBuffer {
|
||||
|
||||
TerminalRow[] mLines;
|
||||
/** The length of {@link #mLines}. */
|
||||
int mTotalRows;
|
||||
/** The number of rows and columns visible on the screen. */
|
||||
int mScreenRows, mColumns;
|
||||
/** The number of rows kept in history. */
|
||||
private int mActiveTranscriptRows = 0;
|
||||
/** The index in the circular buffer where the visible screen starts. */
|
||||
private int mScreenFirstRow = 0;
|
||||
|
||||
/**
|
||||
* Create a transcript screen.
|
||||
*
|
||||
* @param columns the width of the screen in characters.
|
||||
* @param totalRows the height of the entire text area, in rows of text.
|
||||
* @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off
|
||||
* the top of the screen.
|
||||
*/
|
||||
public TerminalBuffer(int columns, int totalRows, int screenRows) {
|
||||
mColumns = columns;
|
||||
mTotalRows = totalRows;
|
||||
mScreenRows = screenRows;
|
||||
mLines = new TerminalRow[totalRows];
|
||||
|
||||
blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL);
|
||||
}
|
||||
|
||||
public String getTranscriptText() {
|
||||
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim();
|
||||
}
|
||||
|
||||
public String getTranscriptTextWithoutJoinedLines() {
|
||||
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim();
|
||||
}
|
||||
|
||||
public String getTranscriptTextWithFullLinesJoined() {
|
||||
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim();
|
||||
}
|
||||
|
||||
public String getSelectedText(int selX1, int selY1, int selX2, int selY2) {
|
||||
return getSelectedText(selX1, selY1, selX2, selY2, true);
|
||||
}
|
||||
|
||||
public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) {
|
||||
return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, false);
|
||||
}
|
||||
|
||||
public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) {
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
final int columns = mColumns;
|
||||
|
||||
if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows();
|
||||
if (selY2 >= mScreenRows) selY2 = mScreenRows - 1;
|
||||
|
||||
for (int row = selY1; row <= selY2; row++) {
|
||||
int x1 = (row == selY1) ? selX1 : 0;
|
||||
int x2;
|
||||
if (row == selY2) {
|
||||
x2 = selX2 + 1;
|
||||
if (x2 > columns) x2 = columns;
|
||||
} else {
|
||||
x2 = columns;
|
||||
}
|
||||
TerminalRow lineObject = mLines[externalToInternalRow(row)];
|
||||
int x1Index = lineObject.findStartOfColumn(x1);
|
||||
int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed();
|
||||
if (x2Index == x1Index) {
|
||||
// Selected the start of a wide character.
|
||||
x2Index = lineObject.findStartOfColumn(x2 + 1);
|
||||
}
|
||||
char[] line = lineObject.mText;
|
||||
int lastPrintingCharIndex = -1;
|
||||
int i;
|
||||
boolean rowLineWrap = getLineWrap(row);
|
||||
if (rowLineWrap && x2 == columns) {
|
||||
// If the line was wrapped, we shouldn't lose trailing space:
|
||||
lastPrintingCharIndex = x2Index - 1;
|
||||
} else {
|
||||
for (i = x1Index; i < x2Index; ++i) {
|
||||
char c = line[i];
|
||||
if (c != ' ') lastPrintingCharIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
int len = lastPrintingCharIndex - x1Index + 1;
|
||||
if (lastPrintingCharIndex != -1 && len > 0)
|
||||
builder.append(line, x1Index, len);
|
||||
|
||||
boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1;
|
||||
if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
|
||||
&& row < selY2 && row < mScreenRows - 1) builder.append('\n');
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public String getWordAtLocation(int x, int y) {
|
||||
// Set y1 and y2 to the lines where the wrapped line starts and ends.
|
||||
// I.e. if a line that is wrapped to 3 lines starts at line 4, and this
|
||||
// is called with y=5, then y1 would be set to 4 and y2 would be set to 6.
|
||||
int y1 = y;
|
||||
int y2 = y;
|
||||
while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, true, true).contains("\n")) {
|
||||
y1--;
|
||||
}
|
||||
while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, true, true).contains("\n")) {
|
||||
y2++;
|
||||
}
|
||||
|
||||
// Get the text for the whole wrapped line
|
||||
String text = getSelectedText(0, y1, mColumns, y2, true, true);
|
||||
// The index of x in text
|
||||
int textOffset = (y - y1) * mColumns + x;
|
||||
|
||||
if (textOffset >= text.length()) {
|
||||
// The click was to the right of the last word on the line, so
|
||||
// there's no word to return
|
||||
return "";
|
||||
}
|
||||
|
||||
// Set x1 and x2 to the indices of the last space before x and the
|
||||
// first space after x in text respectively
|
||||
int x1 = text.lastIndexOf(' ', textOffset);
|
||||
int x2 = text.indexOf(' ', textOffset);
|
||||
if (x2 == -1) {
|
||||
x2 = text.length();
|
||||
}
|
||||
|
||||
if (x1 == x2) {
|
||||
// The click was on a space, so there's no word to return
|
||||
return "";
|
||||
}
|
||||
return text.substring(x1 + 1, x2);
|
||||
}
|
||||
|
||||
public int getActiveTranscriptRows() {
|
||||
return mActiveTranscriptRows;
|
||||
}
|
||||
|
||||
public int getActiveRows() {
|
||||
return mActiveTranscriptRows + mScreenRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a row value from the public external coordinate system to our internal private coordinate system.
|
||||
*
|
||||
* <pre>
|
||||
* - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
|
||||
* - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
|
||||
* mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
|
||||
*
|
||||
* External ↔ Internal:
|
||||
*
|
||||
* [ ... ] [ ... ]
|
||||
* [ -mActiveTranscriptRows ] [ mScreenFirstRow - mActiveTranscriptRows ]
|
||||
* [ ... ] [ ... ]
|
||||
* [ 0 (visible screen starts here) ] ↔ [ mScreenFirstRow ]
|
||||
* [ ... ] [ ... ]
|
||||
* [ mScreenRows-1 ] [ mScreenFirstRow + mScreenRows-1 ]
|
||||
* </pre>
|
||||
*
|
||||
* @param externalRow a row in the external coordinate system.
|
||||
* @return The row corresponding to the input argument in the private coordinate system.
|
||||
*/
|
||||
public int externalToInternalRow(int externalRow) {
|
||||
if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows)
|
||||
throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows);
|
||||
final int internalRow = mScreenFirstRow + externalRow;
|
||||
return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows);
|
||||
}
|
||||
|
||||
public void setLineWrap(int row) {
|
||||
mLines[externalToInternalRow(row)].mLineWrap = true;
|
||||
}
|
||||
|
||||
public boolean getLineWrap(int row) {
|
||||
return mLines[externalToInternalRow(row)].mLineWrap;
|
||||
}
|
||||
|
||||
public void clearLineWrap(int row) {
|
||||
mLines[externalToInternalRow(row)].mLineWrap = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the screen which this transcript backs. Currently, this only works if the number of columns does not
|
||||
* change or the rows expand (that is, it only works when shrinking the number of rows).
|
||||
*
|
||||
* @param newColumns The number of columns the screen should have.
|
||||
* @param newRows The number of rows the screen should have.
|
||||
* @param cursor An int[2] containing the (column, row) cursor location.
|
||||
*/
|
||||
public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) {
|
||||
// newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000):
|
||||
if (newColumns == mColumns && newRows <= mTotalRows) {
|
||||
// Fast resize where just the rows changed.
|
||||
int shiftDownOfTopRow = mScreenRows - newRows;
|
||||
if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) {
|
||||
// Shrinking. Check if we can skip blank rows at bottom below cursor.
|
||||
for (int i = mScreenRows - 1; i > 0; i--) {
|
||||
if (cursor[1] >= i) break;
|
||||
int r = externalToInternalRow(i);
|
||||
if (mLines[r] == null || mLines[r].isBlank()) {
|
||||
if (--shiftDownOfTopRow == 0) break;
|
||||
}
|
||||
}
|
||||
} else if (shiftDownOfTopRow < 0) {
|
||||
// Negative shift down = expanding. Only move screen up if there is transcript to show:
|
||||
int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows);
|
||||
if (shiftDownOfTopRow != actualShift) {
|
||||
// The new lines revealed by the resizing are not all from the transcript. Blank the below ones.
|
||||
for (int i = 0; i < actualShift - shiftDownOfTopRow; i++)
|
||||
allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle);
|
||||
shiftDownOfTopRow = actualShift;
|
||||
}
|
||||
}
|
||||
mScreenFirstRow += shiftDownOfTopRow;
|
||||
mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows);
|
||||
mTotalRows = newTotalRows;
|
||||
mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow);
|
||||
cursor[1] -= shiftDownOfTopRow;
|
||||
mScreenRows = newRows;
|
||||
} else {
|
||||
// Copy away old state and update new:
|
||||
TerminalRow[] oldLines = mLines;
|
||||
mLines = new TerminalRow[newTotalRows];
|
||||
for (int i = 0; i < newTotalRows; i++)
|
||||
mLines[i] = new TerminalRow(newColumns, currentStyle);
|
||||
|
||||
final int oldActiveTranscriptRows = mActiveTranscriptRows;
|
||||
final int oldScreenFirstRow = mScreenFirstRow;
|
||||
final int oldScreenRows = mScreenRows;
|
||||
final int oldTotalRows = mTotalRows;
|
||||
mTotalRows = newTotalRows;
|
||||
mScreenRows = newRows;
|
||||
mActiveTranscriptRows = mScreenFirstRow = 0;
|
||||
mColumns = newColumns;
|
||||
|
||||
int newCursorRow = -1;
|
||||
int newCursorColumn = -1;
|
||||
int oldCursorRow = cursor[1];
|
||||
int oldCursorColumn = cursor[0];
|
||||
boolean newCursorPlaced = false;
|
||||
|
||||
int currentOutputExternalRow = 0;
|
||||
int currentOutputExternalColumn = 0;
|
||||
|
||||
// Loop over every character in the initial state.
|
||||
// Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we
|
||||
// keep track how many blank lines we have skipped if we later on find a non-blank line.
|
||||
int skippedBlankLines = 0;
|
||||
for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) {
|
||||
// Do what externalToInternalRow() does but for the old state:
|
||||
int internalOldRow = oldScreenFirstRow + externalOldRow;
|
||||
internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows);
|
||||
|
||||
TerminalRow oldLine = oldLines[internalOldRow];
|
||||
boolean cursorAtThisRow = externalOldRow == oldCursorRow;
|
||||
// The cursor may only be on a non-null line, which we should not skip:
|
||||
if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) {
|
||||
skippedBlankLines++;
|
||||
continue;
|
||||
} else if (skippedBlankLines > 0) {
|
||||
// After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines.
|
||||
for (int i = 0; i < skippedBlankLines; i++) {
|
||||
if (currentOutputExternalRow == mScreenRows - 1) {
|
||||
scrollDownOneLine(0, mScreenRows, currentStyle);
|
||||
} else {
|
||||
currentOutputExternalRow++;
|
||||
}
|
||||
currentOutputExternalColumn = 0;
|
||||
}
|
||||
skippedBlankLines = 0;
|
||||
}
|
||||
|
||||
int lastNonSpaceIndex = 0;
|
||||
boolean justToCursor = false;
|
||||
if (cursorAtThisRow || oldLine.mLineWrap) {
|
||||
// Take the whole line, either because of cursor on it, or if line wrapping.
|
||||
lastNonSpaceIndex = oldLine.getSpaceUsed();
|
||||
if (cursorAtThisRow) justToCursor = true;
|
||||
} else {
|
||||
for (int i = 0; i < oldLine.getSpaceUsed(); i++)
|
||||
// NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices
|
||||
if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */)
|
||||
lastNonSpaceIndex = i + 1;
|
||||
}
|
||||
|
||||
int currentOldCol = 0;
|
||||
long styleAtCol = 0;
|
||||
for (int i = 0; i < lastNonSpaceIndex; i++) {
|
||||
// Note that looping over java character, not cells.
|
||||
char c = oldLine.mText[i];
|
||||
int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c;
|
||||
int displayWidth = WcWidth.width(codePoint);
|
||||
// Use the last style if this is a zero-width character:
|
||||
if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol);
|
||||
|
||||
// Line wrap as necessary:
|
||||
if (currentOutputExternalColumn + displayWidth > mColumns) {
|
||||
setLineWrap(currentOutputExternalRow);
|
||||
if (currentOutputExternalRow == mScreenRows - 1) {
|
||||
if (newCursorPlaced) newCursorRow--;
|
||||
scrollDownOneLine(0, mScreenRows, currentStyle);
|
||||
} else {
|
||||
currentOutputExternalRow++;
|
||||
}
|
||||
currentOutputExternalColumn = 0;
|
||||
}
|
||||
|
||||
int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0);
|
||||
int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar;
|
||||
setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol);
|
||||
|
||||
if (displayWidth > 0) {
|
||||
if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) {
|
||||
newCursorColumn = currentOutputExternalColumn;
|
||||
newCursorRow = currentOutputExternalRow;
|
||||
newCursorPlaced = true;
|
||||
}
|
||||
currentOldCol += displayWidth;
|
||||
currentOutputExternalColumn += displayWidth;
|
||||
if (justToCursor && newCursorPlaced) break;
|
||||
}
|
||||
}
|
||||
// Old row has been copied. Check if we need to insert newline if old line was not wrapping:
|
||||
if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) {
|
||||
if (currentOutputExternalRow == mScreenRows - 1) {
|
||||
if (newCursorPlaced) newCursorRow--;
|
||||
scrollDownOneLine(0, mScreenRows, currentStyle);
|
||||
} else {
|
||||
currentOutputExternalRow++;
|
||||
}
|
||||
currentOutputExternalColumn = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cursor[0] = newCursorColumn;
|
||||
cursor[1] = newCursorRow;
|
||||
}
|
||||
|
||||
// Handle cursor scrolling off screen:
|
||||
if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound
|
||||
* into account.
|
||||
*
|
||||
* @param srcInternal The first line to be copied.
|
||||
* @param len The number of lines to be copied.
|
||||
*/
|
||||
private void blockCopyLinesDown(int srcInternal, int len) {
|
||||
if (len == 0) return;
|
||||
int totalRows = mTotalRows;
|
||||
|
||||
int start = len - 1;
|
||||
// Save away line to be overwritten:
|
||||
TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows];
|
||||
// Do the copy from bottom to top.
|
||||
for (int i = start; i >= 0; --i)
|
||||
mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows];
|
||||
// Put back overwritten line, now above the block:
|
||||
mLines[(srcInternal) % totalRows] = lineToBeOverWritten;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24).
|
||||
*
|
||||
* @param topMargin First line that is scrolled.
|
||||
* @param bottomMargin One line after the last line that is scrolled.
|
||||
* @param style the style for the newly exposed line.
|
||||
*/
|
||||
public void scrollDownOneLine(int topMargin, int bottomMargin, long style) {
|
||||
if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows)
|
||||
throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows);
|
||||
|
||||
// Copy the fixed topMargin lines one line down so that they remain on screen in same position:
|
||||
blockCopyLinesDown(mScreenFirstRow, topMargin);
|
||||
// Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same
|
||||
// position:
|
||||
blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin);
|
||||
|
||||
// Update the screen location in the ring buffer:
|
||||
mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows;
|
||||
// Note that the history has grown if not already full:
|
||||
if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++;
|
||||
|
||||
// Blank the newly revealed line above the bottom margin:
|
||||
int blankRow = externalToInternalRow(bottomMargin - 1);
|
||||
if (mLines[blankRow] == null) {
|
||||
mLines[blankRow] = new TerminalRow(mColumns, style);
|
||||
} else {
|
||||
mLines[blankRow].clear(style);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block copy characters from one position in the screen to another. The two positions can overlap. All characters
|
||||
* of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will
|
||||
* be thrown.
|
||||
*
|
||||
* @param sx source X coordinate
|
||||
* @param sy source Y coordinate
|
||||
* @param w width
|
||||
* @param h height
|
||||
* @param dx destination X coordinate
|
||||
* @param dy destination Y coordinate
|
||||
*/
|
||||
public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) {
|
||||
if (w == 0) return;
|
||||
if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows)
|
||||
throw new IllegalArgumentException();
|
||||
boolean copyingUp = sy > dy;
|
||||
for (int y = 0; y < h; y++) {
|
||||
int y2 = copyingUp ? y : (h - (y + 1));
|
||||
TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2));
|
||||
allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block set characters. All characters must be within the bounds of the screen, or else and
|
||||
* InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block
|
||||
* of characters.
|
||||
*/
|
||||
public void blockSet(int sx, int sy, int w, int h, int val, long style) {
|
||||
if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) {
|
||||
throw new IllegalArgumentException(
|
||||
"Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")");
|
||||
}
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
setChar(sx + x, sy + y, val, style);
|
||||
}
|
||||
|
||||
public TerminalRow allocateFullLineIfNecessary(int row) {
|
||||
return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row];
|
||||
}
|
||||
|
||||
public void setChar(int column, int row, int codePoint, long style) {
|
||||
if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns)
|
||||
throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns);
|
||||
row = externalToInternalRow(row);
|
||||
allocateFullLineIfNecessary(row).setChar(column, codePoint, style);
|
||||
}
|
||||
|
||||
public long getStyleAt(int externalRow, int column) {
|
||||
return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column);
|
||||
}
|
||||
|
||||
/** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */
|
||||
public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left,
|
||||
int bottom, int right) {
|
||||
for (int y = top; y < bottom; y++) {
|
||||
TerminalRow line = mLines[externalToInternalRow(y)];
|
||||
int startOfLine = (rectangular || y == top) ? left : leftMargin;
|
||||
int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin;
|
||||
for (int x = startOfLine; x < endOfLine; x++) {
|
||||
long currentStyle = line.getStyle(x);
|
||||
int foreColor = TextStyle.decodeForeColor(currentStyle);
|
||||
int backColor = TextStyle.decodeBackColor(currentStyle);
|
||||
int effect = TextStyle.decodeEffect(currentStyle);
|
||||
if (reverse) {
|
||||
// Clear out the bits to reverse and add them back in reversed:
|
||||
effect = (effect & ~bits) | (bits & ~effect);
|
||||
} else if (setOrClear) {
|
||||
effect |= bits;
|
||||
} else {
|
||||
effect &= ~bits;
|
||||
}
|
||||
line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void clearTranscript() {
|
||||
if (mScreenFirstRow < mActiveTranscriptRows) {
|
||||
Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null);
|
||||
Arrays.fill(mLines, 0, mScreenFirstRow, null);
|
||||
} else {
|
||||
Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null);
|
||||
}
|
||||
mActiveTranscriptRows = 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using
|
||||
* Operating System Control (OSC) sequences.
|
||||
*
|
||||
* @see TerminalColors
|
||||
*/
|
||||
public final class TerminalColorScheme {
|
||||
|
||||
/** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */
|
||||
private static final int[] DEFAULT_COLORSCHEME = {
|
||||
// 16 original colors. First 8 are dim.
|
||||
0xff000000, // black
|
||||
0xffcd0000, // dim red
|
||||
0xff00cd00, // dim green
|
||||
0xffcdcd00, // dim yellow
|
||||
0xff6495ed, // dim blue
|
||||
0xffcd00cd, // dim magenta
|
||||
0xff00cdcd, // dim cyan
|
||||
0xffe5e5e5, // dim white
|
||||
// Second 8 are bright:
|
||||
0xff7f7f7f, // medium grey
|
||||
0xffff0000, // bright red
|
||||
0xff00ff00, // bright green
|
||||
0xffffff00, // bright yellow
|
||||
0xff5c5cff, // light blue
|
||||
0xffff00ff, // bright magenta
|
||||
0xff00ffff, // bright cyan
|
||||
0xffffffff, // bright white
|
||||
|
||||
// 216 color cube, six shades of each color:
|
||||
0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff,
|
||||
0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff,
|
||||
0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff,
|
||||
0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff,
|
||||
0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff,
|
||||
0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff,
|
||||
0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff,
|
||||
0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff,
|
||||
0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff,
|
||||
0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff,
|
||||
0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff,
|
||||
0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff,
|
||||
0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff,
|
||||
0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff,
|
||||
0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff,
|
||||
0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff,
|
||||
0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff,
|
||||
0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff,
|
||||
|
||||
// 24 grey scale ramp:
|
||||
0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676,
|
||||
0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee,
|
||||
|
||||
// COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR:
|
||||
0xffffffff, 0xff000000, 0xffffffff};
|
||||
|
||||
public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS];
|
||||
|
||||
public TerminalColorScheme() {
|
||||
reset();
|
||||
}
|
||||
|
||||
private void reset() {
|
||||
System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS);
|
||||
}
|
||||
|
||||
public void updateWith(Properties props) {
|
||||
reset();
|
||||
boolean cursorPropExists = false;
|
||||
for (Map.Entry<Object, Object> entries : props.entrySet()) {
|
||||
String key = (String) entries.getKey();
|
||||
String value = (String) entries.getValue();
|
||||
int colorIndex;
|
||||
|
||||
if (key.equals("foreground")) {
|
||||
colorIndex = TextStyle.COLOR_INDEX_FOREGROUND;
|
||||
} else if (key.equals("background")) {
|
||||
colorIndex = TextStyle.COLOR_INDEX_BACKGROUND;
|
||||
} else if (key.equals("cursor")) {
|
||||
colorIndex = TextStyle.COLOR_INDEX_CURSOR;
|
||||
cursorPropExists = true;
|
||||
} else if (key.startsWith("color")) {
|
||||
try {
|
||||
colorIndex = Integer.parseInt(key.substring(5));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Invalid property: '" + key + "'");
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid property: '" + key + "'");
|
||||
}
|
||||
|
||||
int colorValue = TerminalColors.parse(value);
|
||||
if (colorValue == 0)
|
||||
throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'");
|
||||
|
||||
mDefaultColors[colorIndex] = colorValue;
|
||||
}
|
||||
|
||||
if (!cursorPropExists)
|
||||
setCursorColorForBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
* If the "cursor" color is not set by user, we need to decide on the appropriate color that will
|
||||
* be visible on the current terminal background. White will not be visible on light backgrounds
|
||||
* and black won't be visible on dark backgrounds. So we find the perceived brightness of the
|
||||
* background color and if its below the threshold (too dark), we use white cursor and if its
|
||||
* above (too bright), we use black cursor.
|
||||
*/
|
||||
public void setCursorColorForBackground() {
|
||||
int backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND];
|
||||
int brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor);
|
||||
if (brightness > 0) {
|
||||
if (brightness < 130)
|
||||
mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xffffffff;
|
||||
else
|
||||
mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xff000000;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import android.graphics.Color;
|
||||
|
||||
/** Current terminal colors (if different from default). */
|
||||
public final class TerminalColors {
|
||||
|
||||
/** Static data - a bit ugly but ok for now. */
|
||||
public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme();
|
||||
|
||||
/**
|
||||
* The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC
|
||||
* 4 control sequence.
|
||||
*/
|
||||
public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS];
|
||||
|
||||
/** Create a new instance with default colors from the theme. */
|
||||
public TerminalColors() {
|
||||
reset();
|
||||
}
|
||||
|
||||
/** Reset a particular indexed color with the default color from the color theme. */
|
||||
public void reset(int index) {
|
||||
mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index];
|
||||
}
|
||||
|
||||
/** Reset all indexed colors with the default color from the color theme. */
|
||||
public void reset() {
|
||||
System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html
|
||||
* <p/>
|
||||
* Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed.
|
||||
*/
|
||||
static int parse(String c) {
|
||||
try {
|
||||
int skipInitial, skipBetween;
|
||||
if (c.charAt(0) == '#') {
|
||||
// #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits.
|
||||
skipInitial = 1;
|
||||
skipBetween = 0;
|
||||
} else if (c.startsWith("rgb:")) {
|
||||
// rgb:<red>/<green>/<blue> where <red>, <green>, <blue> := h | hh | hhh | hhhh. Scaled.
|
||||
skipInitial = 4;
|
||||
skipBetween = 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
int charsForColors = c.length() - skipInitial - 2 * skipBetween;
|
||||
if (charsForColors % 3 != 0) return 0; // Unequal lengths.
|
||||
int componentLength = charsForColors / 3;
|
||||
double mult = 255 / (Math.pow(2, componentLength * 4) - 1);
|
||||
|
||||
int currentPosition = skipInitial;
|
||||
String rString = c.substring(currentPosition, currentPosition + componentLength);
|
||||
currentPosition += componentLength + skipBetween;
|
||||
String gString = c.substring(currentPosition, currentPosition + componentLength);
|
||||
currentPosition += componentLength + skipBetween;
|
||||
String bString = c.substring(currentPosition, currentPosition + componentLength);
|
||||
|
||||
int r = (int) (Integer.parseInt(rString, 16) * mult);
|
||||
int g = (int) (Integer.parseInt(gString, 16) * mult);
|
||||
int b = (int) (Integer.parseInt(bString, 16) * mult);
|
||||
return 0xFF << 24 | r << 16 | g << 8 | b;
|
||||
} catch (NumberFormatException | IndexOutOfBoundsException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Try parse a color from a text parameter and into a specified index. */
|
||||
public void tryParseColor(int intoIndex, String textParameter) {
|
||||
int c = parse(textParameter);
|
||||
if (c != 0) mCurrentColors[intoIndex] = c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the perceived brightness of the color based on its RGB components.
|
||||
*
|
||||
* https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx
|
||||
* http://alienryderflex.com/hsp.html
|
||||
*
|
||||
* @param color The color code int.
|
||||
* @return Returns value between 0-255.
|
||||
*/
|
||||
public static int getPerceivedBrightnessOfColor(int color) {
|
||||
return (int)
|
||||
Math.floor(Math.sqrt(
|
||||
Math.pow(Color.red(color), 2) * 0.241 +
|
||||
Math.pow(Color.green(color), 2) * 0.691 +
|
||||
Math.pow(Color.blue(color), 2) * 0.068
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */
|
||||
public abstract class TerminalOutput {
|
||||
|
||||
/** Write a string using the UTF-8 encoding to the terminal client. */
|
||||
public final void write(String data) {
|
||||
if (data == null) return;
|
||||
byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
|
||||
write(bytes, 0, bytes.length);
|
||||
}
|
||||
|
||||
/** Write bytes to the terminal client. */
|
||||
public abstract void write(byte[] data, int offset, int count);
|
||||
|
||||
/** Notify the terminal client that the terminal title has changed. */
|
||||
public abstract void titleChanged(String oldTitle, String newTitle);
|
||||
|
||||
/** Notify the terminal client that text should be copied to clipboard. */
|
||||
public abstract void onCopyTextToClipboard(String text);
|
||||
|
||||
/** Notify the terminal client that text should be pasted from clipboard. */
|
||||
public abstract void onPasteTextFromClipboard();
|
||||
|
||||
/** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */
|
||||
public abstract void onBell();
|
||||
|
||||
public abstract void onColorsChanged();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A row in a terminal, composed of a fixed number of cells.
|
||||
* <p>
|
||||
* The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering.
|
||||
*/
|
||||
public final class TerminalRow {
|
||||
|
||||
private static final float SPARE_CAPACITY_FACTOR = 1.5f;
|
||||
|
||||
/** The number of columns in this terminal row. */
|
||||
private final int mColumns;
|
||||
/** The text filling this terminal row. */
|
||||
public char[] mText;
|
||||
/** The number of java char:s used in {@link #mText}. */
|
||||
private short mSpaceUsed;
|
||||
/** If this row has been line wrapped due to text output at the end of line. */
|
||||
boolean mLineWrap;
|
||||
/** The style bits of each cell in the row. See {@link TextStyle}. */
|
||||
final long[] mStyle;
|
||||
/** If this row might contain chars with width != 1, used for deactivating fast path */
|
||||
boolean mHasNonOneWidthOrSurrogateChars;
|
||||
|
||||
/** Construct a blank row (containing only whitespace, ' ') with a specified style. */
|
||||
public TerminalRow(int columns, long style) {
|
||||
mColumns = columns;
|
||||
mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)];
|
||||
mStyle = new long[columns];
|
||||
clear(style);
|
||||
}
|
||||
|
||||
/** NOTE: The sourceX2 is exclusive. */
|
||||
public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) {
|
||||
mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars;
|
||||
final int x1 = line.findStartOfColumn(sourceX1);
|
||||
final int x2 = line.findStartOfColumn(sourceX2);
|
||||
boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1));
|
||||
final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText;
|
||||
int latestNonCombiningWidth = 0;
|
||||
for (int i = x1; i < x2; i++) {
|
||||
char sourceChar = sourceChars[i];
|
||||
int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar;
|
||||
if (startingFromSecondHalfOfWideChar) {
|
||||
// Just treat copying second half of wide char as copying whitespace.
|
||||
codePoint = ' ';
|
||||
startingFromSecondHalfOfWideChar = false;
|
||||
}
|
||||
int w = WcWidth.width(codePoint);
|
||||
if (w > 0) {
|
||||
destinationX += latestNonCombiningWidth;
|
||||
sourceX1 += latestNonCombiningWidth;
|
||||
latestNonCombiningWidth = w;
|
||||
}
|
||||
setChar(destinationX, codePoint, line.getStyle(sourceX1));
|
||||
}
|
||||
}
|
||||
|
||||
public int getSpaceUsed() {
|
||||
return mSpaceUsed;
|
||||
}
|
||||
|
||||
/** Note that the column may end of second half of wide character. */
|
||||
public int findStartOfColumn(int column) {
|
||||
if (column == mColumns) return getSpaceUsed();
|
||||
|
||||
int currentColumn = 0;
|
||||
int currentCharIndex = 0;
|
||||
while (true) { // 0<2 1 < 2
|
||||
int newCharIndex = currentCharIndex;
|
||||
char c = mText[newCharIndex++]; // cci=1, cci=2
|
||||
boolean isHigh = Character.isHighSurrogate(c);
|
||||
int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c;
|
||||
int wcwidth = WcWidth.width(codePoint); // 1, 2
|
||||
if (wcwidth > 0) {
|
||||
currentColumn += wcwidth;
|
||||
if (currentColumn == column) {
|
||||
while (newCharIndex < mSpaceUsed) {
|
||||
// Skip combining chars.
|
||||
if (Character.isHighSurrogate(mText[newCharIndex])) {
|
||||
if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) {
|
||||
newCharIndex += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (WcWidth.width(mText[newCharIndex]) <= 0) {
|
||||
newCharIndex++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return newCharIndex;
|
||||
} else if (currentColumn > column) {
|
||||
// Wide column going past end.
|
||||
return currentCharIndex;
|
||||
}
|
||||
}
|
||||
currentCharIndex = newCharIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean wideDisplayCharacterStartingAt(int column) {
|
||||
for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) {
|
||||
char c = mText[currentCharIndex++];
|
||||
int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c;
|
||||
int wcwidth = WcWidth.width(codePoint);
|
||||
if (wcwidth > 0) {
|
||||
if (currentColumn == column && wcwidth == 2) return true;
|
||||
currentColumn += wcwidth;
|
||||
if (currentColumn > column) return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void clear(long style) {
|
||||
Arrays.fill(mText, ' ');
|
||||
Arrays.fill(mStyle, style);
|
||||
mSpaceUsed = (short) mColumns;
|
||||
mHasNonOneWidthOrSurrogateChars = false;
|
||||
}
|
||||
|
||||
// https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
|
||||
public void setChar(int columnToSet, int codePoint, long style) {
|
||||
if (columnToSet < 0 || columnToSet >= mStyle.length)
|
||||
throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style);
|
||||
|
||||
mStyle[columnToSet] = style;
|
||||
|
||||
final int newCodePointDisplayWidth = WcWidth.width(codePoint);
|
||||
|
||||
// Fast path when we don't have any chars with width != 1
|
||||
if (!mHasNonOneWidthOrSurrogateChars) {
|
||||
if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) {
|
||||
mHasNonOneWidthOrSurrogateChars = true;
|
||||
} else {
|
||||
mText[columnToSet] = (char) codePoint;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final boolean newIsCombining = newCodePointDisplayWidth <= 0;
|
||||
|
||||
boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1);
|
||||
|
||||
if (newIsCombining) {
|
||||
// When standing at second half of wide character and inserting combining:
|
||||
if (wasExtraColForWideChar) columnToSet--;
|
||||
} else {
|
||||
// Check if we are overwriting the second half of a wide character starting at the previous column:
|
||||
if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style);
|
||||
// Check if we are overwriting the first half of a wide character starting at the next column:
|
||||
boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1);
|
||||
if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style);
|
||||
}
|
||||
|
||||
char[] text = mText;
|
||||
final int oldStartOfColumnIndex = findStartOfColumn(columnToSet);
|
||||
final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex);
|
||||
|
||||
// Get the number of elements in the mText array this column uses now
|
||||
int oldCharactersUsedForColumn;
|
||||
if (columnToSet + oldCodePointDisplayWidth < mColumns) {
|
||||
oldCharactersUsedForColumn = findStartOfColumn(columnToSet + oldCodePointDisplayWidth) - oldStartOfColumnIndex;
|
||||
} else {
|
||||
// Last character.
|
||||
oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex;
|
||||
}
|
||||
|
||||
// Find how many chars this column will need
|
||||
int newCharactersUsedForColumn = Character.charCount(codePoint);
|
||||
if (newIsCombining) {
|
||||
// Combining characters are added to the contents of the column instead of overwriting them, so that they
|
||||
// modify the existing contents.
|
||||
// FIXME: Put a limit of combining characters.
|
||||
// FIXME: Unassigned characters also get width=0.
|
||||
newCharactersUsedForColumn += oldCharactersUsedForColumn;
|
||||
}
|
||||
|
||||
int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn;
|
||||
int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn;
|
||||
|
||||
final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn;
|
||||
if (javaCharDifference > 0) {
|
||||
// Shift the rest of the line right.
|
||||
int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex;
|
||||
if (mSpaceUsed + javaCharDifference > text.length) {
|
||||
// We need to grow the array
|
||||
char[] newText = new char[text.length + mColumns];
|
||||
System.arraycopy(text, 0, newText, 0, oldStartOfColumnIndex + oldCharactersUsedForColumn);
|
||||
System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn);
|
||||
mText = text = newText;
|
||||
} else {
|
||||
System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn);
|
||||
}
|
||||
} else if (javaCharDifference < 0) {
|
||||
// Shift the rest of the line left.
|
||||
System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex);
|
||||
}
|
||||
mSpaceUsed += javaCharDifference;
|
||||
|
||||
// Store char. A combining character is stored at the end of the existing contents so that it modifies them:
|
||||
//noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used.
|
||||
Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0));
|
||||
|
||||
if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
|
||||
// Replace second half of wide char with a space. Which mean that we actually add a ' ' java character.
|
||||
if (mSpaceUsed + 1 > text.length) {
|
||||
char[] newText = new char[text.length + mColumns];
|
||||
System.arraycopy(text, 0, newText, 0, newNextColumnIndex);
|
||||
System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
|
||||
mText = text = newText;
|
||||
} else {
|
||||
System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
|
||||
}
|
||||
text[newNextColumnIndex] = ' ';
|
||||
|
||||
++mSpaceUsed;
|
||||
} else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
|
||||
if (columnToSet == mColumns - 1) {
|
||||
throw new IllegalArgumentException("Cannot put wide character in last column");
|
||||
} else if (columnToSet == mColumns - 2) {
|
||||
// Truncate the line to the second part of this wide char:
|
||||
mSpaceUsed = (short) newNextColumnIndex;
|
||||
} else {
|
||||
// Overwrite the contents of the next column, which mean we actually remove java characters. Due to the
|
||||
// check at the beginning of this method we know that we are not overwriting a wide char.
|
||||
int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1);
|
||||
int nextLen = newNextNextColumnIndex - newNextColumnIndex;
|
||||
|
||||
// Shift the array leftwards.
|
||||
System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex);
|
||||
mSpaceUsed -= nextLen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean isBlank() {
|
||||
for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++)
|
||||
if (mText[charIndex] != ' ') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public final long getStyle(int column) {
|
||||
return mStyle[column];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.os.Handler;
|
||||
import android.os.Message;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.system.OsConstants;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A terminal session, consisting of a process coupled to a terminal interface.
|
||||
* <p>
|
||||
* The subprocess will be executed by the constructor, and when the size is made known by a call to
|
||||
* {@link #updateSize(int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O.
|
||||
* All terminal emulation and callback methods will be performed on the main thread.
|
||||
* <p>
|
||||
* The child process may be exited forcefully by using the {@link #finishIfRunning()} method.
|
||||
* <p>
|
||||
* NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks!
|
||||
*/
|
||||
public final class TerminalSession extends TerminalOutput {
|
||||
|
||||
private static final int MSG_NEW_INPUT = 1;
|
||||
private static final int MSG_PROCESS_EXITED = 4;
|
||||
|
||||
public final String mHandle = UUID.randomUUID().toString();
|
||||
|
||||
TerminalEmulator mEmulator;
|
||||
|
||||
/**
|
||||
* A queue written to from a separate thread when the process outputs, and read by main thread to process by
|
||||
* terminal emulator.
|
||||
*/
|
||||
final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096);
|
||||
/**
|
||||
* A queue written to from the main thread due to user interaction, and read by another thread which forwards by
|
||||
* writing to the {@link #mTerminalFileDescriptor}.
|
||||
*/
|
||||
final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096);
|
||||
/** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */
|
||||
private final byte[] mUtf8InputBuffer = new byte[5];
|
||||
|
||||
/** Callback which gets notified when a session finishes or changes title. */
|
||||
TerminalSessionClient mClient;
|
||||
|
||||
/** The pid of the shell process. 0 if not started and -1 if finished running. */
|
||||
int mShellPid;
|
||||
|
||||
/** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */
|
||||
int mShellExitStatus;
|
||||
|
||||
/**
|
||||
* The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling
|
||||
* {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int)}.
|
||||
*/
|
||||
private int mTerminalFileDescriptor;
|
||||
|
||||
/** Set by the application for user identification of session, not by terminal. */
|
||||
public String mSessionName;
|
||||
|
||||
final Handler mMainThreadHandler = new MainThreadHandler();
|
||||
|
||||
private final String mShellPath;
|
||||
private final String mCwd;
|
||||
private final String[] mArgs;
|
||||
private final String[] mEnv;
|
||||
private final Integer mTranscriptRows;
|
||||
|
||||
|
||||
private static final String LOG_TAG = "TerminalSession";
|
||||
|
||||
public TerminalSession(String shellPath, String cwd, String[] args, String[] env, Integer transcriptRows, TerminalSessionClient client) {
|
||||
this.mShellPath = shellPath;
|
||||
this.mCwd = cwd;
|
||||
this.mArgs = args;
|
||||
this.mEnv = env;
|
||||
this.mTranscriptRows = transcriptRows;
|
||||
this.mClient = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param client The {@link TerminalSessionClient} interface implementation to allow
|
||||
* for communication between {@link TerminalSession} and its client.
|
||||
*/
|
||||
public void updateTerminalSessionClient(TerminalSessionClient client) {
|
||||
mClient = client;
|
||||
|
||||
if (mEmulator != null)
|
||||
mEmulator.updateTerminalSessionClient(client);
|
||||
}
|
||||
|
||||
/** Inform the attached pty of the new size and reflow or initialize the emulator. */
|
||||
public void updateSize(int columns, int rows) {
|
||||
if (mEmulator == null) {
|
||||
initializeEmulator(columns, rows);
|
||||
} else {
|
||||
JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns);
|
||||
mEmulator.resize(columns, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/** The terminal title as set through escape sequences or null if none set. */
|
||||
public String getTitle() {
|
||||
return (mEmulator == null) ? null : mEmulator.getTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the terminal emulator's window size and start terminal emulation.
|
||||
*
|
||||
* @param columns The number of columns in the terminal window.
|
||||
* @param rows The number of rows in the terminal window.
|
||||
*/
|
||||
public void initializeEmulator(int columns, int rows) {
|
||||
mEmulator = new TerminalEmulator(this, columns, rows, mTranscriptRows, mClient);
|
||||
|
||||
int[] processId = new int[1];
|
||||
mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns);
|
||||
mShellPid = processId[0];
|
||||
mClient.setTerminalShellPid(this, mShellPid);
|
||||
|
||||
final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient);
|
||||
|
||||
new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
|
||||
@Override
|
||||
public void run() {
|
||||
try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) {
|
||||
final byte[] buffer = new byte[4096];
|
||||
while (true) {
|
||||
int read = termIn.read(buffer);
|
||||
if (read == -1) return;
|
||||
if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return;
|
||||
mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore, just shutting down.
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
|
||||
new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") {
|
||||
@Override
|
||||
public void run() {
|
||||
final byte[] buffer = new byte[4096];
|
||||
try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) {
|
||||
while (true) {
|
||||
int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true);
|
||||
if (bytesToWrite == -1) return;
|
||||
termOut.write(buffer, 0, bytesToWrite);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
|
||||
new Thread("TermSessionWaiter[pid=" + mShellPid + "]") {
|
||||
@Override
|
||||
public void run() {
|
||||
int processExitCode = JNI.waitFor(mShellPid);
|
||||
mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode));
|
||||
}
|
||||
}.start();
|
||||
|
||||
}
|
||||
|
||||
/** Write data to the shell process. */
|
||||
@Override
|
||||
public void write(byte[] data, int offset, int count) {
|
||||
if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count);
|
||||
}
|
||||
|
||||
/** Write the Unicode code point to the terminal encoded in UTF-8. */
|
||||
public void writeCodePoint(boolean prependEscape, int codePoint) {
|
||||
if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
|
||||
// 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range.
|
||||
throw new IllegalArgumentException("Invalid code point: " + codePoint);
|
||||
}
|
||||
|
||||
int bufferPosition = 0;
|
||||
if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27;
|
||||
|
||||
if (codePoint <= /* 7 bits */0b1111111) {
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) codePoint;
|
||||
} else if (codePoint <= /* 11 bits */0b11111111111) {
|
||||
/* 110xxxxx leading byte with leading 5 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6));
|
||||
/* 10xxxxxx continuation byte with following 6 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
|
||||
} else if (codePoint <= /* 16 bits */0b1111111111111111) {
|
||||
/* 1110xxxx leading byte with leading 4 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12));
|
||||
/* 10xxxxxx continuation byte with following 6 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
|
||||
/* 10xxxxxx continuation byte with following 6 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
|
||||
} else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */
|
||||
/* 11110xxx leading byte with leading 3 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18));
|
||||
/* 10xxxxxx continuation byte with following 6 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111));
|
||||
/* 10xxxxxx continuation byte with following 6 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
|
||||
/* 10xxxxxx continuation byte with following 6 bits */
|
||||
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
|
||||
}
|
||||
write(mUtf8InputBuffer, 0, bufferPosition);
|
||||
}
|
||||
|
||||
public TerminalEmulator getEmulator() {
|
||||
return mEmulator;
|
||||
}
|
||||
|
||||
/** Notify the {@link #mClient} that the screen has changed. */
|
||||
protected void notifyScreenUpdate() {
|
||||
mClient.onTextChanged(this);
|
||||
}
|
||||
|
||||
/** Reset state for terminal emulator state. */
|
||||
public void reset() {
|
||||
mEmulator.reset();
|
||||
notifyScreenUpdate();
|
||||
}
|
||||
|
||||
/** Finish this terminal session by sending SIGKILL to the shell. */
|
||||
public void finishIfRunning() {
|
||||
if (isRunning()) {
|
||||
try {
|
||||
Os.kill(mShellPid, OsConstants.SIGKILL);
|
||||
} catch (ErrnoException e) {
|
||||
Logger.logWarn(mClient, LOG_TAG, "Failed sending SIGKILL: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanup resources when the process exits. */
|
||||
void cleanupResources(int exitStatus) {
|
||||
synchronized (this) {
|
||||
mShellPid = -1;
|
||||
mShellExitStatus = exitStatus;
|
||||
}
|
||||
|
||||
// Stop the reader and writer threads, and close the I/O streams
|
||||
mTerminalToProcessIOQueue.close();
|
||||
mProcessToTerminalIOQueue.close();
|
||||
JNI.close(mTerminalFileDescriptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void titleChanged(String oldTitle, String newTitle) {
|
||||
mClient.onTitleChanged(this);
|
||||
}
|
||||
|
||||
public synchronized boolean isRunning() {
|
||||
return mShellPid != -1;
|
||||
}
|
||||
|
||||
/** Only valid if not {@link #isRunning()}. */
|
||||
public synchronized int getExitStatus() {
|
||||
return mShellExitStatus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyTextToClipboard(String text) {
|
||||
mClient.onCopyTextToClipboard(this, text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPasteTextFromClipboard() {
|
||||
mClient.onPasteTextFromClipboard(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBell() {
|
||||
mClient.onBell(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorsChanged() {
|
||||
mClient.onColorsChanged(this);
|
||||
}
|
||||
|
||||
public int getPid() {
|
||||
return mShellPid;
|
||||
}
|
||||
|
||||
/** Returns the shell's working directory or null if it was unavailable. */
|
||||
public String getCwd() {
|
||||
if (mShellPid < 1) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final String cwdSymlink = String.format("/proc/%s/cwd/", mShellPid);
|
||||
String outputPath = new File(cwdSymlink).getCanonicalPath();
|
||||
String outputPathWithTrailingSlash = outputPath;
|
||||
if (!outputPath.endsWith("/")) {
|
||||
outputPathWithTrailingSlash += '/';
|
||||
}
|
||||
if (!cwdSymlink.equals(outputPathWithTrailingSlash)) {
|
||||
return outputPath;
|
||||
}
|
||||
} catch (IOException | SecurityException e) {
|
||||
Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Error getting current directory", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) {
|
||||
FileDescriptor result = new FileDescriptor();
|
||||
try {
|
||||
Field descriptorField;
|
||||
try {
|
||||
descriptorField = FileDescriptor.class.getDeclaredField("descriptor");
|
||||
} catch (NoSuchFieldException e) {
|
||||
// For desktop java:
|
||||
descriptorField = FileDescriptor.class.getDeclaredField("fd");
|
||||
}
|
||||
descriptorField.setAccessible(true);
|
||||
descriptorField.set(result, fileDescriptor);
|
||||
} catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
|
||||
Logger.logStackTraceWithMessage(client, LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e);
|
||||
System.exit(1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressLint("HandlerLeak")
|
||||
class MainThreadHandler extends Handler {
|
||||
|
||||
final byte[] mReceiveBuffer = new byte[4 * 1024];
|
||||
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false);
|
||||
if (bytesRead > 0) {
|
||||
mEmulator.append(mReceiveBuffer, bytesRead);
|
||||
notifyScreenUpdate();
|
||||
}
|
||||
|
||||
if (msg.what == MSG_PROCESS_EXITED) {
|
||||
int exitCode = (Integer) msg.obj;
|
||||
cleanupResources(exitCode);
|
||||
|
||||
String exitDescription = "\r\n[Process completed";
|
||||
if (exitCode > 0) {
|
||||
// Non-zero process exit.
|
||||
exitDescription += " (code " + exitCode + ")";
|
||||
} else if (exitCode < 0) {
|
||||
// Negated signal.
|
||||
exitDescription += " (signal " + (-exitCode) + ")";
|
||||
}
|
||||
exitDescription += " - press Enter]";
|
||||
|
||||
byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8);
|
||||
mEmulator.append(bytesToWrite, bytesToWrite.length);
|
||||
notifyScreenUpdate();
|
||||
|
||||
mClient.onSessionFinished(TerminalSession.this);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The interface for communication between {@link TerminalSession} and its client. It is used to
|
||||
* send callbacks to the client when {@link TerminalSession} changes or for sending other
|
||||
* back data to the client like logs.
|
||||
*/
|
||||
public interface TerminalSessionClient {
|
||||
|
||||
void onTextChanged(@NonNull TerminalSession changedSession);
|
||||
|
||||
void onTitleChanged(@NonNull TerminalSession changedSession);
|
||||
|
||||
void onSessionFinished(@NonNull TerminalSession finishedSession);
|
||||
|
||||
void onCopyTextToClipboard(@NonNull TerminalSession session, String text);
|
||||
|
||||
void onPasteTextFromClipboard(@Nullable TerminalSession session);
|
||||
|
||||
void onBell(@NonNull TerminalSession session);
|
||||
|
||||
void onColorsChanged(@NonNull TerminalSession session);
|
||||
|
||||
void onTerminalCursorStateChange(boolean state);
|
||||
|
||||
void setTerminalShellPid(@NonNull TerminalSession session, int pid);
|
||||
|
||||
|
||||
|
||||
Integer getTerminalCursorStyle();
|
||||
|
||||
|
||||
|
||||
void logError(String tag, String message);
|
||||
|
||||
void logWarn(String tag, String message);
|
||||
|
||||
void logInfo(String tag, String message);
|
||||
|
||||
void logDebug(String tag, String message);
|
||||
|
||||
void logVerbose(String tag, String message);
|
||||
|
||||
void logStackTraceWithMessage(String tag, String message, Exception e);
|
||||
|
||||
void logStackTrace(String tag, Exception e);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal
|
||||
* row in {@link TerminalRow#mStyle}.
|
||||
* </p>
|
||||
* <p>
|
||||
* The bit layout is:
|
||||
* </p>
|
||||
* - 16 flags (11 currently used).
|
||||
* - 24 for foreground color (only 9 first bits if a color index).
|
||||
* - 24 for background color (only 9 first bits if a color index).
|
||||
*/
|
||||
public final class TextStyle {
|
||||
|
||||
public final static int CHARACTER_ATTRIBUTE_BOLD = 1;
|
||||
public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1;
|
||||
public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2;
|
||||
public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3;
|
||||
public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4;
|
||||
public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5;
|
||||
public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6;
|
||||
/**
|
||||
* The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable.
|
||||
* <p>
|
||||
* This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that
|
||||
* come after it as erasable from the screen.
|
||||
* </p>
|
||||
*/
|
||||
public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7;
|
||||
/** Dim colors. Also known as faint or half intensity. */
|
||||
public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8;
|
||||
/** If true (24-bit) color is used for the cell for foreground. */
|
||||
private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9;
|
||||
/** If true (24-bit) color is used for the cell for foreground. */
|
||||
private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10;
|
||||
|
||||
public final static int COLOR_INDEX_FOREGROUND = 256;
|
||||
public final static int COLOR_INDEX_BACKGROUND = 257;
|
||||
public final static int COLOR_INDEX_CURSOR = 258;
|
||||
|
||||
/** The 256 standard color entries and the three special (foreground, background and cursor) ones. */
|
||||
public final static int NUM_INDEXED_COLORS = 259;
|
||||
|
||||
/** Normal foreground and background colors and no effects. */
|
||||
final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0);
|
||||
|
||||
static long encode(int foreColor, int backColor, int effect) {
|
||||
long result = effect & 0b111111111;
|
||||
if ((0xff000000 & foreColor) == 0xff000000) {
|
||||
// 24-bit color.
|
||||
result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L);
|
||||
} else {
|
||||
// Indexed color.
|
||||
result |= (foreColor & 0b111111111L) << 40;
|
||||
}
|
||||
if ((0xff000000 & backColor) == 0xff000000) {
|
||||
// 24-bit color.
|
||||
result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L);
|
||||
} else {
|
||||
// Indexed color.
|
||||
result |= (backColor & 0b111111111L) << 16L;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int decodeForeColor(long style) {
|
||||
if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) {
|
||||
return (int) ((style >>> 40) & 0b111111111L);
|
||||
} else {
|
||||
return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static int decodeBackColor(long style) {
|
||||
if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) {
|
||||
return (int) ((style >>> 16) & 0b111111111L);
|
||||
} else {
|
||||
return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL);
|
||||
}
|
||||
}
|
||||
|
||||
public static int decodeEffect(long style) {
|
||||
return (int) (style & 0b11111111111);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/**
|
||||
* Implementation of wcwidth(3) for Unicode 15.
|
||||
*
|
||||
* Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* Must be kept in sync with the following:
|
||||
* https://github.com/termux/wcwidth
|
||||
* https://github.com/termux/libandroid-support
|
||||
* https://github.com/termux/termux-packages/tree/master/packages/libandroid-support
|
||||
*/
|
||||
public final class WcWidth {
|
||||
|
||||
// From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
|
||||
// from https://github.com/jquast/wcwidth/pull/64
|
||||
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
|
||||
private static final int[][] ZERO_WIDTH = {
|
||||
{0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le
|
||||
{0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli
|
||||
{0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg
|
||||
{0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe
|
||||
{0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
|
||||
{0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
|
||||
{0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
|
||||
{0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra
|
||||
{0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below
|
||||
{0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip
|
||||
{0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen
|
||||
{0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda
|
||||
{0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon
|
||||
{0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem
|
||||
{0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip
|
||||
{0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh
|
||||
{0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun
|
||||
{0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot
|
||||
{0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan
|
||||
{0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh
|
||||
{0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A
|
||||
{0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
|
||||
{0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
|
||||
{0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark
|
||||
{0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M
|
||||
{0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S
|
||||
{0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara
|
||||
{0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
|
||||
{0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta
|
||||
{0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
|
||||
{0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama
|
||||
{0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
|
||||
{0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
|
||||
{0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu
|
||||
{0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta
|
||||
{0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
|
||||
{0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama
|
||||
{0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
|
||||
{0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark
|
||||
{0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
|
||||
{0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
|
||||
{0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
|
||||
{0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
|
||||
{0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
|
||||
{0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
|
||||
{0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak
|
||||
{0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
|
||||
{0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara
|
||||
{0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta
|
||||
{0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
|
||||
{0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
|
||||
{0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama
|
||||
{0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
|
||||
{0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle
|
||||
{0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu
|
||||
{0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta
|
||||
{0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I
|
||||
{0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic
|
||||
{0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama
|
||||
{0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark
|
||||
{0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic
|
||||
{0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara
|
||||
{0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
|
||||
{0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama
|
||||
{0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca
|
||||
{0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An
|
||||
{0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta
|
||||
{0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
|
||||
{0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
|
||||
{0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama
|
||||
{0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark
|
||||
{0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
|
||||
{0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu
|
||||
{0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta
|
||||
{0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I
|
||||
{0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E
|
||||
{0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama
|
||||
{0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
|
||||
{0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin
|
||||
{0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular
|
||||
{0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
|
||||
{0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama
|
||||
{0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
|
||||
{0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu
|
||||
{0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
|
||||
{0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
|
||||
{0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
|
||||
{0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a
|
||||
{0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu
|
||||
{0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan
|
||||
{0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
|
||||
{0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo
|
||||
{0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil)
|
||||
{0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig
|
||||
{0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
|
||||
{0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
|
||||
{0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
|
||||
{0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
|
||||
{0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
|
||||
{0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
|
||||
{0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
|
||||
{0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter
|
||||
{0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
|
||||
{0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
|
||||
{0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
|
||||
{0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat
|
||||
{0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
|
||||
{0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
|
||||
{0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
|
||||
{0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
|
||||
{0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S
|
||||
{0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
|
||||
{0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
|
||||
{0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
|
||||
{0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin
|
||||
{0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama
|
||||
{0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U
|
||||
{0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U
|
||||
{0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
|
||||
{0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
|
||||
{0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
|
||||
{0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit
|
||||
{0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
|
||||
{0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan
|
||||
{0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation
|
||||
{0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation
|
||||
{0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
|
||||
{0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
|
||||
{0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U
|
||||
{0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O
|
||||
{0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv
|
||||
{0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i
|
||||
{0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U
|
||||
{0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
|
||||
{0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
|
||||
{0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
|
||||
{0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
|
||||
{0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
|
||||
{0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
|
||||
{0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
|
||||
{0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt
|
||||
{0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le
|
||||
{0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang
|
||||
{0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan
|
||||
{0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
|
||||
{0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L
|
||||
{0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
|
||||
{0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol
|
||||
{0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
|
||||
{0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
|
||||
{0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
|
||||
{0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign
|
||||
{0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi
|
||||
{0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
|
||||
{0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
|
||||
{0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H
|
||||
{0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
|
||||
{0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta
|
||||
{0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha
|
||||
{0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
|
||||
{0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
|
||||
{0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak
|
||||
{0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above
|
||||
{0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A
|
||||
{0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea
|
||||
{0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above
|
||||
{0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu
|
||||
{0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine
|
||||
{0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette
|
||||
{0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton
|
||||
{0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag
|
||||
{0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous
|
||||
{0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer
|
||||
{0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette
|
||||
{0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
|
||||
{0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
|
||||
{0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
|
||||
{0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
|
||||
{0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
|
||||
{0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern
|
||||
{0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi
|
||||
{0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig
|
||||
{0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay
|
||||
{0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
|
||||
{0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R
|
||||
{0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar
|
||||
{0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
|
||||
{0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
|
||||
{0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign
|
||||
{0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
|
||||
{0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
|
||||
{0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue
|
||||
{0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa
|
||||
{0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
|
||||
{0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
|
||||
{0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
|
||||
{0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang
|
||||
{0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U
|
||||
{0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia
|
||||
{0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
|
||||
{0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
|
||||
{0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
|
||||
{0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama
|
||||
{0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
|
||||
{0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
|
||||
{0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
|
||||
{0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
|
||||
{0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16
|
||||
{0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo
|
||||
{0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
|
||||
{0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M
|
||||
{0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let
|
||||
{0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
|
||||
{0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
|
||||
{0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
|
||||
{0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
|
||||
{0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama
|
||||
{0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation
|
||||
{0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas
|
||||
{0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M
|
||||
{0x10efd, 0x10eff}, // (nil) ..(nil)
|
||||
{0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke
|
||||
{0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two
|
||||
{0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
|
||||
{0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama
|
||||
{0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi
|
||||
{0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta
|
||||
{0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara
|
||||
{0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
|
||||
{0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta
|
||||
{0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali
|
||||
{0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga
|
||||
{0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
|
||||
{0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa
|
||||
{0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta
|
||||
{0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara
|
||||
{0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O
|
||||
{0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe
|
||||
{0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca
|
||||
{0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
|
||||
{0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara
|
||||
{0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda
|
||||
{0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun
|
||||
{0x11241, 0x11241}, // (nil) ..(nil)
|
||||
{0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
|
||||
{0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
|
||||
{0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu
|
||||
{0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta
|
||||
{0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
|
||||
{0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit
|
||||
{0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter
|
||||
{0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai
|
||||
{0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara
|
||||
{0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta
|
||||
{0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark
|
||||
{0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
|
||||
{0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short
|
||||
{0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
|
||||
{0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta
|
||||
{0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
|
||||
{0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara
|
||||
{0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta
|
||||
{0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
|
||||
{0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai
|
||||
{0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara
|
||||
{0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra
|
||||
{0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara
|
||||
{0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
|
||||
{0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au
|
||||
{0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta
|
||||
{0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
|
||||
{0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
|
||||
{0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer
|
||||
{0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara
|
||||
{0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta
|
||||
{0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab
|
||||
{0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama
|
||||
{0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta
|
||||
{0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V
|
||||
{0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A
|
||||
{0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama
|
||||
{0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L
|
||||
{0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An
|
||||
{0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster
|
||||
{0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin
|
||||
{0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe
|
||||
{0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar
|
||||
{0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara
|
||||
{0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner
|
||||
{0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc
|
||||
{0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara
|
||||
{0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama
|
||||
{0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter
|
||||
{0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa
|
||||
{0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E
|
||||
{0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu
|
||||
{0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
|
||||
{0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
|
||||
{0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
|
||||
{0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama
|
||||
{0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara
|
||||
{0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign
|
||||
{0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv
|
||||
{0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama
|
||||
{0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U
|
||||
{0x11f00, 0x11f01}, // (nil) ..(nil)
|
||||
{0x11f36, 0x11f3a}, // (nil) ..(nil)
|
||||
{0x11f40, 0x11f40}, // (nil) ..(nil)
|
||||
{0x11f42, 0x11f42}, // (nil) ..(nil)
|
||||
{0x13440, 0x13440}, // (nil) ..(nil)
|
||||
{0x13447, 0x13455}, // (nil) ..(nil)
|
||||
{0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High
|
||||
{0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
|
||||
{0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi
|
||||
{0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below
|
||||
{0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill
|
||||
{0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark
|
||||
{0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark
|
||||
{0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie
|
||||
{0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining
|
||||
{0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining
|
||||
{0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining
|
||||
{0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining
|
||||
{0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical
|
||||
{0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking
|
||||
{0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement
|
||||
{0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T
|
||||
{0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea
|
||||
{0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie
|
||||
{0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod
|
||||
{0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
{0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
{0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
{0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
{0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
{0x1e08f, 0x1e08f}, // (nil) ..(nil)
|
||||
{0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T
|
||||
{0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone
|
||||
{0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini
|
||||
{0x1e4ec, 0x1e4ef}, // (nil) ..(nil)
|
||||
{0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining
|
||||
{0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta
|
||||
{0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256
|
||||
};
|
||||
|
||||
// https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
|
||||
// from https://github.com/jquast/wcwidth/pull/64
|
||||
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
|
||||
private static final int[][] WIDE_EASTASIAN = {
|
||||
{0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler
|
||||
{0x0231a, 0x0231b}, // Watch ..Hourglass
|
||||
{0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra
|
||||
{0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub
|
||||
{0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock
|
||||
{0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S
|
||||
{0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar
|
||||
{0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage
|
||||
{0x02648, 0x02653}, // Aries ..Pisces
|
||||
{0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol
|
||||
{0x02693, 0x02693}, // Anchor ..Anchor
|
||||
{0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign
|
||||
{0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle
|
||||
{0x026bd, 0x026be}, // Soccer Ball ..Baseball
|
||||
{0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud
|
||||
{0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus
|
||||
{0x026d4, 0x026d4}, // No Entry ..No Entry
|
||||
{0x026ea, 0x026ea}, // Church ..Church
|
||||
{0x026f2, 0x026f3}, // Fountain ..Flag In Hole
|
||||
{0x026f5, 0x026f5}, // Sailboat ..Sailboat
|
||||
{0x026fa, 0x026fa}, // Tent ..Tent
|
||||
{0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump
|
||||
{0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark
|
||||
{0x0270a, 0x0270b}, // Raised Fist ..Raised Hand
|
||||
{0x02728, 0x02728}, // Sparkles ..Sparkles
|
||||
{0x0274c, 0x0274c}, // Cross Mark ..Cross Mark
|
||||
{0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M
|
||||
{0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O
|
||||
{0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S
|
||||
{0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign
|
||||
{0x027b0, 0x027b0}, // Curly Loop ..Curly Loop
|
||||
{0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop
|
||||
{0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square
|
||||
{0x02b50, 0x02b50}, // White Medium Star ..White Medium Star
|
||||
{0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle
|
||||
{0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap
|
||||
{0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified
|
||||
{0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute
|
||||
{0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description
|
||||
{0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In
|
||||
{0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke
|
||||
{0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto
|
||||
{0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn
|
||||
{0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae
|
||||
{0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q
|
||||
{0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha
|
||||
{0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto
|
||||
{0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d
|
||||
{0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr
|
||||
{0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke
|
||||
{0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
|
||||
{0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih
|
||||
{0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil)
|
||||
{0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve
|
||||
{0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop
|
||||
{0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign
|
||||
{0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At
|
||||
{0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa
|
||||
{0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign
|
||||
{0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill
|
||||
{0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea
|
||||
{0x17000, 0x187f7}, // (nil) ..(nil)
|
||||
{0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char
|
||||
{0x18d00, 0x18d08}, // (nil) ..(nil)
|
||||
{0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T
|
||||
{0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N
|
||||
{0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N
|
||||
{0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic
|
||||
{0x1b132, 0x1b132}, // (nil) ..(nil)
|
||||
{0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo
|
||||
{0x1b155, 0x1b155}, // (nil) ..(nil)
|
||||
{0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N
|
||||
{0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb
|
||||
{0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
|
||||
{0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker
|
||||
{0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab
|
||||
{0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs
|
||||
{0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa
|
||||
{0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo
|
||||
{0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed
|
||||
{0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept
|
||||
{0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai
|
||||
{0x1f300, 0x1f320}, // Cyclone ..Shooting Star
|
||||
{0x1f32d, 0x1f335}, // Hot Dog ..Cactus
|
||||
{0x1f337, 0x1f37c}, // Tulip ..Baby Bottle
|
||||
{0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap
|
||||
{0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer
|
||||
{0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And
|
||||
{0x1f3e0, 0x1f3f0}, // House Building ..European Castle
|
||||
{0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag
|
||||
{0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints
|
||||
{0x1f440, 0x1f440}, // Eyes ..Eyes
|
||||
{0x1f442, 0x1f4fc}, // Ear ..Videocassette
|
||||
{0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red
|
||||
{0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch
|
||||
{0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty
|
||||
{0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing
|
||||
{0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be
|
||||
{0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart
|
||||
{0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands
|
||||
{0x1f680, 0x1f6c5}, // Rocket ..Left Luggage
|
||||
{0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation
|
||||
{0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley
|
||||
{0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator
|
||||
{0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy
|
||||
{0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving
|
||||
{0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate
|
||||
{0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square
|
||||
{0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign
|
||||
{0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer
|
||||
{0x1f93c, 0x1f945}, // Wrestlers ..Goal Net
|
||||
{0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet
|
||||
{0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch
|
||||
{0x1fa80, 0x1fa88}, // Yo-yo ..(nil)
|
||||
{0x1fa90, 0x1fabd}, // Ringed Planet ..(nil)
|
||||
{0x1fabf, 0x1fac5}, // (nil) ..Person With Crown
|
||||
{0x1face, 0x1fadb}, // (nil) ..(nil)
|
||||
{0x1fae0, 0x1fae8}, // Melting Face ..(nil)
|
||||
{0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil)
|
||||
{0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil)
|
||||
{0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil)
|
||||
};
|
||||
|
||||
|
||||
private static boolean intable(int[][] table, int c) {
|
||||
// First quick check f|| Latin1 etc. characters.
|
||||
if (c < table[0][0]) return false;
|
||||
|
||||
// Binary search in table.
|
||||
int bot = 0;
|
||||
int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1);
|
||||
while (top >= bot) {
|
||||
int mid = (bot + top) / 2;
|
||||
if (table[mid][1] < c) {
|
||||
bot = mid + 1;
|
||||
} else if (table[mid][0] > c) {
|
||||
top = mid - 1;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return the terminal display width of a code point: 0, 1 || 2. */
|
||||
public static int width(int ucs) {
|
||||
if (ucs == 0 ||
|
||||
ucs == 0x034F ||
|
||||
(0x200B <= ucs && ucs <= 0x200F) ||
|
||||
ucs == 0x2028 ||
|
||||
ucs == 0x2029 ||
|
||||
(0x202A <= ucs && ucs <= 0x202E) ||
|
||||
(0x2060 <= ucs && ucs <= 0x2063)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// C0/C1 control characters
|
||||
// Termux change: Return 0 instead of -1.
|
||||
if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0;
|
||||
|
||||
// combining characters with zero width
|
||||
if (intable(ZERO_WIDTH, ucs)) return 0;
|
||||
|
||||
return intable(WIDE_EASTASIAN, ucs) ? 2 : 1;
|
||||
}
|
||||
|
||||
/** The width at an index position in a java char array. */
|
||||
public static int width(char[] chars, int index) {
|
||||
char c = chars[index];
|
||||
return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
LOCAL_PATH:= $(call my-dir)
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE:= libtermux
|
||||
LOCAL_SRC_FILES:= termux.c
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
@@ -0,0 +1,214 @@
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <jni.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define TERMUX_UNUSED(x) x __attribute__((__unused__))
|
||||
#ifdef __APPLE__
|
||||
# define LACKS_PTSNAME_R
|
||||
#endif
|
||||
|
||||
static int throw_runtime_exception(JNIEnv* env, char const* message)
|
||||
{
|
||||
jclass exClass = (*env)->FindClass(env, "java/lang/RuntimeException");
|
||||
(*env)->ThrowNew(env, exClass, message);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int create_subprocess(JNIEnv* env,
|
||||
char const* cmd,
|
||||
char const* cwd,
|
||||
char* const argv[],
|
||||
char** envp,
|
||||
int* pProcessId,
|
||||
jint rows,
|
||||
jint columns)
|
||||
{
|
||||
int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
|
||||
if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx");
|
||||
|
||||
#ifdef LACKS_PTSNAME_R
|
||||
char* devname;
|
||||
#else
|
||||
char devname[64];
|
||||
#endif
|
||||
if (grantpt(ptm) || unlockpt(ptm) ||
|
||||
#ifdef LACKS_PTSNAME_R
|
||||
(devname = ptsname(ptm)) == NULL
|
||||
#else
|
||||
ptsname_r(ptm, devname, sizeof(devname))
|
||||
#endif
|
||||
) {
|
||||
return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx");
|
||||
}
|
||||
|
||||
// Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display.
|
||||
struct termios tios;
|
||||
tcgetattr(ptm, &tios);
|
||||
tios.c_iflag |= IUTF8;
|
||||
tios.c_iflag &= ~(IXON | IXOFF);
|
||||
tcsetattr(ptm, TCSANOW, &tios);
|
||||
|
||||
/** Set initial winsize. */
|
||||
struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns };
|
||||
ioctl(ptm, TIOCSWINSZ, &sz);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
return throw_runtime_exception(env, "Fork failed");
|
||||
} else if (pid > 0) {
|
||||
*pProcessId = (int) pid;
|
||||
return ptm;
|
||||
} else {
|
||||
// Clear signals which the Android java process may have blocked:
|
||||
sigset_t signals_to_unblock;
|
||||
sigfillset(&signals_to_unblock);
|
||||
sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0);
|
||||
|
||||
close(ptm);
|
||||
setsid();
|
||||
|
||||
int pts = open(devname, O_RDWR);
|
||||
if (pts < 0) exit(-1);
|
||||
|
||||
dup2(pts, 0);
|
||||
dup2(pts, 1);
|
||||
dup2(pts, 2);
|
||||
|
||||
DIR* self_dir = opendir("/proc/self/fd");
|
||||
if (self_dir != NULL) {
|
||||
int self_dir_fd = dirfd(self_dir);
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(self_dir)) != NULL) {
|
||||
int fd = atoi(entry->d_name);
|
||||
if (fd > 2 && fd != self_dir_fd) close(fd);
|
||||
}
|
||||
closedir(self_dir);
|
||||
}
|
||||
|
||||
clearenv();
|
||||
if (envp) for (; *envp; ++envp) putenv(*envp);
|
||||
|
||||
if (chdir(cwd) != 0) {
|
||||
char* error_message;
|
||||
// No need to free asprintf()-allocated memory since doing execvp() or exit() below.
|
||||
if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1) error_message = "chdir()";
|
||||
perror(error_message);
|
||||
fflush(stderr);
|
||||
}
|
||||
execvp(cmd, argv);
|
||||
// Show terminal output about failing exec() call:
|
||||
char* error_message;
|
||||
if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1) error_message = "exec()";
|
||||
perror(error_message);
|
||||
_exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
JNIEnv* env,
|
||||
jclass TERMUX_UNUSED(clazz),
|
||||
jstring cmd,
|
||||
jstring cwd,
|
||||
jobjectArray args,
|
||||
jobjectArray envVars,
|
||||
jintArray processIdArray,
|
||||
jint rows,
|
||||
jint columns)
|
||||
{
|
||||
jsize size = args ? (*env)->GetArrayLength(env, args) : 0;
|
||||
char** argv = NULL;
|
||||
if (size > 0) {
|
||||
argv = (char**) malloc((size + 1) * sizeof(char*));
|
||||
if (!argv) return throw_runtime_exception(env, "Couldn't allocate argv array");
|
||||
for (int i = 0; i < size; ++i) {
|
||||
jstring arg_java_string = (jstring) (*env)->GetObjectArrayElement(env, args, i);
|
||||
char const* arg_utf8 = (*env)->GetStringUTFChars(env, arg_java_string, NULL);
|
||||
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
|
||||
argv[i] = strdup(arg_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
|
||||
}
|
||||
argv[size] = NULL;
|
||||
}
|
||||
|
||||
size = envVars ? (*env)->GetArrayLength(env, envVars) : 0;
|
||||
char** envp = NULL;
|
||||
if (size > 0) {
|
||||
envp = (char**) malloc((size + 1) * sizeof(char *));
|
||||
if (!envp) return throw_runtime_exception(env, "malloc() for envp array failed");
|
||||
for (int i = 0; i < size; ++i) {
|
||||
jstring env_java_string = (jstring) (*env)->GetObjectArrayElement(env, envVars, i);
|
||||
char const* env_utf8 = (*env)->GetStringUTFChars(env, env_java_string, 0);
|
||||
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
|
||||
envp[i] = strdup(env_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
|
||||
}
|
||||
envp[size] = NULL;
|
||||
}
|
||||
|
||||
int procId = 0;
|
||||
char const* cmd_cwd = (*env)->GetStringUTFChars(env, cwd, NULL);
|
||||
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
|
||||
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
|
||||
|
||||
if (argv) {
|
||||
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
|
||||
free(argv);
|
||||
}
|
||||
if (envp) {
|
||||
for (char** tmp = envp; *tmp; ++tmp) free(*tmp);
|
||||
free(envp);
|
||||
}
|
||||
|
||||
int* pProcId = (int*) (*env)->GetPrimitiveArrayCritical(env, processIdArray, NULL);
|
||||
if (!pProcId) return throw_runtime_exception(env, "JNI call GetPrimitiveArrayCritical(processIdArray, &isCopy) failed");
|
||||
|
||||
*pProcId = procId;
|
||||
(*env)->ReleasePrimitiveArrayCritical(env, processIdArray, pProcId, 0);
|
||||
|
||||
return ptm;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyWindowSize(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd, jint rows, jint cols)
|
||||
{
|
||||
struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols };
|
||||
ioctl(fd, TIOCSWINSZ, &sz);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyUTF8Mode(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd)
|
||||
{
|
||||
struct termios tios;
|
||||
tcgetattr(fd, &tios);
|
||||
if ((tios.c_iflag & IUTF8) == 0) {
|
||||
tios.c_iflag |= IUTF8;
|
||||
tcsetattr(fd, TCSANOW, &tios);
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_waitFor(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint pid)
|
||||
{
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
if (WIFEXITED(status)) {
|
||||
return WEXITSTATUS(status);
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
return -WTERMSIG(status);
|
||||
} else {
|
||||
// Should never happen - waitpid(2) says "One of the first three macros will evaluate to a non-zero (true) value".
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_close(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fileDescriptor)
|
||||
{
|
||||
close(fileDescriptor);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
public class ByteQueueTest extends TestCase {
|
||||
|
||||
private static void assertArrayEquals(byte[] expected, byte[] actual) {
|
||||
if (expected.length != actual.length) {
|
||||
fail("Difference array length");
|
||||
}
|
||||
for (int i = 0; i < expected.length; i++) {
|
||||
if (expected[i] != actual[i]) {
|
||||
fail("Inequals at index=" + i + ", expected=" + (int) expected[i] + ", actual=" + (int) actual[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void testCompleteWrites() throws Exception {
|
||||
ByteQueue q = new ByteQueue(10);
|
||||
assertTrue(q.write(new byte[]{1, 2, 3}, 0, 3));
|
||||
|
||||
byte[] arr = new byte[10];
|
||||
assertEquals(3, q.read(arr, true));
|
||||
assertArrayEquals(new byte[]{1, 2, 3}, new byte[]{arr[0], arr[1], arr[2]});
|
||||
|
||||
assertTrue(q.write(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0, 10));
|
||||
assertEquals(10, q.read(arr, true));
|
||||
assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, arr);
|
||||
}
|
||||
|
||||
public void testQueueWraparound() throws Exception {
|
||||
ByteQueue q = new ByteQueue(10);
|
||||
|
||||
byte[] origArray = new byte[]{1, 2, 3, 4, 5, 6};
|
||||
byte[] readArray = new byte[origArray.length];
|
||||
for (int i = 0; i < 20; i++) {
|
||||
q.write(origArray, 0, origArray.length);
|
||||
assertEquals(origArray.length, q.read(readArray, true));
|
||||
assertArrayEquals(origArray, readArray);
|
||||
}
|
||||
}
|
||||
|
||||
public void testWriteNotesClosing() throws Exception {
|
||||
ByteQueue q = new ByteQueue(10);
|
||||
q.close();
|
||||
assertFalse(q.write(new byte[]{1, 2, 3}, 0, 3));
|
||||
}
|
||||
|
||||
public void testReadNonBlocking() throws Exception {
|
||||
ByteQueue q = new ByteQueue(10);
|
||||
assertEquals(0, q.read(new byte[128], false));
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/** "\033[" is the Control Sequence Introducer char sequence (CSI). */
|
||||
public class ControlSequenceIntroducerTest extends TerminalTestCase {
|
||||
|
||||
/** CSI Ps P Scroll down Ps lines (default = 1) (SD). */
|
||||
public void testCsiT() {
|
||||
withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[2Tyo\r\nA\r\nB").assertLinesAre(" ", " ", "1 ", "2 yo", "A ",
|
||||
"Bi ");
|
||||
// Default value (1):
|
||||
withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[Tyo\r\nA\r\nB").assertLinesAre(" ", "1 ", "2 ", "3 yo", "Ai ",
|
||||
"B ");
|
||||
}
|
||||
|
||||
/** CSI Ps S Scroll up Ps lines (default = 1) (SU). */
|
||||
public void testCsiS() {
|
||||
// The behaviour here is a bit inconsistent between terminals - this is how the OS X Terminal.app does it:
|
||||
withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[2Sy").assertLinesAre("3 ", "hi ", " ", " y");
|
||||
// Default value (1):
|
||||
withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[Sy").assertLinesAre("2 ", "3 ", "hi ", " y");
|
||||
}
|
||||
|
||||
/** CSI Ps X Erase Ps Character(s) (default = 1) (ECH). */
|
||||
public void testCsiX() {
|
||||
// See https://code.google.com/p/chromium/issues/detail?id=212712 where test was extraced from.
|
||||
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[X").assertLinesAre("abcdefg ijkl ", " ");
|
||||
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[1X").assertLinesAre("abcdefg ijkl ", " ");
|
||||
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[2X").assertLinesAre("abcdefg jkl ", " ");
|
||||
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[20X").assertLinesAre("abcdefg ", " ");
|
||||
}
|
||||
|
||||
/** CSI Pm m Set SGR parameter(s) from semicolon-separated list Pm. */
|
||||
public void testCsiSGRParameters() {
|
||||
// Set more parameters (19) than supported (16). Additional parameters should be silently consumed.
|
||||
withTerminalSized(3, 2).enterString("\033[0;38;2;255;255;255;48;2;0;0;0;1;2;3;4;5;7;8;9mabc").assertLinesAre("abc", " ");
|
||||
}
|
||||
|
||||
/** CSI Ps b Repeat the preceding graphic character Ps times (REP). */
|
||||
public void testRepeat() {
|
||||
withTerminalSized(3, 2).enterString("a\033[b").assertLinesAre("aa ", " ");
|
||||
withTerminalSized(3, 2).enterString("a\033[2b").assertLinesAre("aaa", " ");
|
||||
// When no char has been output we ignore REP:
|
||||
withTerminalSized(3, 2).enterString("\033[b").assertLinesAre(" ", " ");
|
||||
// This shows that REP outputs the last emitted code point and not the one relative to the
|
||||
// current cursor position:
|
||||
withTerminalSized(5, 2).enterString("abcde\033[2G\033[2b\n").assertLinesAre("aeede", " ");
|
||||
}
|
||||
|
||||
/** CSI 3 J Clear scrollback (xterm, libvte; non-standard). */
|
||||
public void testCsi3J() {
|
||||
withTerminalSized(3, 2).enterString("a\r\nb\r\nc\r\nd");
|
||||
assertEquals("a\nb\nc\nd", mTerminal.getScreen().getTranscriptText());
|
||||
enterString("\033[3J");
|
||||
assertEquals("c\nd", mTerminal.getScreen().getTranscriptText());
|
||||
|
||||
withTerminalSized(3, 2).enterString("Lorem_ipsum");
|
||||
assertEquals("Lorem_ipsum", mTerminal.getScreen().getTranscriptText());
|
||||
enterString("\033[3J");
|
||||
assertEquals("ipsum", mTerminal.getScreen().getTranscriptText());
|
||||
|
||||
withTerminalSized(3, 2).enterString("w\r\nx\r\ny\r\nz\033[?1049h\033[3J\033[?1049l");
|
||||
assertEquals("y\nz", mTerminal.getScreen().getTranscriptText());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import org.junit.Assert;
|
||||
|
||||
public class CursorAndScreenTest extends TerminalTestCase {
|
||||
|
||||
public void testDeleteLinesKeepsStyles() {
|
||||
int cols = 5, rows = 5;
|
||||
withTerminalSized(cols, rows);
|
||||
for (int row = 0; row < 5; row++) {
|
||||
for (int col = 0; col < 5; col++) {
|
||||
// Foreground color to col, background to row:
|
||||
enterString("\033[38;5;" + col + "m");
|
||||
enterString("\033[48;5;" + row + "m");
|
||||
enterString(Character.toString((char) ('A' + col + row * 5)));
|
||||
}
|
||||
}
|
||||
assertLinesAre("ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY");
|
||||
for (int row = 0; row < 5; row++) {
|
||||
for (int col = 0; col < 5; col++) {
|
||||
long s = getStyleAt(row, col);
|
||||
Assert.assertEquals(col, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(row, TextStyle.decodeBackColor(s));
|
||||
}
|
||||
}
|
||||
// "${CSI}H" - place cursor at 1,1, then "${CSI}2M" to delete two lines.
|
||||
enterString("\033[H\033[2M");
|
||||
assertLinesAre("KLMNO", "PQRST", "UVWXY", " ", " ");
|
||||
for (int row = 0; row < 3; row++) {
|
||||
for (int col = 0; col < 5; col++) {
|
||||
long s = getStyleAt(row, col);
|
||||
Assert.assertEquals(col, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(row + 2, TextStyle.decodeBackColor(s));
|
||||
}
|
||||
}
|
||||
// Set default fg and background for the new blank lines:
|
||||
enterString("\033[38;5;98m");
|
||||
enterString("\033[48;5;99m");
|
||||
// "${CSI}B" to go down one line, then "${CSI}2L" to insert two lines:
|
||||
enterString("\033[B\033[2L");
|
||||
assertLinesAre("KLMNO", " ", " ", "PQRST", "UVWXY");
|
||||
for (int row = 0; row < 5; row++) {
|
||||
for (int col = 0; col < 5; col++) {
|
||||
int wantedForeground = (row == 1 || row == 2) ? 98 : col;
|
||||
int wantedBackground = (row == 1 || row == 2) ? 99 : (row == 0 ? 2 : row);
|
||||
long s = getStyleAt(row, col);
|
||||
Assert.assertEquals(wantedForeground, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(wantedBackground, TextStyle.decodeBackColor(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void testDeleteCharacters() {
|
||||
withTerminalSized(5, 2).enterString("枝ce").assertLinesAre("枝ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("a枝ce").assertLinesAre("a枝ce", " ");
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[P").assertLinesAre("ice ", " ");
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[2P").assertLinesAre("ne ", " ");
|
||||
// "${CSI}${n}P, the delete characters (DCH) sequence should cap characters to delete.
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[99P").assertLinesAre(" ", " ");
|
||||
// With combining char U+0302.
|
||||
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[P").assertLinesAre("ice ", " ");
|
||||
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[2G\033[2P").assertLinesAre("n\u0302e ", " ");
|
||||
// With wide 枝 char, checking that putting char at part replaces other with whitespace:
|
||||
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[Ga").assertLinesAre("a ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2Ga").assertLinesAre(" ace ", " ");
|
||||
// With wide 枝 char, deleting either part replaces other with whitespace:
|
||||
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[P").assertLinesAre(" ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[P").assertLinesAre(" ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[2P").assertLinesAre(" e ", " ");
|
||||
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
|
||||
withTerminalSized(5, 2).enterString("a枝ce").enterString("\033[G\033[P").assertLinesAre("枝ce ", " ");
|
||||
}
|
||||
|
||||
public void testInsertMode() {
|
||||
// "${CSI}4h" enables insert mode.
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hA").assertLinesAre("Anice", " ");
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4hA").assertLinesAre("nAice", " ");
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hABC").assertLinesAre("ABCni", " ");
|
||||
// With combining char U+0302.
|
||||
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hA").assertLinesAre("An\u0302ice", " ");
|
||||
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hAB").assertLinesAre("ABn\u0302ic", " ");
|
||||
withTerminalSized(5, 2).enterString("n\u0302ic\u0302e").enterString("\033[2G\033[4hA").assertLinesAre("n\u0302Aic\u0302e", " ");
|
||||
// ... but without insert mode, combining char should be overwritten:
|
||||
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[GA").assertLinesAre("Aice ", " ");
|
||||
// ... also with two combining:
|
||||
withTerminalSized(5, 2).enterString("n\u0302\u0302i\u0302ce").enterString("\033[GA").assertLinesAre("Ai\u0302ce ", " ");
|
||||
// ... and in last column:
|
||||
withTerminalSized(5, 2).enterString("n\u0302\u0302ice!\u0302").enterString("\033[5GA").assertLinesAre("n\u0302\u0302iceA", " ");
|
||||
withTerminalSized(5, 2).enterString("nic\u0302e!\u0302").enterString("\033[4G枝").assertLinesAre("nic\u0302枝", " ");
|
||||
withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " ");
|
||||
withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " ");
|
||||
// With wide 枝 char.
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4h枝").assertLinesAre("枝nic", " ");
|
||||
withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4h枝").assertLinesAre("n枝ic", " ");
|
||||
withTerminalSized(5, 2).enterString("n枝ce").enterString("\033[G\033[4ha").assertLinesAre("an枝c", " ");
|
||||
}
|
||||
|
||||
/** HPA—Horizontal Position Absolute (http://www.vt100.net/docs/vt510-rm/HPA) */
|
||||
public void testCursorHorizontalPositionAbsolute() {
|
||||
withTerminalSized(4, 4).enterString("ABC\033[`").assertCursorAt(0, 0);
|
||||
enterString("\033[1`").assertCursorAt(0, 0).enterString("\033[2`").assertCursorAt(0, 1);
|
||||
enterString("\r\n\033[3`").assertCursorAt(1, 2).enterString("\033[22`").assertCursorAt(1, 3);
|
||||
// Enable and configure right and left margins, first without origin mode:
|
||||
enterString("\033[?69h\033[2;3s\033[`").assertCursorAt(0, 0).enterString("\033[22`").assertCursorAt(0, 3);
|
||||
// .. now with origin mode:
|
||||
enterString("\033[?6h\033[`").assertCursorAt(0, 1).enterString("\033[22`").assertCursorAt(0, 2);
|
||||
}
|
||||
|
||||
public void testCursorForward() {
|
||||
// "${CSI}${N:=1}C" moves cursor forward N columns:
|
||||
withTerminalSized(6, 2).enterString("A\033[CB\033[2CC").assertLinesAre("A B C", " ");
|
||||
// If an attempt is made to move the cursor to the right of the right margin, the cursor stops at the right margin:
|
||||
withTerminalSized(6, 2).enterString("A\033[44CB").assertLinesAre("A B", " ");
|
||||
// Enable right margin and verify that CUF ends at the set right margin:
|
||||
withTerminalSized(6, 2).enterString("\033[?69h\033[1;3s\033[44CAB").assertLinesAre(" A ", "B ");
|
||||
}
|
||||
|
||||
public void testCursorBack() {
|
||||
// "${CSI}${N:=1}D" moves cursor back N columns:
|
||||
withTerminalSized(3, 2).enterString("A\033[DB").assertLinesAre("B ", " ");
|
||||
withTerminalSized(3, 2).enterString("AB\033[2DC").assertLinesAre("CB ", " ");
|
||||
// If an attempt is made to move the cursor to the left of the left margin, the cursor stops at the left margin:
|
||||
withTerminalSized(3, 2).enterString("AB\033[44DC").assertLinesAre("CB ", " ");
|
||||
// Enable left margin and verify that CUB ends at the set left margin:
|
||||
withTerminalSized(6, 2).enterString("ABCD\033[?69h\033[2;6s\033[44DE").assertLinesAre("AECD ", " ");
|
||||
}
|
||||
|
||||
public void testCursorUp() {
|
||||
// "${CSI}${N:=1}A" moves cursor up N rows:
|
||||
withTerminalSized(3, 3).enterString("ABCDEFG\033[AH").assertLinesAre("ABC", "DHF", "G ");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFG\033[2AH").assertLinesAre("AHC", "DEF", "G ");
|
||||
// If an attempt is made to move the cursor above the top margin, the cursor stops at the top margin:
|
||||
withTerminalSized(3, 3).enterString("ABCDEFG\033[44AH").assertLinesAre("AHC", "DEF", "G ");
|
||||
}
|
||||
|
||||
public void testCursorDown() {
|
||||
// "${CSI}${N:=1}B" moves cursor down N rows:
|
||||
withTerminalSized(3, 3).enterString("AB\033[BC").assertLinesAre("AB ", " C", " ");
|
||||
withTerminalSized(3, 3).enterString("AB\033[2BC").assertLinesAre("AB ", " ", " C");
|
||||
// If an attempt is made to move the cursor below the bottom margin, the cursor stops at the bottom margin:
|
||||
withTerminalSized(3, 3).enterString("AB\033[44BC").assertLinesAre("AB ", " ", " C");
|
||||
}
|
||||
|
||||
public void testReportCursorPosition() {
|
||||
withTerminalSized(10, 10);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
for (int j = 0; j < 10; j++) {
|
||||
enterString("\033[" + (i + 1) + ";" + (j + 1) + "H"); // CUP cursor position.
|
||||
assertCursorAt(i, j);
|
||||
// Device Status Report (DSR):
|
||||
assertEnteringStringGivesResponse("\033[6n", "\033[" + (i + 1) + ";" + (j + 1) + "R");
|
||||
// DECXCPR — Extended Cursor Position. Note that http://www.vt100.net/docs/vt510-rm/DECXCPR says
|
||||
// the response is "${CSI}${LINE};${COLUMN};${PAGE}R" while xterm (http://invisible-island.net/xterm/ctlseqs/ctlseqs.html)
|
||||
// drops the question mark. Expect xterm behaviour here.
|
||||
assertEnteringStringGivesResponse("\033[?6n", "\033[?" + (i + 1) + ";" + (j + 1) + ";1R");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* See comments on horizontal tab handling in TerminalEmulator.java.
|
||||
* <p/>
|
||||
* We do not want to color already written cells when tabbing over them.
|
||||
*/
|
||||
public void DISABLED_testHorizontalTabColorsBackground() {
|
||||
withTerminalSized(10, 3).enterString("\033[48;5;15m").enterString("\t");
|
||||
assertCursorAt(0, 8);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
int expectedColor = i < 8 ? 15 : TextStyle.COLOR_INDEX_BACKGROUND;
|
||||
assertEquals(expectedColor, TextStyle.decodeBackColor(getStyleAt(0, i)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test interactions between the cursor overflow bit and various escape sequences.
|
||||
* <p/>
|
||||
* Adapted from hterm:
|
||||
* https://chromium.googlesource.com/chromiumos/platform/assets/+/2337afa5c063127d5ce40ec7fec9b602d096df86%5E%21/#F2
|
||||
*/
|
||||
public void testClearingOfAutowrap() {
|
||||
// Fill a row with the last hyphen wrong, then run a command that
|
||||
// modifies the screen, then add a hyphen. The wrap bit should be
|
||||
// cleared, so the extra hyphen can fix the row.
|
||||
withTerminalSized(15, 6);
|
||||
|
||||
enterString("----- 1 ----X");
|
||||
enterString("\033[K-"); // EL
|
||||
|
||||
enterString("----- 2 ----X");
|
||||
enterString("\033[J-"); // ED
|
||||
|
||||
enterString("----- 3 ----X");
|
||||
enterString("\033[@-"); // ICH
|
||||
|
||||
enterString("----- 4 ----X");
|
||||
enterString("\033[P-"); // DCH
|
||||
|
||||
enterString("----- 5 ----X");
|
||||
enterString("\033[X-"); // ECH
|
||||
|
||||
// DL will delete the entire line but clear the wrap bit, so we
|
||||
// expect a hyphen at the end and nothing else.
|
||||
enterString("XXXXXXXXXXXXXXX");
|
||||
enterString("\033[M-"); // DL
|
||||
|
||||
assertLinesAre(
|
||||
"----- 1 -----",
|
||||
"----- 2 -----",
|
||||
"----- 3 -----",
|
||||
"----- 4 -----",
|
||||
"----- 5 -----",
|
||||
" -");
|
||||
}
|
||||
|
||||
public void testBackspaceAcrossWrappedLines() {
|
||||
// Backspace should not go to previous line if not auto-wrapped:
|
||||
withTerminalSized(3, 3).enterString("hi\r\n\b\byou").assertLinesAre("hi ", "you", " ");
|
||||
// Backspace should go to previous line if auto-wrapped:
|
||||
withTerminalSized(3, 3).enterString("hi y").assertLinesAre("hi ", "y ", " ").enterString("\b\b#").assertLinesAre("hi#", "y ", " ");
|
||||
// Initial backspace should do nothing:
|
||||
withTerminalSized(3, 3).enterString("\b\b\b\bhi").assertLinesAre("hi ", " ", " ");
|
||||
}
|
||||
|
||||
public void testCursorSaveRestoreLocation() {
|
||||
// DEC save/restore
|
||||
withTerminalSized(4, 2).enterString("t\0337est\r\nme\0338ry ").assertLinesAre("try ", "me ");
|
||||
// ANSI.SYS save/restore
|
||||
withTerminalSized(4, 2).enterString("t\033[sest\r\nme\033[ury ").assertLinesAre("try ", "me ");
|
||||
// Alternate screen enter/exit
|
||||
withTerminalSized(4, 2).enterString("t\033[?1049h\033[Hest\r\nme").assertLinesAre("est ", "me ").enterString("\033[?1049lry").assertLinesAre("try ", " ");
|
||||
}
|
||||
|
||||
public void testCursorSaveRestoreTextStyle() {
|
||||
long s;
|
||||
|
||||
// DEC save/restore
|
||||
withTerminalSized(4, 2).enterString("\033[31;42;4m..\0337\033[36;47;24m\0338..");
|
||||
s = getStyleAt(0, 3);
|
||||
Assert.assertEquals(1, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(2, TextStyle.decodeBackColor(s));
|
||||
Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
|
||||
|
||||
// ANSI.SYS save/restore
|
||||
withTerminalSized(4, 2).enterString("\033[31;42;4m..\033[s\033[36;47;24m\033[u..");
|
||||
s = getStyleAt(0, 3);
|
||||
Assert.assertEquals(1, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(2, TextStyle.decodeBackColor(s));
|
||||
Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
|
||||
|
||||
// Alternate screen enter/exit
|
||||
withTerminalSized(4, 2);
|
||||
enterString("\033[31;42;4m..\033[?1049h\033[H\033[36;47;24m.");
|
||||
s = getStyleAt(0, 0);
|
||||
Assert.assertEquals(6, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(7, TextStyle.decodeBackColor(s));
|
||||
Assert.assertEquals(0, TextStyle.decodeEffect(s));
|
||||
enterString("\033[?1049l..");
|
||||
s = getStyleAt(0, 3);
|
||||
Assert.assertEquals(1, TextStyle.decodeForeColor(s));
|
||||
Assert.assertEquals(2, TextStyle.decodeBackColor(s));
|
||||
Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* "CSI ? Pm h", DEC Private Mode Set (DECSET)
|
||||
* </pre>
|
||||
* <p/>
|
||||
* and
|
||||
* <p/>
|
||||
* <pre>
|
||||
* "CSI ? Pm l", DEC Private Mode Reset (DECRST)
|
||||
* </pre>
|
||||
* <p/>
|
||||
* controls various aspects of the terminal
|
||||
*/
|
||||
public class DecSetTest extends TerminalTestCase {
|
||||
|
||||
/** DECSET 25, DECTCEM, controls visibility of the cursor. */
|
||||
public void testEnableDisableCursor() {
|
||||
withTerminalSized(3, 3);
|
||||
assertTrue("Initially the cursor should be enabled", mTerminal.isCursorEnabled());
|
||||
enterString("\033[?25l"); // Disable Cursor (DECTCEM).
|
||||
assertFalse(mTerminal.isCursorEnabled());
|
||||
enterString("\033[?25h"); // Enable Cursor (DECTCEM).
|
||||
assertTrue(mTerminal.isCursorEnabled());
|
||||
|
||||
enterString("\033[?25l"); // Disable Cursor (DECTCEM), again.
|
||||
assertFalse(mTerminal.isCursorEnabled());
|
||||
mTerminal.reset();
|
||||
assertTrue("Resetting the terminal should enable the cursor", mTerminal.isCursorEnabled());
|
||||
|
||||
enterString("\033[?25l");
|
||||
assertFalse(mTerminal.isCursorEnabled());
|
||||
enterString("\033c"); // RIS resetting should enabled cursor.
|
||||
assertTrue(mTerminal.isCursorEnabled());
|
||||
}
|
||||
|
||||
/** DECSET 2004, controls bracketed paste mode. */
|
||||
public void testBracketedPasteMode() {
|
||||
withTerminalSized(3, 3);
|
||||
|
||||
mTerminal.paste("a");
|
||||
assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear());
|
||||
|
||||
enterString("\033[?2004h"); // Enable bracketed paste mode.
|
||||
mTerminal.paste("a");
|
||||
assertEquals("Pasting when in bracketed paste mode should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear());
|
||||
|
||||
enterString("\033[?2004l"); // Disable bracketed paste mode.
|
||||
mTerminal.paste("a");
|
||||
assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear());
|
||||
|
||||
enterString("\033[?2004h"); // Enable bracketed paste mode, again.
|
||||
mTerminal.paste("a");
|
||||
assertEquals("Pasting when in bracketed paste mode again should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear());
|
||||
|
||||
mTerminal.paste("\033ab\033cd\033");
|
||||
assertEquals("Pasting an escape character should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear());
|
||||
mTerminal.paste("\u0081ab\u0081cd\u009F");
|
||||
assertEquals("Pasting C1 control codes should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear());
|
||||
|
||||
mTerminal.reset();
|
||||
mTerminal.paste("a");
|
||||
assertEquals("Terminal reset() should disable bracketed paste mode", "a", mOutput.getOutputAndClear());
|
||||
}
|
||||
|
||||
/** DECSET 7, DECAWM, controls wraparound mode. */
|
||||
public void testWrapAroundMode() {
|
||||
// Default with wraparound:
|
||||
withTerminalSized(3, 3).enterString("abcd").assertLinesAre("abc", "d ", " ");
|
||||
// With wraparound disabled:
|
||||
withTerminalSized(3, 3).enterString("\033[?7labcd").assertLinesAre("abd", " ", " ");
|
||||
enterString("efg").assertLinesAre("abg", " ", " ");
|
||||
// Re-enabling wraparound:
|
||||
enterString("\033[?7hhij").assertLinesAre("abh", "ij ", " ");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/**
|
||||
* "\033P" is a device control string.
|
||||
*/
|
||||
public class DeviceControlStringTest extends TerminalTestCase {
|
||||
|
||||
private static String hexEncode(String s) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++)
|
||||
result.append(String.format("%02X", (int) s.charAt(i)));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private void assertCapabilityResponse(String cap, String expectedResponse) {
|
||||
String input = "\033P+q" + hexEncode(cap) + "\033\\";
|
||||
assertEnteringStringGivesResponse(input, "\033P1+r" + hexEncode(cap) + "=" + hexEncode(expectedResponse) + "\033\\");
|
||||
}
|
||||
|
||||
public void testReportColorsAndName() {
|
||||
// Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in
|
||||
// hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key
|
||||
// names.
|
||||
// Two special features are also recognized, which are not key names: Co for termcap colors (or colors
|
||||
// for terminfo colors), and TN for termcap name (or name for terminfo name).
|
||||
// xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the
|
||||
// corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are
|
||||
// encoded in hexadecimal (2 digits per character).
|
||||
withTerminalSized(3, 3).enterString("A");
|
||||
assertCapabilityResponse("Co", "256");
|
||||
assertCapabilityResponse("colors", "256");
|
||||
assertCapabilityResponse("TN", "xterm");
|
||||
assertCapabilityResponse("name", "xterm");
|
||||
enterString("B").assertLinesAre("AB ", " ", " ");
|
||||
}
|
||||
|
||||
public void testReportKeys() {
|
||||
withTerminalSized(3, 3);
|
||||
assertCapabilityResponse("kB", "\033[Z");
|
||||
}
|
||||
|
||||
public void testReallyLongDeviceControlString() {
|
||||
withTerminalSized(3, 3).enterString("\033P");
|
||||
for (int i = 0; i < 10000; i++) {
|
||||
enterString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
}
|
||||
// The terminal should ignore the overlong DCS sequence and continue printing "aaa." and fill at least the first two lines with
|
||||
// them:
|
||||
assertLineIs(0, "aaa");
|
||||
assertLineIs(1, "aaa");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
|
||||
public class HistoryTest extends TerminalTestCase {
|
||||
|
||||
public void testHistory() {
|
||||
final int rows = 3;
|
||||
final int cols = 3;
|
||||
withTerminalSized(cols, rows).enterString("111222333444555666777888999");
|
||||
assertCursorAt(2, 2);
|
||||
assertLinesAre("777", "888", "999");
|
||||
assertHistoryStartsWith("666", "555");
|
||||
|
||||
mTerminal.resize(cols, 2);
|
||||
assertHistoryStartsWith("777", "666", "555");
|
||||
|
||||
mTerminal.resize(cols, 3);
|
||||
assertHistoryStartsWith("666", "555");
|
||||
}
|
||||
|
||||
public void testHistoryWithScrollRegion() {
|
||||
// "CSI P_s ; P_s r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM).
|
||||
withTerminalSized(3, 4).enterString("111222333444");
|
||||
assertLinesAre("111", "222", "333", "444");
|
||||
enterString("\033[2;3r");
|
||||
// NOTE: "DECSTBM moves the cursor to column 1, line 1 of the page."
|
||||
assertCursorAt(0, 0);
|
||||
enterString("\nCDEFGH").assertLinesAre("111", "CDE", "FGH", "444");
|
||||
enterString("IJK").assertLinesAre("111", "FGH", "IJK", "444").assertHistoryStartsWith("CDE");
|
||||
enterString("LMN").assertLinesAre("111", "IJK", "LMN", "444").assertHistoryStartsWith("FGH", "CDE");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
public class KeyHandlerTest extends TestCase {
|
||||
|
||||
private static String stringToHex(String s) {
|
||||
if (s == null) return null;
|
||||
StringBuilder buffer = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
if (buffer.length() > 0) {
|
||||
buffer.append(" ");
|
||||
}
|
||||
buffer.append("0x");
|
||||
buffer.append(Integer.toHexString(s.charAt(i)));
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
private static void assertKeysEquals(String expected, String actual) {
|
||||
if (!expected.equals(actual)) {
|
||||
assertEquals(stringToHex(expected), stringToHex(actual));
|
||||
}
|
||||
}
|
||||
|
||||
/** See http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html */
|
||||
public void testTermCaps() {
|
||||
// Backspace.
|
||||
assertKeysEquals("\u007f", KeyHandler.getCodeFromTermcap("kb", false, false));
|
||||
|
||||
// Back tab.
|
||||
assertKeysEquals("\033[Z", KeyHandler.getCodeFromTermcap("kB", false, false));
|
||||
|
||||
// Arrow keys (up/down/right/left):
|
||||
assertKeysEquals("\033[A", KeyHandler.getCodeFromTermcap("ku", false, false));
|
||||
assertKeysEquals("\033[B", KeyHandler.getCodeFromTermcap("kd", false, false));
|
||||
assertKeysEquals("\033[C", KeyHandler.getCodeFromTermcap("kr", false, false));
|
||||
assertKeysEquals("\033[D", KeyHandler.getCodeFromTermcap("kl", false, false));
|
||||
// .. shifted:
|
||||
assertKeysEquals("\033[1;2A", KeyHandler.getCodeFromTermcap("kUP", false, false));
|
||||
assertKeysEquals("\033[1;2B", KeyHandler.getCodeFromTermcap("kDN", false, false));
|
||||
assertKeysEquals("\033[1;2C", KeyHandler.getCodeFromTermcap("%i", false, false));
|
||||
assertKeysEquals("\033[1;2D", KeyHandler.getCodeFromTermcap("#4", false, false));
|
||||
|
||||
// Home/end keys:
|
||||
assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("kh", false, false));
|
||||
assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("@7", false, false));
|
||||
// ... shifted:
|
||||
assertKeysEquals("\033[1;2H", KeyHandler.getCodeFromTermcap("#2", false, false));
|
||||
assertKeysEquals("\033[1;2F", KeyHandler.getCodeFromTermcap("*7", false, false));
|
||||
|
||||
// The traditional keyboard keypad:
|
||||
// [Insert] [Home] [Page Up ]
|
||||
// [Delete] [End] [Page Down]
|
||||
//
|
||||
// Termcap names (with xterm response in parenthesis):
|
||||
// K1=Upper left of keypad (xterm sends same "<ESC>[H" = Home).
|
||||
// K2=Center of keypad (xterm sends invalid response).
|
||||
// K3=Upper right of keypad (xterm sends "<ESC>[5~" = Page Up).
|
||||
// K4=Lower left of keypad (xterm sends "<ESC>[F" = End key).
|
||||
// K5=Lower right of keypad (xterm sends "<ESC>[6~" = Page Down).
|
||||
//
|
||||
// vim/neovim (runtime/doc/term.txt):
|
||||
// t_K1 <kHome> keypad home key
|
||||
// t_K3 <kPageUp> keypad page-up key
|
||||
// t_K4 <kEnd> keypad end key
|
||||
// t_K5 <kPageDown> keypad page-down key
|
||||
//
|
||||
assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("K1", false, false));
|
||||
assertKeysEquals("\033OH", KeyHandler.getCodeFromTermcap("K1", true, false));
|
||||
assertKeysEquals("\033[5~", KeyHandler.getCodeFromTermcap("K3", false, false));
|
||||
assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("K4", false, false));
|
||||
assertKeysEquals("\033OF", KeyHandler.getCodeFromTermcap("K4", true, false));
|
||||
assertKeysEquals("\033[6~", KeyHandler.getCodeFromTermcap("K5", false, false));
|
||||
|
||||
// Function keys F1-F12:
|
||||
assertKeysEquals("\033OP", KeyHandler.getCodeFromTermcap("k1", false, false));
|
||||
assertKeysEquals("\033OQ", KeyHandler.getCodeFromTermcap("k2", false, false));
|
||||
assertKeysEquals("\033OR", KeyHandler.getCodeFromTermcap("k3", false, false));
|
||||
assertKeysEquals("\033OS", KeyHandler.getCodeFromTermcap("k4", false, false));
|
||||
assertKeysEquals("\033[15~", KeyHandler.getCodeFromTermcap("k5", false, false));
|
||||
assertKeysEquals("\033[17~", KeyHandler.getCodeFromTermcap("k6", false, false));
|
||||
assertKeysEquals("\033[18~", KeyHandler.getCodeFromTermcap("k7", false, false));
|
||||
assertKeysEquals("\033[19~", KeyHandler.getCodeFromTermcap("k8", false, false));
|
||||
assertKeysEquals("\033[20~", KeyHandler.getCodeFromTermcap("k9", false, false));
|
||||
assertKeysEquals("\033[21~", KeyHandler.getCodeFromTermcap("k;", false, false));
|
||||
assertKeysEquals("\033[23~", KeyHandler.getCodeFromTermcap("F1", false, false));
|
||||
assertKeysEquals("\033[24~", KeyHandler.getCodeFromTermcap("F2", false, false));
|
||||
// Function keys F13-F24 (same as shifted F1-F12):
|
||||
assertKeysEquals("\033[1;2P", KeyHandler.getCodeFromTermcap("F3", false, false));
|
||||
assertKeysEquals("\033[1;2Q", KeyHandler.getCodeFromTermcap("F4", false, false));
|
||||
assertKeysEquals("\033[1;2R", KeyHandler.getCodeFromTermcap("F5", false, false));
|
||||
assertKeysEquals("\033[1;2S", KeyHandler.getCodeFromTermcap("F6", false, false));
|
||||
assertKeysEquals("\033[15;2~", KeyHandler.getCodeFromTermcap("F7", false, false));
|
||||
assertKeysEquals("\033[17;2~", KeyHandler.getCodeFromTermcap("F8", false, false));
|
||||
assertKeysEquals("\033[18;2~", KeyHandler.getCodeFromTermcap("F9", false, false));
|
||||
assertKeysEquals("\033[19;2~", KeyHandler.getCodeFromTermcap("FA", false, false));
|
||||
assertKeysEquals("\033[20;2~", KeyHandler.getCodeFromTermcap("FB", false, false));
|
||||
assertKeysEquals("\033[21;2~", KeyHandler.getCodeFromTermcap("FC", false, false));
|
||||
assertKeysEquals("\033[23;2~", KeyHandler.getCodeFromTermcap("FD", false, false));
|
||||
assertKeysEquals("\033[24;2~", KeyHandler.getCodeFromTermcap("FE", false, false));
|
||||
}
|
||||
|
||||
public void testKeyCodes() {
|
||||
// Return sends carriage return (\r), which normally gets translated by the device driver to newline (\n) unless the ICRNL termios
|
||||
// flag has been set.
|
||||
assertKeysEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, 0, false, false));
|
||||
|
||||
// Backspace.
|
||||
assertKeysEquals("\u007f", KeyHandler.getCode(KeyEvent.KEYCODE_DEL, 0, false, false));
|
||||
|
||||
// Space.
|
||||
assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, 0, false, false));
|
||||
assertKeysEquals("\u0000", KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, KeyHandler.KEYMOD_CTRL, false, false));
|
||||
|
||||
// Back tab.
|
||||
assertKeysEquals("\033[Z", KeyHandler.getCode(KeyEvent.KEYCODE_TAB, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
|
||||
// Arrow keys (up/down/right/left):
|
||||
assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, false, false));
|
||||
assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, 0, false, false));
|
||||
assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, 0, false, false));
|
||||
assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, 0, false, false));
|
||||
// .. shifted:
|
||||
assertKeysEquals("\033[1;2A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
// .. ctrl:ed:
|
||||
assertKeysEquals("\033[1;5A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_CTRL, false, false));
|
||||
assertKeysEquals("\033[1;5B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_CTRL, false, false));
|
||||
assertKeysEquals("\033[1;5C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_CTRL, false, false));
|
||||
assertKeysEquals("\033[1;5D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_CTRL, false, false));
|
||||
// .. ctrl:ed and shifted:
|
||||
int mod = KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT;
|
||||
assertKeysEquals("\033[1;6A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, mod, false, false));
|
||||
assertKeysEquals("\033[1;6B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, mod, false, false));
|
||||
assertKeysEquals("\033[1;6C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, mod, false, false));
|
||||
assertKeysEquals("\033[1;6D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, mod, false, false));
|
||||
|
||||
// Home/end keys:
|
||||
assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, 0, false, false));
|
||||
assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, 0, false, false));
|
||||
// ... shifted:
|
||||
assertKeysEquals("\033[1;2H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
|
||||
// Function keys F1-F12:
|
||||
assertKeysEquals("\033OP", KeyHandler.getCode(KeyEvent.KEYCODE_F1, 0, false, false));
|
||||
assertKeysEquals("\033OQ", KeyHandler.getCode(KeyEvent.KEYCODE_F2, 0, false, false));
|
||||
assertKeysEquals("\033OR", KeyHandler.getCode(KeyEvent.KEYCODE_F3, 0, false, false));
|
||||
assertKeysEquals("\033OS", KeyHandler.getCode(KeyEvent.KEYCODE_F4, 0, false, false));
|
||||
assertKeysEquals("\033[15~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, 0, false, false));
|
||||
assertKeysEquals("\033[17~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, 0, false, false));
|
||||
assertKeysEquals("\033[18~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, 0, false, false));
|
||||
assertKeysEquals("\033[19~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, 0, false, false));
|
||||
assertKeysEquals("\033[20~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, 0, false, false));
|
||||
assertKeysEquals("\033[21~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, 0, false, false));
|
||||
assertKeysEquals("\033[23~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, 0, false, false));
|
||||
assertKeysEquals("\033[24~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, 0, false, false));
|
||||
// Function keys F13-F24 (same as shifted F1-F12):
|
||||
assertKeysEquals("\033[1;2P", KeyHandler.getCode(KeyEvent.KEYCODE_F1, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2Q", KeyHandler.getCode(KeyEvent.KEYCODE_F2, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2R", KeyHandler.getCode(KeyEvent.KEYCODE_F3, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[1;2S", KeyHandler.getCode(KeyEvent.KEYCODE_F4, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[15;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[17;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[18;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[19;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[20;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[21;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[23;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
assertKeysEquals("\033[24;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, KeyHandler.KEYMOD_SHIFT, false, false));
|
||||
|
||||
assertKeysEquals("0", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("1", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("2", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("3", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("4", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("6", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("7", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("8", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals("9", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals(",", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_COMMA, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
assertKeysEquals(".", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, KeyHandler.KEYMOD_NUM_LOCK, false, false));
|
||||
|
||||
assertKeysEquals("\033[2~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, 0, false, false));
|
||||
assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, 0, false, false));
|
||||
assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, 0, false, false));
|
||||
assertKeysEquals("\033[6~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, 0, false, false));
|
||||
assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, 0, false, false));
|
||||
assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, 0, false, false));
|
||||
assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, 0, false, false));
|
||||
assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, 0, false, false));
|
||||
assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, 0, false, false));
|
||||
assertKeysEquals("\033[5~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, 0, false, false));
|
||||
assertKeysEquals("\033[3~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, 0, false, false));
|
||||
}
|
||||
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import android.util.Base64;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
/** "ESC ]" is the Operating System Command. */
|
||||
public class OperatingSystemControlTest extends TerminalTestCase {
|
||||
|
||||
public void testSetTitle() throws Exception {
|
||||
List<ChangedTitle> expectedTitleChanges = new ArrayList<>();
|
||||
|
||||
withTerminalSized(10, 10);
|
||||
enterString("\033]0;Hello, world\007");
|
||||
assertEquals("Hello, world", mTerminal.getTitle());
|
||||
expectedTitleChanges.add(new ChangedTitle(null, "Hello, world"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033]0;Goodbye, world\007");
|
||||
assertEquals("Goodbye, world", mTerminal.getTitle());
|
||||
expectedTitleChanges.add(new ChangedTitle("Hello, world", "Goodbye, world"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033]0;Goodbye, \u00F1 world\007");
|
||||
assertEquals("Goodbye, \uu00F1 world", mTerminal.getTitle());
|
||||
expectedTitleChanges.add(new ChangedTitle("Goodbye, world", "Goodbye, \uu00F1 world"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
// 2 should work as well (0 sets both title and icon).
|
||||
enterString("\033]2;Updated\007");
|
||||
assertEquals("Updated", mTerminal.getTitle());
|
||||
expectedTitleChanges.add(new ChangedTitle("Goodbye, \uu00F1 world", "Updated"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033[22;0t");
|
||||
enterString("\033]0;FIRST\007");
|
||||
expectedTitleChanges.add(new ChangedTitle("Updated", "FIRST"));
|
||||
assertEquals("FIRST", mTerminal.getTitle());
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033[22;0t");
|
||||
enterString("\033]0;SECOND\007");
|
||||
assertEquals("SECOND", mTerminal.getTitle());
|
||||
|
||||
expectedTitleChanges.add(new ChangedTitle("FIRST", "SECOND"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033[23;0t");
|
||||
assertEquals("FIRST", mTerminal.getTitle());
|
||||
|
||||
expectedTitleChanges.add(new ChangedTitle("SECOND", "FIRST"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033[23;0t");
|
||||
expectedTitleChanges.add(new ChangedTitle("FIRST", "Updated"));
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
|
||||
enterString("\033[22;0t");
|
||||
enterString("\033[22;0t");
|
||||
enterString("\033[22;0t");
|
||||
// Popping to same title should not cause changes.
|
||||
enterString("\033[23;0t");
|
||||
enterString("\033[23;0t");
|
||||
enterString("\033[23;0t");
|
||||
assertEquals(expectedTitleChanges, mOutput.titleChanges);
|
||||
}
|
||||
|
||||
public void testTitleStack() throws Exception {
|
||||
// echo -ne '\e]0;BEFORE\007' # set title
|
||||
// echo -ne '\e[22t' # push to stack
|
||||
// echo -ne '\e]0;AFTER\007' # set new title
|
||||
// echo -ne '\e[23t' # retrieve from stack
|
||||
|
||||
withTerminalSized(10, 10);
|
||||
enterString("\033]0;InitialTitle\007");
|
||||
assertEquals("InitialTitle", mTerminal.getTitle());
|
||||
enterString("\033[22t");
|
||||
assertEquals("InitialTitle", mTerminal.getTitle());
|
||||
enterString("\033]0;UpdatedTitle\007");
|
||||
assertEquals("UpdatedTitle", mTerminal.getTitle());
|
||||
enterString("\033[23t");
|
||||
assertEquals("InitialTitle", mTerminal.getTitle());
|
||||
enterString("\033[23t\033[23t\033[23t");
|
||||
assertEquals("InitialTitle", mTerminal.getTitle());
|
||||
}
|
||||
|
||||
public void testSetColor() throws Exception {
|
||||
// "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC.
|
||||
withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007");
|
||||
assertEquals(Integer.toHexString(0xFF00FF00), Integer.toHexString(mTerminal.mColors.mCurrentColors[5]));
|
||||
enterString("\033]4;5;#00FFAB\007");
|
||||
assertEquals(mTerminal.mColors.mCurrentColors[5], 0xFF00FFAB);
|
||||
enterString("\033]4;255;#ABFFAB\007");
|
||||
assertEquals(mTerminal.mColors.mCurrentColors[255], 0xFFABFFAB);
|
||||
// Two indexed colors at once:
|
||||
enterString("\033]4;7;#00FF00;8;#0000FF\007");
|
||||
assertEquals(mTerminal.mColors.mCurrentColors[7], 0xFF00FF00);
|
||||
assertEquals(mTerminal.mColors.mCurrentColors[8], 0xFF0000FF);
|
||||
}
|
||||
|
||||
void assertIndexColorsMatch(int[] expected) {
|
||||
for (int i = 0; i < 255; i++)
|
||||
assertEquals("index=" + i, expected[i], mTerminal.mColors.mCurrentColors[i]);
|
||||
}
|
||||
|
||||
public void testResetColor() throws Exception {
|
||||
withTerminalSized(4, 4);
|
||||
int[] initialColors = new int[TextStyle.NUM_INDEXED_COLORS];
|
||||
System.arraycopy(mTerminal.mColors.mCurrentColors, 0, initialColors, 0, initialColors.length);
|
||||
int[] expectedColors = new int[initialColors.length];
|
||||
System.arraycopy(mTerminal.mColors.mCurrentColors, 0, expectedColors, 0, expectedColors.length);
|
||||
Random rand = new Random();
|
||||
for (int endType = 0; endType < 3; endType++) {
|
||||
// Both BEL (7) and ST (ESC \) can end an OSC sequence.
|
||||
String ender = (endType == 0) ? "\007" : "\033\\";
|
||||
for (int i = 0; i < 255; i++) {
|
||||
expectedColors[i] = 0xFF000000 + (rand.nextInt() & 0xFFFFFF);
|
||||
int r = (expectedColors[i] >> 16) & 0xFF;
|
||||
int g = (expectedColors[i] >> 8) & 0xFF;
|
||||
int b = expectedColors[i] & 0xFF;
|
||||
String rgbHex = String.format("%02x", r) + String.format("%02x", g) + String.format("%02x", b);
|
||||
enterString("\033]4;" + i + ";#" + rgbHex + ender);
|
||||
assertEquals(expectedColors[i], mTerminal.mColors.mCurrentColors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
enterString("\033]104;0\007");
|
||||
expectedColors[0] = TerminalColors.COLOR_SCHEME.mDefaultColors[0];
|
||||
assertIndexColorsMatch(expectedColors);
|
||||
enterString("\033]104;1;2\007");
|
||||
expectedColors[1] = TerminalColors.COLOR_SCHEME.mDefaultColors[1];
|
||||
expectedColors[2] = TerminalColors.COLOR_SCHEME.mDefaultColors[2];
|
||||
assertIndexColorsMatch(expectedColors);
|
||||
enterString("\033]104\007"); // Reset all colors.
|
||||
assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors);
|
||||
}
|
||||
|
||||
public void disabledTestSetClipboard() {
|
||||
// Cannot run this as a unit test since Base64 is a android.util class.
|
||||
enterString("\033]52;c;" + Base64.encodeToString("Hello, world".getBytes(), 0) + "\007");
|
||||
}
|
||||
|
||||
public void testResettingTerminalResetsColor() throws Exception {
|
||||
// "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC.
|
||||
withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007");
|
||||
enterString("\033]4;5;#00FFAB\007").assertColor(5, 0xFF00FFAB);
|
||||
enterString("\033]4;255;#ABFFAB\007").assertColor(255, 0xFFABFFAB);
|
||||
mTerminal.reset();
|
||||
assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors);
|
||||
}
|
||||
|
||||
public void testSettingDynamicColors() {
|
||||
// "${OSC}${DYNAMIC};${COLORSPEC}${BEL_OR_STRINGTERMINATOR}" => Change ${DYNAMIC} color to the color specified by $COLORSPEC where:
|
||||
// DYNAMIC=10: Text foreground color.
|
||||
// DYNAMIC=11: Text background color.
|
||||
// DYNAMIC=12: Text cursor color.
|
||||
withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00);
|
||||
enterString("\033]11;#0ABCD0\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF0ABCD0);
|
||||
enterString("\033]12;#00ABCD\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00ABCD);
|
||||
// Two special colors at once
|
||||
// ("Each successive parameter changes the next color in the list. The value of P s tells the starting point in the list"):
|
||||
enterString("\033]10;#FF0000;#00FF00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
|
||||
assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00);
|
||||
// Three at once:
|
||||
enterString("\033]10;#0000FF;#00FF00;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFF0000FF);
|
||||
assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFFFF0000);
|
||||
|
||||
// Without ending semicolon:
|
||||
enterString("\033]10;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
|
||||
// For background and cursor:
|
||||
enterString("\033]11;#FFFF00;\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00);
|
||||
enterString("\033]12;#00FFFF;\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF);
|
||||
|
||||
// Using string terminator:
|
||||
String stringTerminator = "\033\\";
|
||||
enterString("\033]10;#FF0000" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
|
||||
// For background and cursor:
|
||||
enterString("\033]11;#FFFF00;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00);
|
||||
enterString("\033]12;#00FFFF;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF);
|
||||
}
|
||||
|
||||
public void testReportSpecialColors() {
|
||||
// "${OSC}${DYNAMIC};?${BEL}" => Terminal responds with the control sequence which would set the current color.
|
||||
// Both xterm and libvte (gnome-terminal and others) use the longest color representation, which means that
|
||||
// the response is "${OSC}rgb:RRRR/GGGG/BBBB"
|
||||
withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00);
|
||||
assertEnteringStringGivesResponse("\033]10;?\007", "\033]10;rgb:abab/cdcd/0000\007");
|
||||
// Same as above but with string terminator. xterm uses the same string terminator in the response, which
|
||||
// e.g. script posted at http://superuser.com/questions/157563/programmatic-access-to-current-xterm-background-color
|
||||
// relies on:
|
||||
assertEnteringStringGivesResponse("\033]10;?\033\\", "\033]10;rgb:abab/cdcd/0000\033\\");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
public class RectangularAreasTest extends TerminalTestCase {
|
||||
|
||||
/** http://www.vt100.net/docs/vt510-rm/DECFRA */
|
||||
public void testFillRectangularArea() {
|
||||
withTerminalSized(3, 3).enterString("\033[88$x").assertLinesAre("XXX", "XXX", "XXX");
|
||||
withTerminalSized(3, 3).enterString("\033[88;1;1;2;10$x").assertLinesAre("XXX", "XXX", " ");
|
||||
withTerminalSized(3, 3).enterString("\033[88;2;1;3;10$x").assertLinesAre(" ", "XXX", "XXX");
|
||||
withTerminalSized(3, 3).enterString("\033[88;1;1;100;1$x").assertLinesAre("X ", "X ", "X ");
|
||||
withTerminalSized(3, 3).enterString("\033[88;1;1;100;2$x").assertLinesAre("XX ", "XX ", "XX ");
|
||||
withTerminalSized(3, 3).enterString("\033[88;100;1;100;2$x").assertLinesAre(" ", " ", " ");
|
||||
}
|
||||
|
||||
/** http://www.vt100.net/docs/vt510-rm/DECERA */
|
||||
public void testEraseRectangularArea() {
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[$z").assertLinesAre(" ", " ", " ");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10$z").assertLinesAre(" ", " ", "GHI");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10$z").assertLinesAre("ABC", " ", " ");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1$z").assertLinesAre(" BC", " EF", " HI");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2$z").assertLinesAre(" C", " F", " I");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2$z").assertLinesAre("ABC", "DEF", "GHI");
|
||||
|
||||
withTerminalSized(3, 3).enterString("A\033[$zBC").assertLinesAre(" BC", " ", " ");
|
||||
}
|
||||
|
||||
/** http://www.vt100.net/docs/vt510-rm/DECSED */
|
||||
public void testSelectiveEraseInDisplay() {
|
||||
// ${CSI}1"q enables protection, ${CSI}0"q disables it.
|
||||
// ${CSI}?${0,1,2}J" erases (0=cursor to end, 1=start to cursor, 2=complete display).
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2J").assertLinesAre(" ", " ", " ");
|
||||
withTerminalSized(3, 3).enterString("ABC\033[1\"qDE\033[0\"qFGHI\033[?2J").assertLinesAre(" ", "DE ", " ");
|
||||
withTerminalSized(3, 3).enterString("\033[1\"qABCDE\033[0\"qFGHI\033[?2J").assertLinesAre("ABC", "DE ", " ");
|
||||
}
|
||||
|
||||
/** http://vt100.net/docs/vt510-rm/DECSEL */
|
||||
public void testSelectiveEraseInLine() {
|
||||
// ${CSI}1"q enables protection, ${CSI}0"q disables it.
|
||||
// ${CSI}?${0,1,2}K" erases (0=cursor to end, 1=start to cursor, 2=complete line).
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2K").assertLinesAre("ABC", "DEF", " ");
|
||||
withTerminalSized(3, 3).enterString("ABCDE\033[?0KFGHI").assertLinesAre("ABC", "DEF", "GHI");
|
||||
withTerminalSized(3, 3).enterString("ABCDE\033[?1KFGHI").assertLinesAre("ABC", " F", "GHI");
|
||||
withTerminalSized(3, 3).enterString("ABCDE\033[?2KFGHI").assertLinesAre("ABC", " F", "GHI");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;2H\033[?0K").assertLinesAre("ABC", "D ", "GHI");
|
||||
withTerminalSized(3, 3).enterString("ABC\033[1\"qD\033[0\"qE\033[?2KFGHI").assertLinesAre("ABC", "D F", "GHI");
|
||||
}
|
||||
|
||||
/** http://www.vt100.net/docs/vt510-rm/DECSERA */
|
||||
public void testSelectiveEraseInRectangle() {
|
||||
// ${CSI}1"q enables protection, ${CSI}0"q disables it.
|
||||
// ${CSI}?${TOP};${LEFT};${BOTTOM};${RIGHT}${" erases.
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[${").assertLinesAre(" ", " ", " ");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10${").assertLinesAre(" ", " ", "GHI");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10${").assertLinesAre("ABC", " ", " ");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1${").assertLinesAre(" BC", " EF", " HI");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2${").assertLinesAre(" C", " F", " I");
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2${").assertLinesAre("ABC", "DEF", "GHI");
|
||||
|
||||
withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[${").assertLinesAre(" ", " E ", " ");
|
||||
withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[1;1;2;10${").assertLinesAre(" ", " E ", "GHI");
|
||||
}
|
||||
|
||||
/** http://vt100.net/docs/vt510-rm/DECCRA */
|
||||
public void testRectangularCopy() {
|
||||
// "${CSI}${SRC_TOP};${SRC_LEFT};${SRC_BOTTOM};${SRC_RIGHT};${SRC_PAGE};${DST_TOP};${DST_LEFT};${DST_PAGE}\$v"
|
||||
withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;5;1$v").assertLinesAre("ABC ", "DEF AB ", "GHI DE ");
|
||||
withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;4;1$v").assertLinesAre("ABCABC ", "DEFDEF ", "GHIGHI ");
|
||||
withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;3;1$v").assertLinesAre("ABABC ", "DEDEF ", "GHGHI ");
|
||||
withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;1;1$v").assertLinesAre("ABCABC ", "DEFDEF ",
|
||||
"GHIGHI ");
|
||||
withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;2;1$v").assertLinesAre(" ABCBC ", " DEFEF ",
|
||||
" GHIHI ");
|
||||
withTerminalSized(3, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;2;1$v").assertLinesAre("ABC", "DAB", "GDE");
|
||||
|
||||
// Enable ${CSI}?6h origin mode (DECOM) and ${CSI}?69h for left/right margin (DECLRMM) enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s
|
||||
// for DECSLRM margin setting.
|
||||
withTerminalSized(5, 5).enterString("\033[?6h\033[?69h\033[2;4s");
|
||||
enterString("ABCDEFGHIJK").assertLinesAre(" ABC ", " DEF ", " GHI ", " JK ", " ");
|
||||
enterString("\033[1;1;2;2;1;2;2;1$v").assertLinesAre(" ABC ", " DAB ", " GDE ", " JK ", " ");
|
||||
}
|
||||
|
||||
/** http://vt100.net/docs/vt510-rm/DECCARA */
|
||||
public void testChangeAttributesInRectangularArea() {
|
||||
final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
|
||||
// "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$r"
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI");
|
||||
assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0));
|
||||
|
||||
// Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle:
|
||||
withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI");
|
||||
assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
|
||||
}
|
||||
|
||||
/** http://vt100.net/docs/vt510-rm/DECCARA */
|
||||
public void testReverseAttributesInRectangularArea() {
|
||||
final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
|
||||
final int u = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
|
||||
final int bu = TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
|
||||
// "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$t"
|
||||
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
|
||||
assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0));
|
||||
|
||||
// Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle:
|
||||
withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
|
||||
assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
|
||||
|
||||
// Check reversal by initially bolding the B:
|
||||
withTerminalSized(3, 3).enterString("\033[2*xA\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
|
||||
assertEffectAttributesSet(effectLine(b, 0, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
|
||||
|
||||
// Check reversal by initially underlining A, bolding B, then reversing both bold and underline:
|
||||
withTerminalSized(3, 3).enterString("\033[2*x\033[4mA\033[0m\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1;4$t").assertLinesAre("ABC", "DEF",
|
||||
"GHI");
|
||||
assertEffectAttributesSet(effectLine(b, u, 0), effectLine(bu, bu, 0), effectLine(0, 0, 0));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
public class ResizeTest extends TerminalTestCase {
|
||||
|
||||
public void testResizeWhenHasHistory() {
|
||||
final int cols = 3;
|
||||
withTerminalSized(cols, 3).enterString("111222333444555666777888999").assertCursorAt(2, 2).assertLinesAre("777", "888", "999");
|
||||
resize(cols, 5).assertCursorAt(4, 2).assertLinesAre("555", "666", "777", "888", "999");
|
||||
resize(cols, 3).assertCursorAt(2, 2).assertLinesAre("777", "888", "999");
|
||||
}
|
||||
|
||||
public void testResizeWhenInAltBuffer() {
|
||||
final int rows = 3, cols = 3;
|
||||
withTerminalSized(cols, rows).enterString("a\r\ndef$").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
|
||||
|
||||
// Resize and back again while in main buffer:
|
||||
resize(cols, 5).assertLinesAre("a ", "def", "$ ", " ", " ").assertCursorAt(2, 1);
|
||||
resize(cols, rows).assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
|
||||
|
||||
// Switch to alt buffer:
|
||||
enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 1);
|
||||
enterString("h").assertLinesAre(" ", " ", " h ").assertCursorAt(2, 2);
|
||||
|
||||
resize(cols, 5).resize(cols, rows);
|
||||
|
||||
// Switch from alt buffer:
|
||||
enterString("\033[?1049l").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
|
||||
}
|
||||
|
||||
public void testShrinkingInAltBuffer() {
|
||||
final int rows = 5;
|
||||
final int cols = 3;
|
||||
withTerminalSized(cols, rows).enterString("A\r\nB\r\nC\r\nD\r\nE").assertLinesAre("A ", "B ", "C ", "D ", "E ");
|
||||
enterString("\033[?1049h").assertLinesAre(" ", " ", " ", " ", " ");
|
||||
resize(3, 3).enterString("\033[?1049lF").assertLinesAre("C ", "D ", "EF ");
|
||||
}
|
||||
|
||||
public void testResizeAfterNewlineWhenInAltBuffer() {
|
||||
final int rows = 3;
|
||||
final int cols = 3;
|
||||
withTerminalSized(cols, rows);
|
||||
enterString("a\r\nb\r\nc\r\nd\r\ne\r\nf\r\n").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0);
|
||||
assertLineWraps(false, false, false);
|
||||
|
||||
// Switch to alt buffer:
|
||||
enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 0);
|
||||
enterString("h").assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1);
|
||||
|
||||
// Grow by two rows:
|
||||
resize(cols, 5).assertLinesAre(" ", " ", "h ", " ", " ").assertCursorAt(2, 1);
|
||||
resize(cols, rows).assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1);
|
||||
|
||||
// Switch from alt buffer:
|
||||
enterString("\033[?1049l").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0);
|
||||
}
|
||||
|
||||
public void testResizeAfterHistoryWraparound() {
|
||||
final int rows = 3;
|
||||
final int cols = 10;
|
||||
withTerminalSized(cols, rows);
|
||||
StringBuilder buffer = new StringBuilder();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
String s = Integer.toString(i);
|
||||
enterString(s);
|
||||
buffer.setLength(0);
|
||||
buffer.append(s);
|
||||
while (buffer.length() < cols)
|
||||
buffer.append(' ');
|
||||
if (i > rows) {
|
||||
assertLineIs(rows - 1, buffer.toString());
|
||||
}
|
||||
enterString("\r\n");
|
||||
}
|
||||
assertLinesAre("998 ", "999 ", " ");
|
||||
mTerminal.resize(cols, 2);
|
||||
assertLinesAre("999 ", " ");
|
||||
mTerminal.resize(cols, 5);
|
||||
assertLinesAre("996 ", "997 ", "998 ", "999 ", " ");
|
||||
mTerminal.resize(cols, rows);
|
||||
assertLinesAre("998 ", "999 ", " ");
|
||||
}
|
||||
|
||||
public void testVerticalResize() {
|
||||
final int rows = 5;
|
||||
final int cols = 3;
|
||||
|
||||
withTerminalSized(cols, rows);
|
||||
// Foreground color to 119:
|
||||
enterString("\033[38;5;119m");
|
||||
// Background color to 129:
|
||||
enterString("\033[48;5;129m");
|
||||
// Clear with ED, Erase in Display:
|
||||
enterString("\033[2J");
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
long style = getStyleAt(r, c);
|
||||
assertEquals(119, TextStyle.decodeForeColor(style));
|
||||
assertEquals(129, TextStyle.decodeBackColor(style));
|
||||
}
|
||||
}
|
||||
enterString("11\r\n22");
|
||||
assertLinesAre("11 ", "22 ", " ", " ", " ").assertLineWraps(false, false, false, false, false);
|
||||
resize(cols, rows - 2).assertLinesAre("11 ", "22 ", " ");
|
||||
|
||||
// After resize, screen should still be same color:
|
||||
for (int r = 0; r < rows - 2; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
long style = getStyleAt(r, c);
|
||||
assertEquals(119, TextStyle.decodeForeColor(style));
|
||||
assertEquals(129, TextStyle.decodeBackColor(style));
|
||||
}
|
||||
}
|
||||
|
||||
// Background color to 200 and grow back size (which should be cleared to the new background color):
|
||||
enterString("\033[48;5;200m");
|
||||
resize(cols, rows);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
long style = getStyleAt(r, c);
|
||||
assertEquals(119, TextStyle.decodeForeColor(style));
|
||||
assertEquals("wrong at row=" + r, r >= 3 ? 200 : 129, TextStyle.decodeBackColor(style));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void testHorizontalResize() {
|
||||
final int rows = 5;
|
||||
final int cols = 5;
|
||||
|
||||
withTerminalSized(cols, rows);
|
||||
// Background color to 129:
|
||||
// enterString("\033[48;5;129m").assertLinesAre(" ", " ", " ", " ", " ");
|
||||
enterString("1111\r\n2222\r\n3333\r\n4444\r\n5555").assertCursorAt(4, 4);
|
||||
// assertEquals(129, TextStyle.decodeBackColor(getStyleAt(2, 2)));
|
||||
assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertLineWraps(false, false, false, false, false);
|
||||
resize(cols + 2, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
|
||||
assertLineWraps(false, false, false, false, false);
|
||||
resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
|
||||
assertLineWraps(false, false, false, false, false);
|
||||
resize(cols - 1, rows).assertLinesAre("2222", "3333", "4444", "5555", " ").assertCursorAt(4, 0);
|
||||
assertLineWraps(false, false, false, true, false);
|
||||
resize(cols - 2, rows).assertLinesAre("3 ", "444", "4 ", "555", "5 ").assertCursorAt(4, 1);
|
||||
assertLineWraps(false, true, false, true, false);
|
||||
// Back to original size:
|
||||
resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
|
||||
assertLineWraps(false, false, false, false, false);
|
||||
}
|
||||
|
||||
public void testLineWrap() {
|
||||
final int rows = 3, cols = 5;
|
||||
withTerminalSized(cols, rows).enterString("111111").assertLinesAre("11111", "1 ", " ");
|
||||
assertCursorAt(1, 1).assertLineWraps(true, false, false);
|
||||
|
||||
resize(7, rows).assertCursorAt(0, 6).assertLinesAre("111111 ", " ", " ").assertLineWraps(false, false, false);
|
||||
resize(cols, rows).assertCursorAt(1, 1).assertLinesAre("11111", "1 ", " ").assertLineWraps(true, false, false);
|
||||
|
||||
enterString("2").assertLinesAre("11111", "12 ", " ").assertLineWraps(true, false, false);
|
||||
enterString("123").assertLinesAre("11111", "12123", " ").assertLineWraps(true, false, false);
|
||||
enterString("W").assertLinesAre("11111", "12123", "W ").assertLineWraps(true, true, false);
|
||||
|
||||
withTerminalSized(cols, rows).enterString("1234512345");
|
||||
assertLinesAre("12345", "12345", " ").assertLineWraps(true, false, false);
|
||||
enterString("W").assertLinesAre("12345", "12345", "W ").assertLineWraps(true, true, false);
|
||||
}
|
||||
|
||||
public void testCursorPositionWhenShrinking() {
|
||||
final int rows = 5, cols = 3;
|
||||
withTerminalSized(cols, rows).enterString("$ ").assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2);
|
||||
resize(3, 3).assertLinesAre("$ ", " ", " ").assertCursorAt(0, 2);
|
||||
resize(cols, rows).assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2);
|
||||
}
|
||||
|
||||
public void testResizeWithCombiningCharInLastColumn() {
|
||||
withTerminalSized(3, 3).enterString("ABC\u0302DEF").assertLinesAre("ABC\u0302", "DEF", " ");
|
||||
resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " ");
|
||||
|
||||
// Same as above but with colors:
|
||||
withTerminalSized(3, 3).enterString("\033[37mA\033[35mB\033[33mC\u0302\033[32mD\033[31mE\033[34mF").assertLinesAre("ABC\u0302",
|
||||
"DEF", " ");
|
||||
resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " ");
|
||||
assertForegroundIndices(effectLine(7, 5, 3, 2), effectLine(1, 4, 4, 4), effectLine(4, 4, 4, 4));
|
||||
}
|
||||
|
||||
public void testResizeWithLineWrappingContinuing() {
|
||||
withTerminalSized(5, 3).enterString("\r\nAB DE").assertLinesAre(" ", "AB DE", " ");
|
||||
resize(4, 3).assertLinesAre("AB D", "E ", " ");
|
||||
resize(3, 3).assertLinesAre("AB ", "DE ", " ");
|
||||
resize(5, 3).assertLinesAre(" ", "AB DE", " ");
|
||||
}
|
||||
|
||||
public void testResizeWithWideChars() {
|
||||
final int rows = 3, cols = 4;
|
||||
String twoCharsWidthOne = new String(Character.toChars(TerminalRowTest.TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
|
||||
withTerminalSized(cols, rows).enterString(twoCharsWidthOne).enterString("\r\n");
|
||||
enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " ");
|
||||
resize(3, 3).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " ");
|
||||
enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + twoCharsWidthOne + " ", " ");
|
||||
}
|
||||
|
||||
public void testResizeWithMoreWideChars() {
|
||||
final int rows = 4, cols = 5;
|
||||
|
||||
withTerminalSized(cols, rows).enterString("qqrr").assertLinesAre("qqrr ", " ", " ", " ");
|
||||
resize(2, rows).assertLinesAre("qq", "rr", " ", " ");
|
||||
resize(5, rows).assertLinesAre("qqrr ", " ", " ", " ");
|
||||
|
||||
withTerminalSized(cols, rows).enterString("QR").assertLinesAre("QR ", " ", " ", " ");
|
||||
resize(2, rows).assertLinesAre("Q", "R", " ", " ");
|
||||
resize(5, rows).assertLinesAre("QR ", " ", " ", " ");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
public class ScreenBufferTest extends TerminalTestCase {
|
||||
|
||||
public void testBasics() {
|
||||
TerminalBuffer screen = new TerminalBuffer(5, 3, 3);
|
||||
assertEquals("", screen.getTranscriptText());
|
||||
screen.setChar(0, 0, 'a', 0);
|
||||
assertEquals("a", screen.getTranscriptText());
|
||||
screen.setChar(0, 0, 'b', 0);
|
||||
assertEquals("b", screen.getTranscriptText());
|
||||
screen.setChar(2, 0, 'c', 0);
|
||||
assertEquals("b c", screen.getTranscriptText());
|
||||
screen.setChar(2, 2, 'f', 0);
|
||||
assertEquals("b c\n\n f", screen.getTranscriptText());
|
||||
screen.blockSet(0, 0, 2, 2, 'X', 0);
|
||||
}
|
||||
|
||||
public void testBlockSet() {
|
||||
TerminalBuffer screen = new TerminalBuffer(5, 3, 3);
|
||||
screen.blockSet(0, 0, 2, 2, 'X', 0);
|
||||
assertEquals("XX\nXX", screen.getTranscriptText());
|
||||
screen.blockSet(1, 1, 2, 2, 'Y', 0);
|
||||
assertEquals("XX\nXYY\n YY", screen.getTranscriptText());
|
||||
}
|
||||
|
||||
public void testGetSelectedText() {
|
||||
withTerminalSized(5, 3).enterString("ABCDEFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " ");
|
||||
assertEquals("AB", mTerminal.getSelectedText(0, 0, 1, 0));
|
||||
assertEquals("BC", mTerminal.getSelectedText(1, 0, 2, 0));
|
||||
assertEquals("CDE", mTerminal.getSelectedText(2, 0, 4, 0));
|
||||
assertEquals("FG", mTerminal.getSelectedText(0, 1, 1, 1));
|
||||
assertEquals("GH", mTerminal.getSelectedText(1, 1, 2, 1));
|
||||
assertEquals("HIJ", mTerminal.getSelectedText(2, 1, 4, 1));
|
||||
|
||||
assertEquals("ABCDEFG", mTerminal.getSelectedText(0, 0, 1, 1));
|
||||
withTerminalSized(5, 3).enterString("ABCDE\r\nFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " ");
|
||||
assertEquals("ABCDE\nFG", mTerminal.getSelectedText(0, 0, 1, 1));
|
||||
}
|
||||
|
||||
public void testGetSelectedTextJoinFullLines() {
|
||||
withTerminalSized(5, 3).enterString("ABCDE\r\nFG");
|
||||
assertEquals("ABCDEFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true));
|
||||
|
||||
withTerminalSized(5, 3).enterString("ABC\r\nFG");
|
||||
assertEquals("ABC\nFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true));
|
||||
}
|
||||
|
||||
public void testGetWordAtLocation() {
|
||||
withTerminalSized(5, 3).enterString("ABCDEFGHIJ\r\nKLMNO");
|
||||
assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(0, 0));
|
||||
assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 1));
|
||||
assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 2));
|
||||
|
||||
withTerminalSized(5, 3).enterString("ABC DEF GHI ");
|
||||
assertEquals("ABC", mTerminal.getScreen().getWordAtLocation(0, 0));
|
||||
assertEquals("", mTerminal.getScreen().getWordAtLocation(3, 0));
|
||||
assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(4, 0));
|
||||
assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(0, 1));
|
||||
assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(1, 1));
|
||||
assertEquals("GHI", mTerminal.getScreen().getWordAtLocation(0, 2));
|
||||
assertEquals("", mTerminal.getScreen().getWordAtLocation(1, 2));
|
||||
assertEquals("", mTerminal.getScreen().getWordAtLocation(2, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
/**
|
||||
* ${CSI}${top};${bottom}r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM).
|
||||
* <p/>
|
||||
* "DECSTBM moves the cursor to column 1, line 1 of the page" (http://www.vt100.net/docs/vt510-rm/DECSTBM).
|
||||
*/
|
||||
public class ScrollRegionTest extends TerminalTestCase {
|
||||
|
||||
public void testScrollRegionTop() {
|
||||
withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444");
|
||||
enterString("\033[2r").assertCursorAt(0, 0);
|
||||
enterString("\r\n\r\n\r\n\r\nCDEFGH").assertLinesAre("111", "444", "CDE", "FGH").assertHistoryStartsWith("333");
|
||||
enterString("IJK").assertLinesAre("111", "CDE", "FGH", "IJK").assertHistoryStartsWith("444");
|
||||
// Reset scroll region and enter line:
|
||||
enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("LMNOPQ").assertLinesAre("CDE", "FGH", "LMN", "OPQ");
|
||||
}
|
||||
|
||||
public void testScrollRegionBottom() {
|
||||
withTerminalSized(3, 4).enterString("111222333444");
|
||||
assertLinesAre("111", "222", "333", "444");
|
||||
enterString("\033[1;3r").assertCursorAt(0, 0);
|
||||
enterString("\r\n\r\nCDEFGH").assertLinesAre("222", "CDE", "FGH", "444").assertHistoryStartsWith("111");
|
||||
// Reset scroll region and enter line:
|
||||
enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("IJKLMN").assertLinesAre("CDE", "FGH", "IJK", "LMN");
|
||||
}
|
||||
|
||||
public void testScrollRegionResetWithOriginMode() {
|
||||
withTerminalSized(3, 4).enterString("111222333444");
|
||||
assertLinesAre("111", "222", "333", "444");
|
||||
// "\033[?6h" sets origin mode, so that the later DECSTBM resets cursor to below margin:
|
||||
enterString("\033[?6h\033[2r").assertCursorAt(1, 0);
|
||||
}
|
||||
|
||||
public void testScrollRegionLeft() {
|
||||
// ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
|
||||
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEFG").assertLinesAre("ABC", " DE", " FG");
|
||||
enterString("HI").assertLinesAre("ADE", " FG", " HI").enterString("JK").assertLinesAre("AFG", " HI", " JK");
|
||||
enterString("\n").assertLinesAre("AHI", " JK", " ");
|
||||
}
|
||||
|
||||
public void testScrollRegionRight() {
|
||||
// ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
|
||||
withTerminalSized(3, 3).enterString("YYY\033[?69h\033[1;2sABCDEF").assertLinesAre("ABY", "CD ", "EF ");
|
||||
enterString("GH").assertLinesAre("CDY", "EF ", "GH ").enterString("IJ").assertLinesAre("EFY", "GH ", "IJ ");
|
||||
enterString("\n").assertLinesAre("GHY", "IJ ", " ");
|
||||
}
|
||||
|
||||
public void testScrollRegionOnAllSides() {
|
||||
// ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
|
||||
withTerminalSized(4, 4).enterString("ABCDEFGHIJKLMNOP").assertLinesAre("ABCD", "EFGH", "IJKL", "MNOP");
|
||||
// http://www.vt100.net/docs/vt510-rm/DECOM
|
||||
enterString("\033[?6h\033[2;3r").assertCursorAt(1, 0);
|
||||
enterString("\033[?69h\033[2;3s").assertCursorAt(1, 1);
|
||||
enterString("QRST").assertLinesAre("ABCD", "EQRH", "ISTL", "MNOP");
|
||||
enterString("UV").assertLinesAre("ABCD", "ESTH", "IUVL", "MNOP");
|
||||
}
|
||||
|
||||
public void testDECCOLMResetsScrollMargin() {
|
||||
// DECCOLM — Select 80 or 132 Columns per Page (http://www.vt100.net/docs/vt510-rm/DECCOLM) has the important
|
||||
// side effect to clear scroll margins, which is useful for e.g. the "reset" utility to clear scroll margins.
|
||||
withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444");
|
||||
enterString("\033[2r\033[?3h\r\nABCDEFGHIJKL").assertLinesAre("ABC", "DEF", "GHI", "JKL");
|
||||
}
|
||||
|
||||
public void testScrollOutsideVerticalRegion() {
|
||||
withTerminalSized(3, 4).enterString("\033[0;2rhi\033[4;0Hyou").assertLinesAre("hi ", " ", " ", "you");
|
||||
//enterString("see").assertLinesAre("hi ", " ", " ", "see");
|
||||
}
|
||||
|
||||
public void testNELRespectsLeftMargin() {
|
||||
// vttest "Menu 11.3.2: VT420 Cursor-Movement Test", select "10. Test other movement (CR/HT/LF/FF) within margins".
|
||||
// The NEL (ESC E) sequence moves cursor to first position on next line, where first position depends on origin mode and margin.
|
||||
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033ED").assertLinesAre("ABC", "D ", " ");
|
||||
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033[?6h\033ED").assertLinesAre("ABC", " D ", " ");
|
||||
}
|
||||
|
||||
public void testBackwardIndex() {
|
||||
// vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 7.
|
||||
// Without margins:
|
||||
withTerminalSized(3, 3).enterString("ABCDEF\0336H").assertLinesAre("ABC", "DHF", " ");
|
||||
enterString("\0336\0336I").assertLinesAre("ABC", "IHF", " ");
|
||||
enterString("\0336\0336").assertLinesAre(" AB", " IH", " ");
|
||||
// With left margin:
|
||||
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEF\0336\0336").assertLinesAre("A B", " D", " F");
|
||||
}
|
||||
|
||||
public void testForwardIndex() {
|
||||
// vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 8.
|
||||
// Without margins:
|
||||
withTerminalSized(3, 3).enterString("ABCD\0339E").assertLinesAre("ABC", "D E", " ");
|
||||
enterString("\0339").assertLinesAre("BC ", " E ", " ");
|
||||
// With right margin:
|
||||
withTerminalSized(3, 3).enterString("\033[?69h\033[0;2sABCD\0339").assertLinesAre("B ", "D ", " ");
|
||||
}
|
||||
|
||||
public void testScrollDownWithScrollRegion() {
|
||||
withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 ");
|
||||
enterString("\033[3r").enterString("\033[2T").assertLinesAre("1 ", "2 ", " ", " ", "3 ");
|
||||
}
|
||||
|
||||
public void testScrollDownBelowScrollRegion() {
|
||||
withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 ");
|
||||
enterString("\033[1;3r"); // DECSTBM margins.
|
||||
enterString("\033[4;1H"); // Place cursor just below bottom margin.
|
||||
enterString("QQ\r\nRR\r\n\r\n\r\nYY");
|
||||
assertLinesAre("1 ", "2 ", "3 ", "QQ", "YY");
|
||||
}
|
||||
|
||||
/** See https://github.com/termux/termux-app/issues/1340 */
|
||||
public void testScrollRegionDoesNotLimitCursorMovement() {
|
||||
withTerminalSized(6, 4)
|
||||
.enterString("\033[4;7r\033[3;1Haaa\033[Axxx")
|
||||
.assertLinesAre(
|
||||
" ",
|
||||
" xxx",
|
||||
"aaa ",
|
||||
" "
|
||||
);
|
||||
|
||||
withTerminalSized(6, 4)
|
||||
.enterString("\033[1;3r\033[3;1Haaa\033[Bxxx")
|
||||
.assertLinesAre(
|
||||
" ",
|
||||
" ",
|
||||
"aaa ",
|
||||
" xxx"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
public class TerminalRowTest extends TestCase {
|
||||
|
||||
/** The properties of these code points are validated in {@link #testStaticConstants()}. */
|
||||
private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 = 0x679C;
|
||||
private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2 = 0x679D;
|
||||
private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1 = 0x2070E;
|
||||
private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2 = 0x20731;
|
||||
|
||||
/** Unicode Character 'MUSICAL SYMBOL G CLEF' (U+1D11E). Two java chars required for this. */
|
||||
static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1 = 0x1D11E;
|
||||
/** Unicode Character 'MUSICAL SYMBOL G CLEF OTTAVA ALTA' (U+1D11F). Two java chars required for this. */
|
||||
private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2 = 0x1D11F;
|
||||
|
||||
private final int COLUMNS = 80;
|
||||
|
||||
/** A combining character. */
|
||||
private static final int DIARESIS_CODEPOINT = 0x0308;
|
||||
|
||||
private TerminalRow row;
|
||||
|
||||
@Override
|
||||
protected void setUp() throws Exception {
|
||||
super.setUp();
|
||||
row = new TerminalRow(COLUMNS, TextStyle.NORMAL);
|
||||
}
|
||||
|
||||
private void assertLineStartsWith(int... codePoints) {
|
||||
char[] chars = row.mText;
|
||||
int charIndex = 0;
|
||||
for (int i = 0; i < codePoints.length; i++) {
|
||||
int lineCodePoint = chars[charIndex++];
|
||||
if (Character.isHighSurrogate((char) lineCodePoint)) {
|
||||
lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]);
|
||||
}
|
||||
assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertColumnCharIndicesStartsWith(int... indices) {
|
||||
for (int i = 0; i < indices.length; i++) {
|
||||
int expected = indices[i];
|
||||
int actual = row.findStartOfColumn(i);
|
||||
assertEquals("At index=" + i, expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
public void testSimpleDiaresis() {
|
||||
row.setChar(0, DIARESIS_CODEPOINT, 0);
|
||||
assertEquals(81, row.getSpaceUsed());
|
||||
row.setChar(0, DIARESIS_CODEPOINT, 0);
|
||||
assertEquals(82, row.getSpaceUsed());
|
||||
assertLineStartsWith(' ', DIARESIS_CODEPOINT, DIARESIS_CODEPOINT, ' ');
|
||||
}
|
||||
|
||||
public void testStaticConstants() {
|
||||
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
|
||||
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
|
||||
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
|
||||
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
|
||||
|
||||
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
|
||||
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2));
|
||||
assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
|
||||
assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2));
|
||||
|
||||
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1));
|
||||
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2));
|
||||
assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1));
|
||||
assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2));
|
||||
|
||||
assertEquals(1, Character.charCount(DIARESIS_CODEPOINT));
|
||||
assertEquals(0, WcWidth.width(DIARESIS_CODEPOINT));
|
||||
}
|
||||
|
||||
public void testOneColumn() {
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
row.setChar(0, 'a', 0);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
}
|
||||
|
||||
public void testAscii() {
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
row.setChar(0, 'a', 0);
|
||||
assertLineStartsWith('a', ' ', ' ');
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(80, row.getSpaceUsed());
|
||||
row.setChar(0, 'b', 0);
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(2, row.findStartOfColumn(2));
|
||||
assertEquals(80, row.getSpaceUsed());
|
||||
assertColumnCharIndicesStartsWith(0, 1, 2, 3);
|
||||
|
||||
char[] someChars = new char[]{'a', 'c', 'e', '4', '5', '6', '7', '8'};
|
||||
|
||||
char[] rawLine = new char[80];
|
||||
Arrays.fill(rawLine, ' ');
|
||||
Random random = new Random();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
int lineIndex = random.nextInt(rawLine.length);
|
||||
int charIndex = random.nextInt(someChars.length);
|
||||
rawLine[lineIndex] = someChars[charIndex];
|
||||
row.setChar(lineIndex, someChars[charIndex], 0);
|
||||
}
|
||||
char[] lineChars = row.mText;
|
||||
for (int i = 0; i < rawLine.length; i++) {
|
||||
assertEquals(rawLine[i], lineChars[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public void testUnicode() {
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(80, row.getSpaceUsed());
|
||||
|
||||
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
|
||||
assertEquals(81, row.getSpaceUsed());
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(2, row.findStartOfColumn(1));
|
||||
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, ' ', ' ');
|
||||
assertColumnCharIndicesStartsWith(0, 2, 3, 4);
|
||||
|
||||
row.setChar(0, 'a', 0);
|
||||
assertEquals(80, row.getSpaceUsed());
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertLineStartsWith('a', ' ', ' ');
|
||||
assertColumnCharIndicesStartsWith(0, 1, 2, 3);
|
||||
|
||||
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
|
||||
row.setChar(1, 'a', 0);
|
||||
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 'a', ' ');
|
||||
|
||||
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
|
||||
row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, 0);
|
||||
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, ' ');
|
||||
assertColumnCharIndicesStartsWith(0, 2, 4, 5);
|
||||
assertEquals(82, row.getSpaceUsed());
|
||||
}
|
||||
|
||||
public void testDoubleWidth() {
|
||||
row.setChar(0, 'a', 0);
|
||||
row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
|
||||
assertLineStartsWith('a', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' ');
|
||||
assertColumnCharIndicesStartsWith(0, 1, 1, 2);
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ' ', ' ');
|
||||
assertColumnCharIndicesStartsWith(0, 0, 1, 2);
|
||||
|
||||
row.setChar(0, ' ', 0);
|
||||
assertLineStartsWith(' ', ' ', ' ', ' ');
|
||||
assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4);
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
|
||||
assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2);
|
||||
assertColumnCharIndicesStartsWith(0, 0, 1, 1, 2);
|
||||
row.setChar(0, 'a', 0);
|
||||
assertLineStartsWith('a', ' ', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' ');
|
||||
}
|
||||
|
||||
/** Just as {@link #testDoubleWidth()} but requires a surrogate pair. */
|
||||
public void testDoubleWidthSurrogage() {
|
||||
row.setChar(0, 'a', 0);
|
||||
assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4);
|
||||
|
||||
row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0);
|
||||
assertColumnCharIndicesStartsWith(0, 1, 1, 3, 4);
|
||||
assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
|
||||
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertColumnCharIndicesStartsWith(0, 0, 2, 3, 4);
|
||||
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, ' ', ' ', ' ');
|
||||
|
||||
row.setChar(0, ' ', 0);
|
||||
assertLineStartsWith(' ', ' ', ' ', ' ');
|
||||
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0);
|
||||
row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0);
|
||||
assertLineStartsWith(' ', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
|
||||
row.setChar(0, 'a', 0);
|
||||
assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
|
||||
}
|
||||
|
||||
public void testReplacementChar() {
|
||||
row.setChar(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 0);
|
||||
row.setChar(1, 'Y', 0);
|
||||
assertLineStartsWith(TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'Y', ' ', ' ');
|
||||
}
|
||||
|
||||
public void testSurrogateCharsWithNormalDisplayWidth() {
|
||||
// These requires a UTF-16 surrogate pair, and has a display width of one.
|
||||
int first = 0x1D306;
|
||||
int second = 0x1D307;
|
||||
// Assert the above statement:
|
||||
assertEquals(2, Character.toChars(first).length);
|
||||
assertEquals(2, Character.toChars(second).length);
|
||||
|
||||
row.setChar(0, second, 0);
|
||||
assertEquals(second, Character.toCodePoint(row.mText[0], row.mText[1]));
|
||||
assertEquals(' ', row.mText[2]);
|
||||
assertEquals(2, row.findStartOfColumn(1));
|
||||
|
||||
row.setChar(0, first, 0);
|
||||
assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1]));
|
||||
assertEquals(' ', row.mText[2]);
|
||||
assertEquals(2, row.findStartOfColumn(1));
|
||||
|
||||
row.setChar(1, second, 0);
|
||||
row.setChar(2, 'a', 0);
|
||||
assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1]));
|
||||
assertEquals(second, Character.toCodePoint(row.mText[2], row.mText[3]));
|
||||
assertEquals('a', row.mText[4]);
|
||||
assertEquals(' ', row.mText[5]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(2, row.findStartOfColumn(1));
|
||||
assertEquals(4, row.findStartOfColumn(2));
|
||||
assertEquals(5, row.findStartOfColumn(3));
|
||||
assertEquals(6, row.findStartOfColumn(4));
|
||||
|
||||
row.setChar(0, ' ', 0);
|
||||
assertEquals(' ', row.mText[0]);
|
||||
assertEquals(second, Character.toCodePoint(row.mText[1], row.mText[2]));
|
||||
assertEquals('a', row.mText[3]);
|
||||
assertEquals(' ', row.mText[4]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(3, row.findStartOfColumn(2));
|
||||
assertEquals(4, row.findStartOfColumn(3));
|
||||
assertEquals(5, row.findStartOfColumn(4));
|
||||
|
||||
for (int i = 0; i < 80; i++) {
|
||||
row.setChar(i, i % 2 == 0 ? first : second, 0);
|
||||
}
|
||||
for (int i = 0; i < 80; i++) {
|
||||
int idx = row.findStartOfColumn(i);
|
||||
assertEquals(i % 2 == 0 ? first : second, Character.toCodePoint(row.mText[idx], row.mText[idx + 1]));
|
||||
}
|
||||
for (int i = 0; i < 80; i++) {
|
||||
row.setChar(i, i % 2 == 0 ? 'a' : 'b', 0);
|
||||
}
|
||||
for (int i = 0; i < 80; i++) {
|
||||
int idx = row.findStartOfColumn(i);
|
||||
assertEquals(i, idx);
|
||||
assertEquals(i % 2 == 0 ? 'a' : 'b', row.mText[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public void testOverwritingDoubleDisplayWidthWithNormalDisplayWidth() {
|
||||
// Initial "OO "
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
assertEquals(' ', row.mText[1]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(0, row.findStartOfColumn(1));
|
||||
assertEquals(1, row.findStartOfColumn(2));
|
||||
|
||||
// Setting first column to a clears second: "a "
|
||||
row.setChar(0, 'a', 0);
|
||||
assertEquals('a', row.mText[0]);
|
||||
assertEquals(' ', row.mText[1]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(2, row.findStartOfColumn(2));
|
||||
|
||||
// Back to initial "OO "
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
assertEquals(' ', row.mText[1]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(0, row.findStartOfColumn(1));
|
||||
assertEquals(1, row.findStartOfColumn(2));
|
||||
|
||||
// Setting first column to a clears first: " a "
|
||||
row.setChar(1, 'a', 0);
|
||||
assertEquals(' ', row.mText[0]);
|
||||
assertEquals('a', row.mText[1]);
|
||||
assertEquals(' ', row.mText[2]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(2, row.findStartOfColumn(2));
|
||||
}
|
||||
|
||||
public void testOverwritingDoubleDisplayWidthWithSelf() {
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
assertEquals(' ', row.mText[1]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(0, row.findStartOfColumn(1));
|
||||
assertEquals(1, row.findStartOfColumn(2));
|
||||
}
|
||||
|
||||
public void testNormalCharsWithDoubleDisplayWidth() {
|
||||
// These fit in one java char, and has a display width of two.
|
||||
assertTrue(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 != ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2);
|
||||
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
|
||||
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
|
||||
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
|
||||
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
|
||||
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
assertEquals(0, row.findStartOfColumn(1));
|
||||
assertEquals(' ', row.mText[1]);
|
||||
|
||||
row.setChar(0, 'a', 0);
|
||||
assertEquals('a', row.mText[0]);
|
||||
assertEquals(' ', row.mText[1]);
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
// The first character fills both first columns.
|
||||
assertEquals(0, row.findStartOfColumn(1));
|
||||
row.setChar(2, 'a', 0);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
assertEquals('a', row.mText[1]);
|
||||
assertEquals(1, row.findStartOfColumn(2));
|
||||
|
||||
row.setChar(0, 'c', 0);
|
||||
assertEquals('c', row.mText[0]);
|
||||
assertEquals(' ', row.mText[1]);
|
||||
assertEquals('a', row.mText[2]);
|
||||
assertEquals(' ', row.mText[3]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(2, row.findStartOfColumn(2));
|
||||
}
|
||||
|
||||
public void testNormalCharsWithDoubleDisplayWidthOverlapping() {
|
||||
// These fit in one java char, and has a display width of two.
|
||||
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
|
||||
row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
|
||||
row.setChar(4, 'a', 0);
|
||||
// O = ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO
|
||||
// A = ANOTHER_JAVA_CHAR_DISPLAY_WIDTH_TWO
|
||||
// "OOAAa "
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(0, row.findStartOfColumn(1));
|
||||
assertEquals(1, row.findStartOfColumn(2));
|
||||
assertEquals(1, row.findStartOfColumn(3));
|
||||
assertEquals(2, row.findStartOfColumn(4));
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]);
|
||||
assertEquals('a', row.mText[2]);
|
||||
assertEquals(' ', row.mText[3]);
|
||||
|
||||
row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
|
||||
// " AA a "
|
||||
assertEquals(' ', row.mText[0]);
|
||||
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]);
|
||||
assertEquals(' ', row.mText[2]);
|
||||
assertEquals('a', row.mText[3]);
|
||||
assertEquals(' ', row.mText[4]);
|
||||
assertEquals(0, row.findStartOfColumn(0));
|
||||
assertEquals(1, row.findStartOfColumn(1));
|
||||
assertEquals(1, row.findStartOfColumn(2));
|
||||
assertEquals(2, row.findStartOfColumn(3));
|
||||
assertEquals(3, row.findStartOfColumn(4));
|
||||
}
|
||||
|
||||
// https://github.com/jackpal/Android-Terminal-Emulator/issues/145
|
||||
public void testCrashATE145() {
|
||||
// 0xC2541 is unassigned, use display width 1 for UNICODE_REPLACEMENT_CHAR.
|
||||
// assertEquals(1, WcWidth.width(0xC2541));
|
||||
assertEquals(2, Character.charCount(0xC2541));
|
||||
|
||||
assertEquals(2, WcWidth.width(0x73EE));
|
||||
assertEquals(1, Character.charCount(0x73EE));
|
||||
|
||||
assertEquals(0, WcWidth.width(0x009F));
|
||||
assertEquals(1, Character.charCount(0x009F));
|
||||
|
||||
int[] points = new int[]{0xC2541, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD, 'B', 0x009B, 0x61C9, 'Z'};
|
||||
// int[] expected = new int[] { TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD,
|
||||
// 'B', 0x009B, 0x61C9, 'Z' };
|
||||
int currentColumn = 0;
|
||||
for (int point : points) {
|
||||
row.setChar(currentColumn, point, 0);
|
||||
currentColumn += WcWidth.width(point);
|
||||
}
|
||||
// assertLineStartsWith(points);
|
||||
// assertEquals(Character.highSurrogate(0xC2541), line.mText[0]);
|
||||
// assertEquals(Character.lowSurrogate(0xC2541), line.mText[1]);
|
||||
// assertEquals('a', line.mText[2]);
|
||||
// assertEquals('8', line.mText[3]);
|
||||
// assertEquals(Character.highSurrogate(0x73EE), line.mText[4]);
|
||||
// assertEquals(Character.lowSurrogate(0x73EE), line.mText[5]);
|
||||
//
|
||||
// char[] chars = line.mText;
|
||||
// int charIndex = 0;
|
||||
// for (int i = 0; i < points.length; i++) {
|
||||
// char c = chars[charIndex];
|
||||
// charIndex++;
|
||||
// int thisPoint = (int) c;
|
||||
// if (Character.isHighSurrogate(c)) {
|
||||
// thisPoint = Character.toCodePoint(c, chars[charIndex]);
|
||||
// charIndex++;
|
||||
// }
|
||||
// assertEquals("At index=" + i + ", charIndex=" + charIndex + ", char=" + (char) thisPoint, points[i], thisPoint);
|
||||
// }
|
||||
}
|
||||
|
||||
public void testNormalization() {
|
||||
// int lowerCaseN = 0x006E;
|
||||
// int combiningTilde = 0x0303;
|
||||
// int combined = 0x00F1;
|
||||
row.setChar(0, 0x006E, 0);
|
||||
assertEquals(80, row.getSpaceUsed());
|
||||
row.setChar(0, 0x0303, 0);
|
||||
assertEquals(81, row.getSpaceUsed());
|
||||
// assertEquals("\u00F1 ", new String(term.getScreen().getLine(0)));
|
||||
assertLineStartsWith(0x006E, 0x0303, ' ');
|
||||
}
|
||||
|
||||
public void testInsertWideAtLastColumn() {
|
||||
row.setChar(COLUMNS - 2, 'Z', 0);
|
||||
row.setChar(COLUMNS - 1, 'a', 0);
|
||||
assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]);
|
||||
assertEquals('a', row.mText[row.findStartOfColumn(COLUMNS - 1)]);
|
||||
row.setChar(COLUMNS - 1, 'ö', 0);
|
||||
assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]);
|
||||
assertEquals('ö', row.mText[row.findStartOfColumn(COLUMNS - 1)]);
|
||||
// line.setChar(COLUMNS - 1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1);
|
||||
// assertEquals('Z', line.mText[line.findStartOfColumn(COLUMNS - 2)]);
|
||||
// assertEquals(' ', line.mText[line.findStartOfColumn(COLUMNS - 1)]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
|
||||
public class TerminalTest extends TerminalTestCase {
|
||||
|
||||
public void testCursorPositioning() throws Exception {
|
||||
withTerminalSized(10, 10).placeCursorAndAssert(1, 2).placeCursorAndAssert(3, 5).placeCursorAndAssert(2, 2).enterString("A")
|
||||
.assertCursorAt(2, 3);
|
||||
}
|
||||
|
||||
public void testScreen() throws UnsupportedEncodingException {
|
||||
withTerminalSized(3, 3);
|
||||
assertLinesAre(" ", " ", " ");
|
||||
|
||||
assertEquals("", mTerminal.getScreen().getTranscriptText());
|
||||
enterString("hi").assertLinesAre("hi ", " ", " ");
|
||||
assertEquals("hi", mTerminal.getScreen().getTranscriptText());
|
||||
enterString("\r\nu");
|
||||
assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText());
|
||||
mTerminal.reset();
|
||||
assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText());
|
||||
|
||||
withTerminalSized(3, 3).enterString("hello");
|
||||
assertEquals("hello", mTerminal.getScreen().getTranscriptText());
|
||||
enterString("\r\nworld");
|
||||
assertEquals("hello\nworld", mTerminal.getScreen().getTranscriptText());
|
||||
}
|
||||
|
||||
public void testScrollDownInAltBuffer() {
|
||||
withTerminalSized(3, 3).enterString("\033[?1049h");
|
||||
enterString("\033[38;5;111m1\r\n");
|
||||
enterString("\033[38;5;112m2\r\n");
|
||||
enterString("\033[38;5;113m3\r\n");
|
||||
enterString("\033[38;5;114m4\r\n");
|
||||
enterString("\033[38;5;115m5");
|
||||
assertLinesAre("3 ", "4 ", "5 ");
|
||||
assertForegroundColorAt(0, 0, 113);
|
||||
assertForegroundColorAt(1, 0, 114);
|
||||
assertForegroundColorAt(2, 0, 115);
|
||||
}
|
||||
|
||||
public void testMouseClick() throws Exception {
|
||||
withTerminalSized(10, 10);
|
||||
assertFalse(mTerminal.isMouseTrackingActive());
|
||||
enterString("\033[?1000h");
|
||||
assertTrue(mTerminal.isMouseTrackingActive());
|
||||
enterString("\033[?1000l");
|
||||
assertFalse(mTerminal.isMouseTrackingActive());
|
||||
enterString("\033[?1000h");
|
||||
assertTrue(mTerminal.isMouseTrackingActive());
|
||||
|
||||
enterString("\033[?1006h");
|
||||
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, true);
|
||||
assertEquals("\033[<0;3;4M", mOutput.getOutputAndClear());
|
||||
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, false);
|
||||
assertEquals("\033[<0;3;4m", mOutput.getOutputAndClear());
|
||||
|
||||
// When the client says that a click is outside (which could happen when pixels are outside
|
||||
// the terminal area, see https://github.com/termux/termux-app/issues/501) the terminal
|
||||
// sends a click at the edge.
|
||||
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 0, 0, true);
|
||||
assertEquals("\033[<0;1;1M", mOutput.getOutputAndClear());
|
||||
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 11, 11, false);
|
||||
assertEquals("\033[<0;10;10m", mOutput.getOutputAndClear());
|
||||
}
|
||||
|
||||
public void testNormalization() throws UnsupportedEncodingException {
|
||||
// int lowerCaseN = 0x006E;
|
||||
// int combiningTilde = 0x0303;
|
||||
// int combined = 0x00F1;
|
||||
withTerminalSized(3, 3).assertLinesAre(" ", " ", " ");
|
||||
enterString("\u006E\u0303");
|
||||
assertEquals(1, WcWidth.width("\u006E\u0303".toCharArray(), 0));
|
||||
// assertEquals("\u00F1 ", new String(mTerminal.getScreen().getLine(0)));
|
||||
assertLinesAre("\u006E\u0303 ", " ", " ");
|
||||
}
|
||||
|
||||
/** On "\e[18t" xterm replies with "\e[8;${HEIGHT};${WIDTH}t" */
|
||||
public void testReportTerminalSize() throws Exception {
|
||||
withTerminalSized(5, 5);
|
||||
assertEnteringStringGivesResponse("\033[18t", "\033[8;5;5t");
|
||||
for (int width = 3; width < 12; width++) {
|
||||
for (int height = 3; height < 12; height++) {
|
||||
mTerminal.resize(width, height);
|
||||
assertEnteringStringGivesResponse("\033[18t", "\033[8;" + height + ";" + width + "t");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Device Status Report (DSR) and Report Cursor Position (CPR). */
|
||||
public void testDeviceStatusReport() throws Exception {
|
||||
withTerminalSized(5, 5);
|
||||
assertEnteringStringGivesResponse("\033[5n", "\033[0n");
|
||||
|
||||
assertEnteringStringGivesResponse("\033[6n", "\033[1;1R");
|
||||
enterString("AB");
|
||||
assertEnteringStringGivesResponse("\033[6n", "\033[1;3R");
|
||||
enterString("\r\n");
|
||||
assertEnteringStringGivesResponse("\033[6n", "\033[2;1R");
|
||||
}
|
||||
|
||||
/** Test the cursor shape changes using DECSCUSR. */
|
||||
public void testSetCursorStyle() throws Exception {
|
||||
withTerminalSized(5, 5);
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
|
||||
enterString("\033[3 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
|
||||
enterString("\033[5 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle());
|
||||
enterString("\033[0 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
|
||||
enterString("\033[6 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle());
|
||||
enterString("\033[4 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
|
||||
enterString("\033[1 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
|
||||
enterString("\033[4 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
|
||||
enterString("\033[2 q");
|
||||
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
|
||||
}
|
||||
|
||||
public void testPaste() {
|
||||
withTerminalSized(5, 5);
|
||||
mTerminal.paste("hi");
|
||||
assertEquals("hi", mOutput.getOutputAndClear());
|
||||
|
||||
enterString("\033[?2004h");
|
||||
mTerminal.paste("hi");
|
||||
assertEquals("\033[200~" + "hi" + "\033[201~", mOutput.getOutputAndClear());
|
||||
|
||||
enterString("\033[?2004l");
|
||||
mTerminal.paste("hi");
|
||||
assertEquals("hi", mOutput.getOutputAndClear());
|
||||
}
|
||||
|
||||
public void testSelectGraphics() {
|
||||
withTerminalSized(5, 5);
|
||||
enterString("\033[31m");
|
||||
assertEquals(mTerminal.mForeColor, 1);
|
||||
enterString("\033[32m");
|
||||
assertEquals(mTerminal.mForeColor, 2);
|
||||
enterString("\033[43m");
|
||||
assertEquals(2, mTerminal.mForeColor);
|
||||
assertEquals(3, mTerminal.mBackColor);
|
||||
|
||||
// SGR 0 should reset both foreground and background color.
|
||||
enterString("\033[0m");
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
|
||||
|
||||
// Test CSI resetting to default if sequence starts with ; or has sequential ;;
|
||||
// Check TerminalEmulator.parseArg()
|
||||
enterString("\033[31m\033[m");
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
enterString("\033[31m\033[;m");
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
enterString("\033[31m\033[0m");
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
enterString("\033[31m\033[0;m");
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
enterString("\033[31;;m");
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
|
||||
// 256 colors:
|
||||
enterString("\033[38;5;119m");
|
||||
assertEquals(119, mTerminal.mForeColor);
|
||||
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
|
||||
enterString("\033[48;5;129m");
|
||||
assertEquals(119, mTerminal.mForeColor);
|
||||
assertEquals(129, mTerminal.mBackColor);
|
||||
|
||||
// Invalid parameter:
|
||||
enterString("\033[48;8;129m");
|
||||
assertEquals(119, mTerminal.mForeColor);
|
||||
assertEquals(129, mTerminal.mBackColor);
|
||||
|
||||
// Multiple parameters at once:
|
||||
enterString("\033[38;5;178;48;5;179;m");
|
||||
assertEquals(178, mTerminal.mForeColor);
|
||||
assertEquals(179, mTerminal.mBackColor);
|
||||
|
||||
// 24 bit colors:
|
||||
enterString(("\033[0m")); // Reset fg and bg colors.
|
||||
enterString("\033[38;2;255;127;2m");
|
||||
int expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2;
|
||||
assertEquals(expectedForeground, mTerminal.mForeColor);
|
||||
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
|
||||
enterString("\033[48;2;1;2;254m");
|
||||
int expectedBackground = 0xff000000 | (1 << 16) | (2 << 8) | 254;
|
||||
assertEquals(expectedForeground, mTerminal.mForeColor);
|
||||
assertEquals(expectedBackground, mTerminal.mBackColor);
|
||||
|
||||
// 24 bit colors, set fg and bg at once:
|
||||
enterString(("\033[0m")); // Reset fg and bg colors.
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
|
||||
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
|
||||
enterString("\033[38;2;255;127;2;48;2;1;2;254m");
|
||||
assertEquals(expectedForeground, mTerminal.mForeColor);
|
||||
assertEquals(expectedBackground, mTerminal.mBackColor);
|
||||
|
||||
// 24 bit colors, invalid input:
|
||||
enterString("\033[38;2;300;127;2;48;2;1;300;254m");
|
||||
assertEquals(expectedForeground, mTerminal.mForeColor);
|
||||
assertEquals(expectedBackground, mTerminal.mBackColor);
|
||||
}
|
||||
|
||||
public void testBackgroundColorErase() {
|
||||
final int rows = 3;
|
||||
final int cols = 3;
|
||||
withTerminalSized(cols, rows);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
long style = getStyleAt(r, c);
|
||||
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.decodeForeColor(style));
|
||||
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(style));
|
||||
}
|
||||
}
|
||||
// Foreground color to 119:
|
||||
enterString("\033[38;5;119m");
|
||||
// Background color to 129:
|
||||
enterString("\033[48;5;129m");
|
||||
// Clear with ED, Erase in Display:
|
||||
enterString("\033[2J");
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
long style = getStyleAt(r, c);
|
||||
assertEquals(119, TextStyle.decodeForeColor(style));
|
||||
assertEquals(129, TextStyle.decodeBackColor(style));
|
||||
}
|
||||
}
|
||||
// Background color to 139:
|
||||
enterString("\033[48;5;139m");
|
||||
// Insert two blank lines.
|
||||
enterString("\033[2L");
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
long style = getStyleAt(r, c);
|
||||
assertEquals((r == 0 || r == 1) ? 139 : 129, TextStyle.decodeBackColor(style));
|
||||
}
|
||||
}
|
||||
|
||||
withTerminalSized(cols, rows);
|
||||
// Background color to 129:
|
||||
enterString("\033[48;5;129m");
|
||||
// Erase two characters, filling them with background color:
|
||||
enterString("\033[2X");
|
||||
assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 0)));
|
||||
assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 1)));
|
||||
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(getStyleAt(0, 2)));
|
||||
}
|
||||
|
||||
public void testParseColor() {
|
||||
assertEquals(0xFF0000FA, TerminalColors.parse("#0000FA"));
|
||||
assertEquals(0xFF000000, TerminalColors.parse("#000000"));
|
||||
assertEquals(0xFF000000, TerminalColors.parse("#000"));
|
||||
assertEquals(0xFF000000, TerminalColors.parse("#000000000"));
|
||||
assertEquals(0xFF53186f, TerminalColors.parse("#53186f"));
|
||||
|
||||
assertEquals(0xFFFF00FF, TerminalColors.parse("rgb:F/0/F"));
|
||||
assertEquals(0xFF0000FA, TerminalColors.parse("rgb:00/00/FA"));
|
||||
assertEquals(0xFF53186f, TerminalColors.parse("rgb:53/18/6f"));
|
||||
|
||||
assertEquals(0, TerminalColors.parse("invalid_0000FA"));
|
||||
assertEquals(0, TerminalColors.parse("#3456"));
|
||||
}
|
||||
|
||||
/** The ncurses library still uses this. */
|
||||
public void testLineDrawing() {
|
||||
// 016 - shift out / G1. 017 - shift in / G0. "ESC ) 0" - use line drawing for G1
|
||||
withTerminalSized(4, 2).enterString("q\033)0q\016q\017q").assertLinesAre("qq─q", " ");
|
||||
// "\0337", saving cursor should save G0, G1 and invoked charset and "ESC 8" should restore.
|
||||
withTerminalSized(4, 2).enterString("\033)0\016qqq\0337\017\0338q").assertLinesAre("────", " ");
|
||||
}
|
||||
|
||||
public void testSoftTerminalReset() {
|
||||
// See http://vt100.net/docs/vt510-rm/DECSTR and https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=650304
|
||||
// "\033[?7l" is DECRST to disable wrap-around, and DECSTR ("\033[!p") should reset it.
|
||||
withTerminalSized(3, 3).enterString("\033[?7lABCD").assertLinesAre("ABD", " ", " ");
|
||||
enterString("\033[!pEF").assertLinesAre("ABE", "F ", " ");
|
||||
}
|
||||
|
||||
public void testBel() {
|
||||
withTerminalSized(3, 3);
|
||||
assertEquals(0, mOutput.bellsRung);
|
||||
enterString("\07");
|
||||
assertEquals(1, mOutput.bellsRung);
|
||||
enterString("hello\07");
|
||||
assertEquals(2, mOutput.bellsRung);
|
||||
enterString("\07hello");
|
||||
assertEquals(3, mOutput.bellsRung);
|
||||
enterString("hello\07world");
|
||||
assertEquals(4, mOutput.bellsRung);
|
||||
}
|
||||
|
||||
public void testAutomargins() throws UnsupportedEncodingException {
|
||||
withTerminalSized(3, 3).enterString("abc").assertLinesAre("abc", " ", " ").assertCursorAt(0, 2);
|
||||
enterString("d").assertLinesAre("abc", "d ", " ").assertCursorAt(1, 1);
|
||||
|
||||
withTerminalSized(3, 3).enterString("abc\r ").assertLinesAre(" bc", " ", " ").assertCursorAt(0, 1);
|
||||
}
|
||||
|
||||
public void testTab() {
|
||||
withTerminalSized(11, 2).enterString("01234567890\r\tXX").assertLinesAre("01234567XX0", " ");
|
||||
withTerminalSized(11, 2).enterString("01234567890\033[44m\r\tXX").assertLinesAre("01234567XX0", " ");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import junit.framework.AssertionFailedError;
|
||||
import junit.framework.TestCase;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class TerminalTestCase extends TestCase {
|
||||
|
||||
public static class MockTerminalOutput extends TerminalOutput {
|
||||
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
public final List<ChangedTitle> titleChanges = new ArrayList<>();
|
||||
public final List<String> clipboardPuts = new ArrayList<>();
|
||||
public int bellsRung = 0;
|
||||
public int colorsChanged = 0;
|
||||
|
||||
@Override
|
||||
public void write(byte[] data, int offset, int count) {
|
||||
baos.write(data, offset, count);
|
||||
}
|
||||
|
||||
public String getOutputAndClear() {
|
||||
String result = new String(baos.toByteArray(), StandardCharsets.UTF_8);
|
||||
baos.reset();
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void titleChanged(String oldTitle, String newTitle) {
|
||||
titleChanges.add(new ChangedTitle(oldTitle, newTitle));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyTextToClipboard(String text) {
|
||||
clipboardPuts.add(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPasteTextFromClipboard() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBell() {
|
||||
bellsRung++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorsChanged() {
|
||||
colorsChanged++;
|
||||
}
|
||||
}
|
||||
|
||||
public TerminalEmulator mTerminal;
|
||||
public MockTerminalOutput mOutput;
|
||||
|
||||
public static final class ChangedTitle {
|
||||
final String oldTitle;
|
||||
final String newTitle;
|
||||
|
||||
public ChangedTitle(String oldTitle, String newTitle) {
|
||||
this.oldTitle = oldTitle;
|
||||
this.newTitle = newTitle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof ChangedTitle)) return false;
|
||||
ChangedTitle other = (ChangedTitle) o;
|
||||
return Objects.equals(oldTitle, other.oldTitle) && Objects.equals(newTitle, other.newTitle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(oldTitle, newTitle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ChangedTitle[oldTitle=" + oldTitle + ", newTitle=" + newTitle + "]";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public TerminalTestCase enterString(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
mTerminal.append(bytes, bytes.length);
|
||||
assertInvariants();
|
||||
return this;
|
||||
}
|
||||
|
||||
public void assertEnteringStringGivesResponse(String input, String expectedResponse) {
|
||||
enterString(input);
|
||||
String response = mOutput.getOutputAndClear();
|
||||
assertEquals(expectedResponse, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setUp() throws Exception {
|
||||
super.setUp();
|
||||
mOutput = new MockTerminalOutput();
|
||||
}
|
||||
|
||||
protected TerminalTestCase withTerminalSized(int columns, int rows) {
|
||||
// The tests aren't currently using the client, so a null client will suffice, a dummy client should be implemented if needed
|
||||
mTerminal = new TerminalEmulator(mOutput, columns, rows, rows * 2, null);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void assertHistoryStartsWith(String... rows) {
|
||||
assertTrue("About to check " + rows.length + " lines, but only " + mTerminal.getScreen().getActiveTranscriptRows() + " in history",
|
||||
mTerminal.getScreen().getActiveTranscriptRows() >= rows.length);
|
||||
for (int i = 0; i < rows.length; i++) {
|
||||
assertLineIs(-i - 1, rows[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LineWrapper {
|
||||
final TerminalRow mLine;
|
||||
|
||||
public LineWrapper(TerminalRow line) {
|
||||
mLine = line;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return System.identityHashCode(mLine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return o instanceof LineWrapper && ((LineWrapper) o).mLine == mLine;
|
||||
}
|
||||
}
|
||||
|
||||
protected TerminalTestCase assertInvariants() {
|
||||
TerminalBuffer screen = mTerminal.getScreen();
|
||||
TerminalRow[] lines = screen.mLines;
|
||||
|
||||
Set<LineWrapper> linesSet = new HashSet<>();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (lines[i] == null) continue;
|
||||
assertTrue("Line exists at multiple places: " + i, linesSet.add(new LineWrapper(lines[i])));
|
||||
char[] text = lines[i].mText;
|
||||
int usedChars = lines[i].getSpaceUsed();
|
||||
int currentColumn = 0;
|
||||
for (int j = 0; j < usedChars; j++) {
|
||||
char c = text[j];
|
||||
int codePoint;
|
||||
if (Character.isHighSurrogate(c)) {
|
||||
char lowSurrogate = text[++j];
|
||||
assertTrue("High surrogate without following low surrogate", Character.isLowSurrogate(lowSurrogate));
|
||||
codePoint = Character.toCodePoint(c, lowSurrogate);
|
||||
} else {
|
||||
assertFalse("Low surrogate without preceding high surrogate", Character.isLowSurrogate(c));
|
||||
codePoint = c;
|
||||
}
|
||||
assertFalse("Screen should never contain unassigned characters", Character.getType(codePoint) == Character.UNASSIGNED);
|
||||
int width = WcWidth.width(codePoint);
|
||||
assertFalse("The first column should not start with combining character", currentColumn == 0 && width < 0);
|
||||
if (width > 0) currentColumn += width;
|
||||
}
|
||||
assertEquals("Line whose width does not match screens. line=" + new String(lines[i].mText, 0, lines[i].getSpaceUsed()),
|
||||
screen.mColumns, currentColumn);
|
||||
}
|
||||
|
||||
assertEquals("The alt buffer should have have no history", mTerminal.mAltBuffer.mTotalRows, mTerminal.mAltBuffer.mScreenRows);
|
||||
if (mTerminal.isAlternateBufferActive()) {
|
||||
assertEquals("The alt buffer should be the same size as the screen", mTerminal.mRows, mTerminal.mAltBuffer.mTotalRows);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected void assertLineIs(int line, String expected) {
|
||||
TerminalRow l = mTerminal.getScreen().allocateFullLineIfNecessary(mTerminal.getScreen().externalToInternalRow(line));
|
||||
char[] chars = l.mText;
|
||||
int textLen = l.getSpaceUsed();
|
||||
if (textLen != expected.length()) fail("Expected '" + expected + "' (len=" + expected.length() + "), was='"
|
||||
+ new String(chars, 0, textLen) + "' (len=" + textLen + ")");
|
||||
for (int i = 0; i < textLen; i++) {
|
||||
if (expected.charAt(i) != chars[i])
|
||||
fail("Expected '" + expected + "', was='" + new String(chars, 0, textLen) + "' - first different at index=" + i);
|
||||
}
|
||||
}
|
||||
|
||||
public TerminalTestCase assertLinesAre(String... lines) {
|
||||
assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
|
||||
for (int i = 0; i < lines.length; i++)
|
||||
try {
|
||||
assertLineIs(i, lines[i]);
|
||||
} catch (AssertionFailedError e) {
|
||||
throw new AssertionFailedError("Line: " + i + " - " + e.getMessage());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public TerminalTestCase resize(int cols, int rows) {
|
||||
mTerminal.resize(cols, rows);
|
||||
assertInvariants();
|
||||
return this;
|
||||
}
|
||||
|
||||
public TerminalTestCase assertLineWraps(boolean... lines) {
|
||||
for (int i = 0; i < lines.length; i++)
|
||||
assertEquals("line=" + i, lines[i], mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(i)].mLineWrap);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected TerminalTestCase assertLineStartsWith(int line, int... codePoints) {
|
||||
char[] chars = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(line)].mText;
|
||||
int charIndex = 0;
|
||||
for (int i = 0; i < codePoints.length; i++) {
|
||||
int lineCodePoint = chars[charIndex++];
|
||||
if (Character.isHighSurrogate((char) lineCodePoint)) {
|
||||
lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]);
|
||||
}
|
||||
assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
protected TerminalTestCase placeCursorAndAssert(int row, int col) {
|
||||
// +1 due to escape sequence being one based.
|
||||
enterString("\033[" + (row + 1) + ";" + (col + 1) + "H");
|
||||
assertCursorAt(row, col);
|
||||
return this;
|
||||
}
|
||||
|
||||
public TerminalTestCase assertCursorAt(int row, int col) {
|
||||
int actualRow = mTerminal.getCursorRow();
|
||||
int actualCol = mTerminal.getCursorCol();
|
||||
if (!(row == actualRow && col == actualCol))
|
||||
fail("Expected cursor at (row,col)=(" + row + ", " + col + ") but was (" + actualRow + ", " + actualCol + ")");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** For testing only. Encoded style according to {@link TextStyle}. */
|
||||
public long getStyleAt(int externalRow, int column) {
|
||||
return mTerminal.getScreen().getStyleAt(externalRow, column);
|
||||
}
|
||||
|
||||
public static class EffectLine {
|
||||
final int[] styles;
|
||||
|
||||
public EffectLine(int[] styles) {
|
||||
this.styles = styles;
|
||||
}
|
||||
}
|
||||
|
||||
protected EffectLine effectLine(int... bits) {
|
||||
return new EffectLine(bits);
|
||||
}
|
||||
|
||||
public TerminalTestCase assertEffectAttributesSet(EffectLine... lines) {
|
||||
assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
int[] line = lines[i].styles;
|
||||
for (int j = 0; j < line.length; j++) {
|
||||
int effectsAtCell = TextStyle.decodeEffect(getStyleAt(i, j));
|
||||
int attributes = line[j];
|
||||
if ((effectsAtCell & attributes) != attributes) fail("Line=" + i + ", column=" + j + ", expected "
|
||||
+ describeStyle(attributes) + " set, was " + describeStyle(effectsAtCell));
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public TerminalTestCase assertForegroundIndices(EffectLine... lines) {
|
||||
assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
int[] line = lines[i].styles;
|
||||
for (int j = 0; j < line.length; j++) {
|
||||
int actualColor = TextStyle.decodeForeColor(getStyleAt(i, j));
|
||||
int expectedColor = line[j];
|
||||
if (actualColor != expectedColor) fail("Line=" + i + ", column=" + j + ", expected color "
|
||||
+ Integer.toHexString(expectedColor) + " set, was " + Integer.toHexString(actualColor));
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private static String describeStyle(int styleBits) {
|
||||
return "'" + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BLINK) != 0 ? ":BLINK:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BOLD) != 0 ? ":BOLD:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0 ? ":INVERSE:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) != 0 ? ":INVISIBLE:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0 ? ":ITALIC:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0 ? ":PROTECTED:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0 ? ":STRIKETHROUGH:" : "")
|
||||
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0 ? ":UNDERLINE:" : "") + "'";
|
||||
}
|
||||
|
||||
public void assertForegroundColorAt(int externalRow, int column, int color) {
|
||||
long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column);
|
||||
assertEquals(color, TextStyle.decodeForeColor(style));
|
||||
}
|
||||
|
||||
public TerminalTestCase assertColor(int colorIndex, int expected) {
|
||||
int actual = mTerminal.mColors.mCurrentColors[colorIndex];
|
||||
if (expected != actual) {
|
||||
fail("Color index=" + colorIndex + ", expected=" + Integer.toHexString(expected) + ", was=" + Integer.toHexString(actual));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
public class TextStyleTest extends TestCase {
|
||||
|
||||
private static final int[] ALL_EFFECTS = new int[]{0, TextStyle.CHARACTER_ATTRIBUTE_BOLD, TextStyle.CHARACTER_ATTRIBUTE_ITALIC,
|
||||
TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.CHARACTER_ATTRIBUTE_BLINK, TextStyle.CHARACTER_ATTRIBUTE_INVERSE,
|
||||
TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE, TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH, TextStyle.CHARACTER_ATTRIBUTE_PROTECTED,
|
||||
TextStyle.CHARACTER_ATTRIBUTE_DIM};
|
||||
|
||||
public void testEncodingSingle() {
|
||||
for (int fx : ALL_EFFECTS) {
|
||||
for (int fg = 0; fg < TextStyle.NUM_INDEXED_COLORS; fg++) {
|
||||
for (int bg = 0; bg < TextStyle.NUM_INDEXED_COLORS; bg++) {
|
||||
long encoded = TextStyle.encode(fg, bg, fx);
|
||||
assertEquals(fg, TextStyle.decodeForeColor(encoded));
|
||||
assertEquals(bg, TextStyle.decodeBackColor(encoded));
|
||||
assertEquals(fx, TextStyle.decodeEffect(encoded));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void testEncoding24Bit() {
|
||||
int[] values = {255, 240, 127, 1, 0};
|
||||
for (int red : values) {
|
||||
for (int green : values) {
|
||||
for (int blue : values) {
|
||||
int argb = 0xFF000000 | (red << 16) | (green << 8) | blue;
|
||||
long encoded = TextStyle.encode(argb, 0, 0);
|
||||
assertEquals(argb, TextStyle.decodeForeColor(encoded));
|
||||
encoded = TextStyle.encode(0, argb, 0);
|
||||
assertEquals(argb, TextStyle.decodeBackColor(encoded));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void testEncodingCombinations() {
|
||||
for (int f1 : ALL_EFFECTS) {
|
||||
for (int f2 : ALL_EFFECTS) {
|
||||
int combined = f1 | f2;
|
||||
assertEquals(combined, TextStyle.decodeEffect(TextStyle.encode(0, 0, combined)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void testEncodingStrikeThrough() {
|
||||
long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
|
||||
TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH);
|
||||
assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0);
|
||||
}
|
||||
|
||||
public void testEncodingProtected() {
|
||||
long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
|
||||
TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH);
|
||||
assertEquals(0, (TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED));
|
||||
encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
|
||||
TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH | TextStyle.CHARACTER_ATTRIBUTE_PROTECTED);
|
||||
assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
|
||||
public class UnicodeInputTest extends TerminalTestCase {
|
||||
|
||||
public void testIllFormedUtf8SuccessorByteNotConsumed() throws Exception {
|
||||
// The Unicode Standard Version 6.2 – Core Specification (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf):
|
||||
// "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first byte, but which does not
|
||||
// continue with valid successor bytes (see Table 3-7), it must not consume the successor bytes as part of the ill-formed
|
||||
// subsequence whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit subsequence."
|
||||
withTerminalSized(5, 5);
|
||||
mTerminal.append(new byte[]{(byte) 0b11101111, (byte) 'a'}, 2);
|
||||
assertLineIs(0, ((char) TerminalEmulator.UNICODE_REPLACEMENT_CHAR) + "a ");
|
||||
|
||||
// https://code.google.com/p/chromium/issues/detail?id=212704
|
||||
byte[] input = new byte[]{
|
||||
(byte) 0x61, (byte) 0xF1,
|
||||
(byte) 0x80, (byte) 0x80,
|
||||
(byte) 0xe1, (byte) 0x80,
|
||||
(byte) 0xc2, (byte) 0x62,
|
||||
(byte) 0x80, (byte) 0x63,
|
||||
(byte) 0x80, (byte) 0xbf,
|
||||
(byte) 0x64
|
||||
};
|
||||
withTerminalSized(10, 2);
|
||||
mTerminal.append(input, input.length);
|
||||
assertLinesAre("a\uFFFD\uFFFD\uFFFDb\uFFFDc\uFFFD\uFFFDd", " ");
|
||||
|
||||
// Surrogate pairs.
|
||||
withTerminalSized(5, 2);
|
||||
input = new byte[]{
|
||||
(byte) 0xed, (byte) 0xa0,
|
||||
(byte) 0x80, (byte) 0xed,
|
||||
(byte) 0xad, (byte) 0xbf,
|
||||
(byte) 0xed, (byte) 0xae,
|
||||
(byte) 0x80, (byte) 0xed,
|
||||
(byte) 0xbf, (byte) 0xbf
|
||||
};
|
||||
mTerminal.append(input, input.length);
|
||||
assertLinesAre("\uFFFD\uFFFD\uFFFD\uFFFD ", " ");
|
||||
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=746900: "with this patch 0xe0 0x80 is decoded as two U+FFFDs,
|
||||
// but 0xe0 0xa0 is decoded as a single U+FFFD, and this is correct according to the "Best Practices", but IE
|
||||
// and Chrome (Version 22.0.1229.94) decode both of them as two U+FFFDs. Opera 12.11 decodes both of them as
|
||||
// one U+FFFD".
|
||||
withTerminalSized(5, 2);
|
||||
input = new byte[]{(byte) 0xe0, (byte) 0xa0, ' '};
|
||||
mTerminal.append(input, input.length);
|
||||
assertLinesAre("\uFFFD ", " ");
|
||||
|
||||
// withTerminalSized(5, 2);
|
||||
// input = new byte[]{(byte) 0xe0, (byte) 0x80, 'a'};
|
||||
// mTerminal.append(input, input.length);
|
||||
// assertLinesAre("\uFFFD\uFFFDa ", " ");
|
||||
}
|
||||
|
||||
public void testUnassignedCodePoint() throws UnsupportedEncodingException {
|
||||
withTerminalSized(3, 3);
|
||||
// UTF-8 for U+C2541, an unassigned code point:
|
||||
byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81};
|
||||
mTerminal.append(b, b.length);
|
||||
enterString("Y");
|
||||
assertEquals(1, Character.charCount(TerminalEmulator.UNICODE_REPLACEMENT_CHAR));
|
||||
assertLineStartsWith(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, (int) 'Y', ' ');
|
||||
}
|
||||
|
||||
public void testStuff() {
|
||||
withTerminalSized(80, 24);
|
||||
byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81, (byte) 0x61, (byte) 0x38, (byte) 0xe7, (byte) 0x8f,
|
||||
(byte) 0xae, (byte) 0xc2, (byte) 0x9f, (byte) 0xe8, (byte) 0xa0, (byte) 0x9f, (byte) 0xe8, (byte) 0x8c, (byte) 0xa4,
|
||||
(byte) 0xed, (byte) 0x93, (byte) 0x89, (byte) 0xef, (byte) 0xbf, (byte) 0xbd, (byte) 0x42, (byte) 0xc2, (byte) 0x9b,
|
||||
(byte) 0xe6, (byte) 0x87, (byte) 0x89, (byte) 0x5a};
|
||||
mTerminal.append(b, b.length);
|
||||
}
|
||||
|
||||
public void testSimpleCombining() throws Exception {
|
||||
withTerminalSized(3, 2).enterString(" a\u0302 ").assertLinesAre(" a\u0302 ", " ");
|
||||
}
|
||||
|
||||
public void testCombiningCharacterInFirstColumn() throws Exception {
|
||||
withTerminalSized(5, 3).enterString("test\r\nhi\r\n").assertLinesAre("test ", "hi ", " ");
|
||||
|
||||
// U+0302 is COMBINING CIRCUMFLEX ACCENT. Test case from mosh (http://mosh.mit.edu/).
|
||||
withTerminalSized(5, 5).enterString("test\r\nabc\r\n\u0302\r\ndef\r\n");
|
||||
assertLinesAre("test ", "abc ", " \u0302 ", "def ", " ");
|
||||
}
|
||||
|
||||
public void testCombiningCharacterInLastColumn() throws Exception {
|
||||
withTerminalSized(3, 2).enterString(" a\u0302").assertLinesAre(" a\u0302", " ");
|
||||
withTerminalSized(3, 2).enterString(" à̲").assertLinesAre(" à̲", " ");
|
||||
withTerminalSized(3, 2).enterString("Aà̲F").assertLinesAre("Aà̲F", " ");
|
||||
}
|
||||
|
||||
public void testWideCharacterInLastColumn() throws Exception {
|
||||
withTerminalSized(3, 2).enterString(" 枝\u0302").assertLinesAre(" ", "枝\u0302 ");
|
||||
|
||||
withTerminalSized(3, 2).enterString(" 枝").assertLinesAre(" 枝", " ").assertCursorAt(0, 2);
|
||||
enterString("a").assertLinesAre(" 枝", "a ");
|
||||
}
|
||||
|
||||
public void testWideCharacterDeletion() throws Exception {
|
||||
// CSI Ps D Cursor Backward Ps Times
|
||||
withTerminalSized(3, 2).enterString("枝\033[Da").assertLinesAre(" a ", " ");
|
||||
withTerminalSized(3, 2).enterString("枝\033[2Da").assertLinesAre("a ", " ");
|
||||
withTerminalSized(3, 2).enterString("枝\033[2D枝").assertLinesAre("枝 ", " ");
|
||||
withTerminalSized(3, 2).enterString("枝\033[1D枝").assertLinesAre(" 枝", " ");
|
||||
withTerminalSized(5, 2).enterString(" 枝 \033[Da").assertLinesAre(" 枝a ", " ");
|
||||
withTerminalSized(5, 2).enterString("a \033[D\u0302").assertLinesAre("a\u0302 ", " ");
|
||||
withTerminalSized(5, 2).enterString("枝 \033[D\u0302").assertLinesAre("枝\u0302 ", " ");
|
||||
enterString("Z").assertLinesAre("枝\u0302Z ", " ");
|
||||
enterString("\033[D ").assertLinesAre("枝\u0302 ", " ");
|
||||
// Go back two columns, standing at the second half of the wide character:
|
||||
enterString("\033[2DU").assertLinesAre(" U ", " ");
|
||||
}
|
||||
|
||||
public void testWideCharOverwriting() {
|
||||
withTerminalSized(3, 2).enterString("abc\033[3D枝").assertLinesAre("枝c", " ");
|
||||
}
|
||||
|
||||
public void testOverlongUtf8Encoding() throws Exception {
|
||||
// U+0020 should be encoded as 0x20, 0xc0 0xa0 is an overlong encoding
|
||||
// so should be replaced with the replacement char U+FFFD.
|
||||
withTerminalSized(5, 5).mTerminal.append(new byte[]{(byte) 0xc0, (byte) 0xa0, 'Y'}, 3);
|
||||
assertLineIs(0, "\uFFFDY ");
|
||||
}
|
||||
|
||||
public void testWideCharacterWithoutWrapping() throws Exception {
|
||||
// With wraparound disabled. The behaviour when a wide character is output with cursor in
|
||||
// the last column when autowrap is disabled is not obvious, but we expect the wide
|
||||
// character to be ignored here.
|
||||
withTerminalSized(3, 3).enterString("\033[?7l").enterString("枝枝枝").assertLinesAre("枝 ", " ", " ");
|
||||
enterString("a枝").assertLinesAre("枝a", " ", " ");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.termux.terminal;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
public class WcWidthTest extends TestCase {
|
||||
|
||||
private static void assertWidthIs(int expectedWidth, int codePoint) {
|
||||
int wcWidth = WcWidth.width(codePoint);
|
||||
assertEquals(expectedWidth, wcWidth);
|
||||
}
|
||||
|
||||
public void testPrintableAscii() {
|
||||
for (int i = 0x20; i <= 0x7E; i++) {
|
||||
assertWidthIs(1, i);
|
||||
}
|
||||
}
|
||||
|
||||
public void testSomeWidthOne() {
|
||||
assertWidthIs(1, 'å');
|
||||
assertWidthIs(1, 'ä');
|
||||
assertWidthIs(1, 'ö');
|
||||
assertWidthIs(1, 0x23F2);
|
||||
}
|
||||
|
||||
public void testSomeWide() {
|
||||
assertWidthIs(2, 'A');
|
||||
assertWidthIs(2, 'B');
|
||||
assertWidthIs(2, 'C');
|
||||
assertWidthIs(2, '中');
|
||||
assertWidthIs(2, '文');
|
||||
|
||||
assertWidthIs(2, 0x679C);
|
||||
assertWidthIs(2, 0x679D);
|
||||
|
||||
assertWidthIs(2, 0x2070E);
|
||||
assertWidthIs(2, 0x20731);
|
||||
|
||||
assertWidthIs(1, 0x1F781);
|
||||
}
|
||||
|
||||
public void testSomeNonWide() {
|
||||
assertWidthIs(1, 0x1D11E);
|
||||
assertWidthIs(1, 0x1D11F);
|
||||
}
|
||||
|
||||
public void testCombining() {
|
||||
assertWidthIs(0, 0x0302);
|
||||
assertWidthIs(0, 0x0308);
|
||||
assertWidthIs(0, 0xFE0F);
|
||||
}
|
||||
|
||||
public void testWordJoiner() {
|
||||
// https://en.wikipedia.org/wiki/Word_joiner
|
||||
// The word joiner (WJ) is a code point in Unicode used to separate words when using scripts
|
||||
// that do not use explicit spacing. It is encoded since Unicode version 3.2
|
||||
// (released in 2002) as U+2060 WORD JOINER (HTML ⁠).
|
||||
// The word joiner does not produce any space, and prohibits a line break at its position.
|
||||
assertWidthIs(0, 0x2060);
|
||||
}
|
||||
|
||||
public void testSofthyphen() {
|
||||
// http://osdir.com/ml/internationalization.linux/2003-05/msg00006.html:
|
||||
// "Existing implementation practice in terminals is that the SOFT HYPHEN is
|
||||
// a spacing graphical character, and the purpose of my wcwidth() was to
|
||||
// predict the advancement of the cursor position after a string is sent to
|
||||
// a terminal. Hence, I have no choice but to keep wcwidth(SOFT HYPHEN) = 1.
|
||||
// VT100-style terminals do not hyphenate."
|
||||
assertWidthIs(1, 0x00AD);
|
||||
}
|
||||
|
||||
public void testHangul() {
|
||||
assertWidthIs(1, 0x11A3);
|
||||
}
|
||||
|
||||
public void testEmojis() {
|
||||
assertWidthIs(2, 0x1F428); // KOALA.
|
||||
assertWidthIs(2, 0x231a); // WATCH.
|
||||
assertWidthIs(2, 0x1F643); // UPSIDE-DOWN FACE (Unicode 8).
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,22 @@
|
||||
The `termux-shared` library is released under [MIT](https://opensource.org/licenses/MIT) license.
|
||||
|
||||
### Exceptions
|
||||
|
||||
#### [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||
|
||||
- [`src/main/java/com/termux/shared/termux/*`](src/main/java/com/termux/shared/termux).
|
||||
|
||||
The `GPLv3 only` license applies to all files unless specifically specified by a file/directory, like the [`src/main/java/com/termux/shared/termux/TermuxConstants.java`](src/main/java/com/termux/shared/termux/TermuxConstants.java) and [`src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java`](src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java) files are released under the `MIT` license.
|
||||
##
|
||||
|
||||
|
||||
#### [GPLv2 only with "Classpath" exception](https://openjdk.java.net/legal/gplv2+ce.html)
|
||||
|
||||
- [`src/main/java/com/termux/shared/file/filesystem/*`](src/main/java/com/termux/shared/file/filesystem) files that use code from [libcore/ojluni](https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/).
|
||||
##
|
||||
|
||||
|
||||
#### [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
- [`src/main/java/com/termux/shared/shell/StreamGobbler.java`](src/main/java/com/termux/shared/shell/StreamGobbler.java) uses code from [libsuperuser ](https://github.com/Chainfire/libsuperuser).
|
||||
##
|
||||
@@ -0,0 +1,43 @@
|
||||
plugins {
|
||||
id ("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.termux.shared"
|
||||
ndkVersion = BuildConfig.ndkVersion
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
path = file("src/main/cpp/Android.mk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.window.v1alpha9)
|
||||
implementation(libs.common.markwon.core)
|
||||
implementation(libs.common.markwon.extStrikethrough)
|
||||
implementation(libs.common.markwon.linkify)
|
||||
implementation(libs.common.markwon.recycler)
|
||||
implementation(libs.google.material)
|
||||
implementation(libs.google.guava)
|
||||
implementation(libs.common.hiddenApiBypass)
|
||||
|
||||
// Do not increment version higher than 1.0.0-alpha09 since it will break ViewUtils and needs to be looked into
|
||||
// noinspection GradleDependency
|
||||
implementation(libs.common.io)
|
||||
implementation(libs.common.termuxAmLib)
|
||||
|
||||
implementation(projects.common)
|
||||
implementation(projects.termux.termuxView)
|
||||
implementation(projects.buildInfo)
|
||||
implementation(projects.preferences)
|
||||
implementation(projects.resources)
|
||||
|
||||
testImplementation(projects.testing.unit)
|
||||
testImplementation(projects.testing.android)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
-dontobfuscate
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.termux.shared;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
assertEquals("com.termux.shared.test", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
LOCAL_PATH:= $(call my-dir)
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_LDLIBS := -llog
|
||||
LOCAL_MODULE := local-socket
|
||||
LOCAL_SRC_FILES := local-socket.cpp
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
@@ -0,0 +1 @@
|
||||
APP_STL := c++_static
|
||||
@@ -0,0 +1,603 @@
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <cerrno>
|
||||
#include <jni.h>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
#define LOG_TAG "local-socket"
|
||||
#define JNI_EXCEPTION "jni-exception"
|
||||
|
||||
using namespace std;
|
||||
|
||||
|
||||
/* Convert a jstring to a std:string. */
|
||||
string jstring_to_stdstr(JNIEnv *env, jstring jString) {
|
||||
jclass stringClass = env->FindClass("java/lang/String");
|
||||
jmethodID getBytes = env->GetMethodID(stringClass, "getBytes", "()[B");
|
||||
jbyteArray jStringBytesArray = (jbyteArray) env->CallObjectMethod(jString, getBytes);
|
||||
jsize length = env->GetArrayLength(jStringBytesArray);
|
||||
jbyte* jStringBytes = env->GetByteArrayElements(jStringBytesArray, nullptr);
|
||||
std::string stdString((char *)jStringBytes, length);
|
||||
env->ReleaseByteArrayElements(jStringBytesArray, jStringBytes, JNI_ABORT);
|
||||
return stdString;
|
||||
}
|
||||
|
||||
/* Get characters before first occurrence of the delim in a std:string. */
|
||||
string get_string_till_first_delim(string str, char delim) {
|
||||
if (!str.empty()) {
|
||||
stringstream cmdline_args(str);
|
||||
string tmp;
|
||||
if (getline(cmdline_args, tmp, delim))
|
||||
return tmp;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/* Replace `\0` values with spaces in a std:string. */
|
||||
string replace_null_with_space(string str) {
|
||||
if (str.empty())
|
||||
return "";
|
||||
|
||||
stringstream tokens(str);
|
||||
string tmp;
|
||||
string str_spaced;
|
||||
while (getline(tokens, tmp, '\0')){
|
||||
str_spaced.append(" " + tmp);
|
||||
}
|
||||
|
||||
if (!str_spaced.empty()) {
|
||||
if (str_spaced.front() == ' ')
|
||||
str_spaced.erase(0, 1);
|
||||
}
|
||||
|
||||
return str_spaced;
|
||||
}
|
||||
|
||||
/* Get class name of a jclazz object with a call to `Class.getName()`. */
|
||||
string get_class_name(JNIEnv *env, jclass clazz) {
|
||||
jclass classClass = env->FindClass("java/lang/Class");
|
||||
jmethodID getName = env->GetMethodID(classClass, "getName", "()Ljava/lang/String;");
|
||||
jstring className = (jstring) env->CallObjectMethod(clazz, getName);
|
||||
return jstring_to_stdstr(env, className);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Get /proc/[pid]/cmdline for a process with pid.
|
||||
*
|
||||
* https://manpages.debian.org/testing/manpages/proc.5.en.html
|
||||
*/
|
||||
string get_process_cmdline(const pid_t pid) {
|
||||
string cmdline;
|
||||
char buf[BUFSIZ];
|
||||
size_t len;
|
||||
char procfile[BUFSIZ];
|
||||
sprintf(procfile, "/proc/%d/cmdline", pid);
|
||||
FILE *fp = fopen(procfile, "rb");
|
||||
if (fp) {
|
||||
while ((len = fread(buf, 1, sizeof(buf), fp)) > 0) {
|
||||
cmdline.append(buf, len);
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
return cmdline;
|
||||
}
|
||||
|
||||
/* Extract process name from /proc/[pid]/cmdline value of a process. */
|
||||
string get_process_name_from_cmdline(string cmdline) {
|
||||
return get_string_till_first_delim(cmdline, '\0');
|
||||
}
|
||||
|
||||
/* Replace `\0` values with spaces in /proc/[pid]/cmdline value of a process. */
|
||||
string get_process_cmdline_spaced(string cmdline) {
|
||||
return replace_null_with_space(cmdline);
|
||||
}
|
||||
|
||||
|
||||
/* Send an ERROR log message to android logcat. */
|
||||
void log_error(string message) {
|
||||
__android_log_write(ANDROID_LOG_ERROR, LOG_TAG, message.c_str());
|
||||
}
|
||||
|
||||
/* Send an WARN log message to android logcat. */
|
||||
void log_warn(string message) {
|
||||
__android_log_write(ANDROID_LOG_WARN, LOG_TAG, message.c_str());
|
||||
}
|
||||
|
||||
/* Get "title: message" formatted string. */
|
||||
string get_title_and_message(JNIEnv *env, jstring title, string message) {
|
||||
if (title)
|
||||
message = jstring_to_stdstr(env, title) + ": " + message;
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
/* Convert timespec to milliseconds. */
|
||||
int64_t timespec_to_milliseconds(const struct timespec* const time) {
|
||||
return (((int64_t)time->tv_sec) * 1000) + (((int64_t)time->tv_nsec)/1000000);
|
||||
}
|
||||
|
||||
/* Convert milliseconds to timeval. */
|
||||
timeval milliseconds_to_timeval(int milliseconds) {
|
||||
struct timeval tv = {};
|
||||
tv.tv_sec = milliseconds / 1000;
|
||||
tv.tv_usec = (milliseconds % 1000) * 1000;
|
||||
return tv;
|
||||
}
|
||||
|
||||
|
||||
// Note: Exceptions thrown from JNI must be caught with Throwable class instead of Exception,
|
||||
// otherwise exception will be sent to UncaughtExceptionHandler of the thread.
|
||||
// Android studio complains that getJniResult functions always return nullptr since linter is broken
|
||||
// for jboolean and jobject if comparisons.
|
||||
bool checkJniException(JNIEnv *env) {
|
||||
if (env->ExceptionCheck()) {
|
||||
jthrowable throwable = env->ExceptionOccurred();
|
||||
if (throwable != NULL) {
|
||||
env->ExceptionClear();
|
||||
env->Throw(throwable);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
string getJniResultString(const int retvalParam, const int errnoParam,
|
||||
string errmsgParam, const int intDataParam) {
|
||||
return "retval=" + to_string(retvalParam) + ", errno=" + to_string(errnoParam) +
|
||||
", errmsg=\"" + errmsgParam + "\"" + ", intData=" + to_string(intDataParam);
|
||||
}
|
||||
|
||||
/* Get "com/termux/shared/jni/models/JniResult" object that can be returned as result for a JNI call. */
|
||||
jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, const int errnoParam,
|
||||
string errmsgParam, const int intDataParam) {
|
||||
jclass clazz = env->FindClass("com/termux/shared/jni/models/JniResult");
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (!clazz) {
|
||||
log_error(get_title_and_message(env, title,
|
||||
"Failed to find JniResult class to create object for " +
|
||||
getJniResultString(retvalParam, errnoParam, errmsgParam, intDataParam)));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jmethodID constructor = env->GetMethodID(clazz, "<init>", "(IILjava/lang/String;I)V");
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (!constructor) {
|
||||
log_error(get_title_and_message(env, title,
|
||||
"Failed to get constructor for JniResult class to create object for " +
|
||||
getJniResultString(retvalParam, errnoParam, errmsgParam, intDataParam)));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!errmsgParam.empty())
|
||||
errmsgParam = get_title_and_message(env, title, string(errmsgParam));
|
||||
|
||||
jobject obj = env->NewObject(clazz, constructor, retvalParam, errnoParam, env->NewStringUTF(errmsgParam.c_str()), intDataParam);
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (obj == NULL) {
|
||||
log_error(get_title_and_message(env, title,
|
||||
"Failed to get JniResult object for " +
|
||||
getJniResultString(retvalParam, errnoParam, errmsgParam, intDataParam)));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, const int errnoParam) {
|
||||
return getJniResult(env, title, retvalParam, errnoParam, strerror(errnoParam), 0);
|
||||
}
|
||||
|
||||
jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, string errmsgPrefixParam) {
|
||||
return getJniResult(env, title, retvalParam, 0, errmsgPrefixParam, 0);
|
||||
}
|
||||
|
||||
jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, const int errnoParam, string errmsgPrefixParam) {
|
||||
return getJniResult(env, title, retvalParam, errnoParam, errmsgPrefixParam + ": " + string(strerror(errnoParam)), 0);
|
||||
}
|
||||
|
||||
jobject getJniResult(JNIEnv *env, jstring title, const int intDataParam) {
|
||||
return getJniResult(env, title, 0, 0, "", intDataParam);
|
||||
}
|
||||
|
||||
jobject getJniResult(JNIEnv *env, jstring title) {
|
||||
return getJniResult(env, title, 0, 0, "", 0);
|
||||
}
|
||||
|
||||
|
||||
/* Set int fieldName field for clazz to value. */
|
||||
string setIntField(JNIEnv *env, jobject obj, jclass clazz, const string fieldName, const int value) {
|
||||
jfieldID field = env->GetFieldID(clazz, fieldName.c_str(), "I");
|
||||
if (checkJniException(env)) return JNI_EXCEPTION;
|
||||
if (!field) {
|
||||
return "Failed to get int \"" + string(fieldName) + "\" field of \"" +
|
||||
get_class_name(env, clazz) + "\" class to set value \"" + to_string(value) + "\"";
|
||||
}
|
||||
|
||||
env->SetIntField(obj, field, value);
|
||||
if (checkJniException(env)) return JNI_EXCEPTION;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/* Set String fieldName field for clazz to value. */
|
||||
string setStringField(JNIEnv *env, jobject obj, jclass clazz, const string fieldName, const string value) {
|
||||
jfieldID field = env->GetFieldID(clazz, fieldName.c_str(), "Ljava/lang/String;");
|
||||
if (checkJniException(env)) return JNI_EXCEPTION;
|
||||
if (!field) {
|
||||
return "Failed to get String \"" + string(fieldName) + "\" field of \"" +
|
||||
get_class_name(env, clazz) + "\" class to set value \"" + value + "\"";
|
||||
}
|
||||
|
||||
env->SetObjectField(obj, field, env->NewStringUTF(value.c_str()));
|
||||
if (checkJniException(env)) return JNI_EXCEPTION;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_createServerSocketNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle,
|
||||
jbyteArray pathArray,
|
||||
jint backlog) {
|
||||
if (backlog < 1 || backlog > 500) {
|
||||
return getJniResult(env, logTitle, -1, "createServerSocketNative(): Backlog \"" +
|
||||
to_string(backlog) + "\" is not between 1-500");
|
||||
}
|
||||
|
||||
// Create server socket
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno, "createServerSocketNative(): Create local socket failed");
|
||||
}
|
||||
|
||||
jbyte* path = env->GetByteArrayElements(pathArray, nullptr);
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (path == nullptr) {
|
||||
close(fd);
|
||||
return getJniResult(env, logTitle, -1, "createServerSocketNative(): Path passed is null");
|
||||
}
|
||||
|
||||
// On Linux, sun_path is 108 bytes (UNIX_PATH_MAX) in size
|
||||
int chars = env->GetArrayLength(pathArray);
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (chars >= 108 || chars >= sizeof(struct sockaddr_un) - sizeof(sa_family_t)) {
|
||||
env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
close(fd);
|
||||
return getJniResult(env, logTitle, -1, "createServerSocketNative(): Path passed is too long");
|
||||
}
|
||||
|
||||
struct sockaddr_un adr = {.sun_family = AF_UNIX};
|
||||
memcpy(&adr.sun_path, path, chars);
|
||||
|
||||
// Bind path to server socket
|
||||
if (::bind(fd, reinterpret_cast<struct sockaddr*>(&adr), sizeof(adr)) == -1) {
|
||||
int errnoBackup = errno;
|
||||
env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
close(fd);
|
||||
return getJniResult(env, logTitle, -1, errnoBackup,
|
||||
"createServerSocketNative(): Bind to local socket at path \"" + string(adr.sun_path) + "\" with fd " + to_string(fd) + " failed");
|
||||
}
|
||||
|
||||
// Start listening for client sockets on server socket
|
||||
if (listen(fd, backlog) == -1) {
|
||||
int errnoBackup = errno;
|
||||
env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
close(fd);
|
||||
return getJniResult(env, logTitle, -1, errnoBackup,
|
||||
"createServerSocketNative(): Listen on local socket at path \"" + string(adr.sun_path) + "\" with fd " + to_string(fd) + " failed");
|
||||
}
|
||||
|
||||
env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
|
||||
// Return success and server socket fd in JniResult.intData field
|
||||
return getJniResult(env, logTitle, fd);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_closeSocketNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle, jint fd) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "closeSocketNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
if (close(fd) == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno, "closeSocketNative(): Failed to close socket fd " + to_string(fd));
|
||||
}
|
||||
|
||||
// Return success
|
||||
return getJniResult(env, logTitle);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_acceptNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle, jint fd) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "acceptNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
// Accept client socket
|
||||
int clientFd = accept(fd, nullptr, nullptr);
|
||||
if (clientFd == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno, "acceptNative(): Failed to accept client on fd " + to_string(fd));
|
||||
}
|
||||
|
||||
// Return success and client socket fd in JniResult.intData field
|
||||
return getJniResult(env, logTitle, clientFd);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_readNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle,
|
||||
jint fd, jbyteArray dataArray,
|
||||
jlong deadline) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "readNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
jbyte* data = env->GetByteArrayElements(dataArray, nullptr);
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (data == nullptr) {
|
||||
return getJniResult(env, logTitle, -1, "readNative(): data passed is null");
|
||||
}
|
||||
|
||||
struct timespec time = {};
|
||||
jbyte* current = data;
|
||||
int bytes = env->GetArrayLength(dataArray);
|
||||
if (checkJniException(env)) return NULL;
|
||||
int bytesRead = 0;
|
||||
while (bytesRead < bytes) {
|
||||
if (deadline > 0) {
|
||||
if (clock_gettime(CLOCK_REALTIME, &time) != -1) {
|
||||
// If current time is greater than the time defined in deadline
|
||||
if (timespec_to_milliseconds(&time) > deadline) {
|
||||
env->ReleaseByteArrayElements(dataArray, data, 0);
|
||||
if (checkJniException(env)) return NULL;
|
||||
return getJniResult(env, logTitle, -1,
|
||||
"readNative(): Deadline \"" + to_string(deadline) + "\" timeout");
|
||||
}
|
||||
} else {
|
||||
log_warn(get_title_and_message(env, logTitle,
|
||||
"readNative(): Deadline \"" + to_string(deadline) +
|
||||
"\" timeout will not work since failed to get current time"));
|
||||
}
|
||||
}
|
||||
|
||||
// Read data from socket
|
||||
int ret = read(fd, current, bytes);
|
||||
if (ret == -1) {
|
||||
int errnoBackup = errno;
|
||||
env->ReleaseByteArrayElements(dataArray, data, 0);
|
||||
if (checkJniException(env)) return NULL;
|
||||
return getJniResult(env, logTitle, -1, errnoBackup, "readNative(): Failed to read on fd " + to_string(fd));
|
||||
}
|
||||
// EOF, peer closed writing end
|
||||
if (ret == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
bytesRead += ret;
|
||||
current += ret;
|
||||
}
|
||||
|
||||
env->ReleaseByteArrayElements(dataArray, data, 0);
|
||||
if (checkJniException(env)) return NULL;
|
||||
|
||||
// Return success and bytes read in JniResult.intData field
|
||||
return getJniResult(env, logTitle, bytesRead);
|
||||
}
|
||||
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_sendNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle,
|
||||
jint fd, jbyteArray dataArray,
|
||||
jlong deadline) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "sendNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
jbyte* data = env->GetByteArrayElements(dataArray, nullptr);
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (data == nullptr) {
|
||||
return getJniResult(env, logTitle, -1, "sendNative(): data passed is null");
|
||||
}
|
||||
|
||||
struct timespec time = {};
|
||||
jbyte* current = data;
|
||||
int bytes = env->GetArrayLength(dataArray);
|
||||
if (checkJniException(env)) return NULL;
|
||||
while (bytes > 0) {
|
||||
if (deadline > 0) {
|
||||
if (clock_gettime(CLOCK_REALTIME, &time) != -1) {
|
||||
// If current time is greater than the time defined in deadline
|
||||
if (timespec_to_milliseconds(&time) > deadline) {
|
||||
env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
return getJniResult(env, logTitle, -1,
|
||||
"sendNative(): Deadline \"" + to_string(deadline) + "\" timeout");
|
||||
}
|
||||
} else {
|
||||
log_warn(get_title_and_message(env, logTitle,
|
||||
"sendNative(): Deadline \"" + to_string(deadline) +
|
||||
"\" timeout will not work since failed to get current time"));
|
||||
}
|
||||
}
|
||||
|
||||
// Send data to socket
|
||||
int ret = send(fd, current, bytes, MSG_NOSIGNAL);
|
||||
if (ret == -1) {
|
||||
int errnoBackup = errno;
|
||||
env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
return getJniResult(env, logTitle, -1, errnoBackup, "sendNative(): Failed to send on fd " + to_string(fd));
|
||||
}
|
||||
|
||||
bytes -= ret;
|
||||
current += ret;
|
||||
}
|
||||
|
||||
env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT);
|
||||
if (checkJniException(env)) return NULL;
|
||||
|
||||
// Return success
|
||||
return getJniResult(env, logTitle);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_availableNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle, jint fd) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "availableNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
int available = 0;
|
||||
if (ioctl(fd, SIOCINQ, &available) == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno,
|
||||
"availableNative(): Failed to get number of unread bytes in the receive buffer of fd " + to_string(fd));
|
||||
}
|
||||
|
||||
// Return success and bytes available in JniResult.intData field
|
||||
return getJniResult(env, logTitle, available);
|
||||
}
|
||||
|
||||
/* Sets socket option timeout in milliseconds. */
|
||||
int set_socket_timeout(int fd, int option, int timeout) {
|
||||
struct timeval tv = milliseconds_to_timeval(timeout);
|
||||
socklen_t len = sizeof(tv);
|
||||
return setsockopt(fd, SOL_SOCKET, option, &tv, len);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_setSocketReadTimeoutNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle,
|
||||
jint fd, jint timeout) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "setSocketReadTimeoutNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
if (set_socket_timeout(fd, SO_RCVTIMEO, timeout) == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno,
|
||||
"setSocketReadTimeoutNative(): Failed to set socket receiving (SO_RCVTIMEO) timeout for fd " + to_string(fd));
|
||||
}
|
||||
|
||||
// Return success
|
||||
return getJniResult(env, logTitle);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_setSocketSendTimeoutNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle,
|
||||
jint fd, jint timeout) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "setSocketSendTimeoutNative(): Invalid fd \"" +
|
||||
to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
if (set_socket_timeout(fd, SO_SNDTIMEO, timeout) == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno,
|
||||
"setSocketSendTimeoutNative(): Failed to set socket sending (SO_SNDTIMEO) timeout for fd " + to_string(fd));
|
||||
}
|
||||
|
||||
// Return success
|
||||
return getJniResult(env, logTitle);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_termux_shared_net_socket_local_LocalSocketManager_getPeerCredNative(JNIEnv *env, jclass clazz,
|
||||
jstring logTitle,
|
||||
jint fd, jobject peerCred) {
|
||||
if (fd < 0) {
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): Invalid fd \"" + to_string(fd) + "\" passed");
|
||||
}
|
||||
|
||||
if (peerCred == nullptr) {
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): peerCred passed is null");
|
||||
}
|
||||
|
||||
// Initialize to -1 instead of 0 in case a failed getsockopt() call somehow doesn't report failure and returns the uid of root
|
||||
struct ucred cred = {};
|
||||
cred.pid = -1; cred.uid = -1; cred.gid = -1;
|
||||
|
||||
socklen_t len = sizeof(cred);
|
||||
|
||||
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == -1) {
|
||||
return getJniResult(env, logTitle, -1, errno, "getPeerCredNative(): Failed to get peer credentials for fd " + to_string(fd));
|
||||
}
|
||||
|
||||
// Fill "com.termux.shared.net.socket.local.PeerCred" object.
|
||||
// The pid, uid and gid will always be set based on ucred.
|
||||
// The pname and cmdline will only be set if current process has access to "/proc/[pid]/cmdline"
|
||||
// of peer process. Processes of other users/apps are not normally accessible.
|
||||
jclass peerCredClazz = env->GetObjectClass(peerCred);
|
||||
if (checkJniException(env)) return NULL;
|
||||
if (!peerCredClazz) {
|
||||
return getJniResult(env, logTitle, -1, errno, "getPeerCredNative(): Failed to get PeerCred class");
|
||||
}
|
||||
|
||||
string error;
|
||||
|
||||
error = setIntField(env, peerCred, peerCredClazz, "pid", cred.pid);
|
||||
if (!error.empty()) {
|
||||
if (error == JNI_EXCEPTION) return NULL;
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
|
||||
}
|
||||
|
||||
error = setIntField(env, peerCred, peerCredClazz, "uid", cred.uid);
|
||||
if (!error.empty()) {
|
||||
if (error == JNI_EXCEPTION) return NULL;
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
|
||||
}
|
||||
|
||||
error = setIntField(env, peerCred, peerCredClazz, "gid", cred.gid);
|
||||
if (!error.empty()) {
|
||||
if (error == JNI_EXCEPTION) return NULL;
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
|
||||
}
|
||||
|
||||
string cmdline = get_process_cmdline(cred.pid);
|
||||
if (!cmdline.empty()) {
|
||||
error = setStringField(env, peerCred, peerCredClazz, "pname", get_process_name_from_cmdline(cmdline));
|
||||
if (!error.empty()) {
|
||||
if (error == JNI_EXCEPTION) return NULL;
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
|
||||
}
|
||||
|
||||
error = setStringField(env, peerCred, peerCredClazz, "cmdline", get_process_cmdline_spaced(cmdline));
|
||||
if (!error.empty()) {
|
||||
if (error == JNI_EXCEPTION) return NULL;
|
||||
return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
|
||||
}
|
||||
}
|
||||
|
||||
// Return success since PeerCred was filled successfully
|
||||
return getJniResult(env, logTitle);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package com.termux.shared.activities;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.itsaky.androidide.app.BaseIDEActivity;
|
||||
import com.termux.shared.R;
|
||||
import com.termux.shared.data.DataUtils;
|
||||
import com.termux.shared.databinding.ActivityReportBinding;
|
||||
import com.termux.shared.errors.Error;
|
||||
import com.termux.shared.file.FileUtils;
|
||||
import com.termux.shared.file.filesystem.FileType;
|
||||
import com.termux.shared.interact.ShareUtils;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.markdown.MarkdownUtils;
|
||||
import com.termux.shared.models.ReportInfo;
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import io.noties.markwon.Markwon;
|
||||
import io.noties.markwon.recycler.MarkwonAdapter;
|
||||
import io.noties.markwon.recycler.SimpleEntry;
|
||||
import org.commonmark.node.FencedCodeBlock;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* An activity to show reports in markdown format as per CommonMark spec based on config passed as
|
||||
* {@link ReportInfo}. Add Following to `AndroidManifest.xml` to use in an app:
|
||||
* {@code `<activity android:name="com.termux.shared.activities.ReportActivity"
|
||||
* android:theme="@style/Theme.AppCompat.TermuxReportActivity"
|
||||
* android:documentLaunchMode="intoExisting" />` } and
|
||||
* {@code `<receiver
|
||||
* android:name="com.termux.shared.activities.ReportActivity$ReportActivityBroadcastReceiver"
|
||||
* android:exported="false" />` } Receiver **must not** be `exported="true"`!!!
|
||||
* <p>
|
||||
* Also make an incremental call to
|
||||
* {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} in the app to cleanup cached
|
||||
* files.
|
||||
*/
|
||||
public class ReportActivity extends BaseIDEActivity {
|
||||
|
||||
private ActivityReportBinding binding;
|
||||
|
||||
private static final String CLASS_NAME = ReportActivity.class.getCanonicalName();
|
||||
private static final String ACTION_DELETE_REPORT_INFO_OBJECT_FILE =
|
||||
CLASS_NAME + ".ACTION_DELETE_REPORT_INFO_OBJECT_FILE";
|
||||
|
||||
private static final String EXTRA_REPORT_INFO_OBJECT = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT";
|
||||
private static final String EXTRA_REPORT_INFO_OBJECT_FILE_PATH =
|
||||
CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT_FILE_PATH";
|
||||
|
||||
private static final String CACHE_DIR_BASENAME = "report_activity";
|
||||
private static final String CACHE_FILE_BASENAME_PREFIX = "report_info_";
|
||||
|
||||
public static final int REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE = 1000;
|
||||
|
||||
public static final int ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES = 1000 * 1024; // 1MB
|
||||
|
||||
private ReportInfo mReportInfo;
|
||||
private String mReportInfoFilePath;
|
||||
private String mReportActivityMarkdownString;
|
||||
private Bundle mBundle;
|
||||
|
||||
private static final String LOG_TAG = "ReportActivity";
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected View bindLayout() {
|
||||
this.binding = ActivityReportBinding.inflate(getLayoutInflater());
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
Logger.logVerbose(LOG_TAG, "onCreate");
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
if (toolbar != null) {
|
||||
setSupportActionBar(toolbar);
|
||||
}
|
||||
|
||||
mBundle = null;
|
||||
Intent intent = getIntent();
|
||||
if (intent != null) {
|
||||
mBundle = intent.getExtras();
|
||||
} else if (savedInstanceState != null) {
|
||||
mBundle = savedInstanceState;
|
||||
}
|
||||
|
||||
updateUI();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
Logger.logVerbose(LOG_TAG, "onNewIntent");
|
||||
|
||||
setIntent(intent);
|
||||
|
||||
if (intent != null) {
|
||||
deleteReportInfoFile(this, mReportInfoFilePath);
|
||||
mBundle = intent.getExtras();
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateUI() {
|
||||
|
||||
if (mBundle == null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
mReportInfo = null;
|
||||
mReportInfoFilePath = null;
|
||||
|
||||
if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
|
||||
mReportInfoFilePath = mBundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH);
|
||||
Logger.logVerbose(LOG_TAG,
|
||||
ReportInfo.class.getSimpleName() + " serialized object will be read from file at path \""
|
||||
+ mReportInfoFilePath + "\"");
|
||||
if (mReportInfoFilePath != null) {
|
||||
try {
|
||||
FileUtils.ReadSerializableObjectResult result = FileUtils.readSerializableObjectFromFile(
|
||||
ReportInfo.class.getSimpleName(), mReportInfoFilePath, ReportInfo.class, false);
|
||||
if (result.error != null) {
|
||||
Logger.logErrorExtended(LOG_TAG, result.error.toString());
|
||||
Logger.showToast(this, Error.getMinimalErrorString(result.error), true);
|
||||
finish();
|
||||
return;
|
||||
} else {
|
||||
if (result.serializableObject != null) {
|
||||
mReportInfo = (ReportInfo) result.serializableObject;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.logErrorAndShowToast(this, LOG_TAG, e.getMessage());
|
||||
Logger.logStackTraceWithMessage(LOG_TAG,
|
||||
"Failure while getting " + ReportInfo.class.getSimpleName()
|
||||
+ " serialized object from file at path \"" + mReportInfoFilePath + "\"", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mReportInfo = (ReportInfo) mBundle.getSerializable(EXTRA_REPORT_INFO_OBJECT);
|
||||
}
|
||||
|
||||
if (mReportInfo == null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
final ActionBar actionBar = getSupportActionBar();
|
||||
if (actionBar != null) {
|
||||
if (mReportInfo.reportTitle != null) {
|
||||
actionBar.setTitle(mReportInfo.reportTitle);
|
||||
} else {
|
||||
actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report");
|
||||
}
|
||||
}
|
||||
|
||||
RecyclerView recyclerView = findViewById(R.id.recycler_view);
|
||||
|
||||
final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this);
|
||||
|
||||
final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot(
|
||||
R.layout.markdown_adapter_node_default)
|
||||
.include(FencedCodeBlock.class,
|
||||
SimpleEntry.create(R.layout.markdown_adapter_node_code_block, R.id.code_text_view))
|
||||
.build();
|
||||
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
generateReportActivityMarkdownString();
|
||||
adapter.setMarkdown(markwon, mReportActivityMarkdownString);
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
|
||||
outState.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, mReportInfoFilePath);
|
||||
} else {
|
||||
outState.putSerializable(EXTRA_REPORT_INFO_OBJECT, mReportInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
Logger.logVerbose(LOG_TAG, "onDestroy");
|
||||
binding = null;
|
||||
deleteReportInfoFile(this, mReportInfoFilePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(final Menu menu) {
|
||||
final MenuInflater inflater = getMenuInflater();
|
||||
inflater.inflate(R.menu.menu_report, menu);
|
||||
|
||||
if (mReportInfo.reportSaveFilePath == null) {
|
||||
MenuItem item = menu.findItem(R.id.menu_item_save_report_to_file);
|
||||
if (item != null) {
|
||||
item.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
// Remove activity from recents menu on back button press
|
||||
finishAndRemoveTask();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
int id = item.getItemId();
|
||||
if (id == R.id.menu_item_share_report) {
|
||||
ShareUtils.shareText(this, getString(R.string.title_report_text),
|
||||
ReportInfo.getReportInfoMarkdownString(mReportInfo));
|
||||
} else if (id == R.id.menu_item_copy_report) {
|
||||
ShareUtils.copyTextToClipboard(this, ReportInfo.getReportInfoMarkdownString(mReportInfo),
|
||||
null);
|
||||
} else if (id == R.id.menu_item_save_report_to_file) {
|
||||
ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,
|
||||
mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),
|
||||
true, REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
||||
@NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
Logger.logInfo(LOG_TAG, "Storage permission granted by user on request.");
|
||||
if (requestCode == REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE) {
|
||||
ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,
|
||||
mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),
|
||||
true, -1);
|
||||
}
|
||||
} else {
|
||||
Logger.logInfo(LOG_TAG, "Storage permission denied by user on request.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the markdown {@link String} to be shown in {@link ReportActivity}.
|
||||
*/
|
||||
private void generateReportActivityMarkdownString() {
|
||||
// We need to reduce chances of OutOfMemoryError happening so reduce new allocations and
|
||||
// do not keep output of getReportInfoMarkdownString in memory
|
||||
StringBuilder reportString = new StringBuilder();
|
||||
|
||||
if (mReportInfo.reportStringPrefix != null) {
|
||||
reportString.append(mReportInfo.reportStringPrefix);
|
||||
}
|
||||
|
||||
String reportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo);
|
||||
int reportMarkdownStringSize = reportMarkdownString.getBytes().length;
|
||||
boolean truncated = false;
|
||||
if (reportMarkdownStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {
|
||||
Logger.logVerbose(LOG_TAG,
|
||||
mReportInfo.reportTitle + " report string size " + reportMarkdownStringSize
|
||||
+ " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");
|
||||
reportString.append(DataUtils.getTruncatedCommandOutput(reportMarkdownString,
|
||||
ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, true));
|
||||
truncated = true;
|
||||
} else {
|
||||
reportString.append(reportMarkdownString);
|
||||
}
|
||||
|
||||
// Free reference
|
||||
reportMarkdownString = null;
|
||||
|
||||
if (mReportInfo.reportStringSuffix != null) {
|
||||
reportString.append(mReportInfo.reportStringSuffix);
|
||||
}
|
||||
|
||||
int reportStringSize = reportString.length();
|
||||
if (reportStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {
|
||||
// This may break markdown formatting
|
||||
Logger.logVerbose(LOG_TAG,
|
||||
mReportInfo.reportTitle + " report string total size " + reportStringSize
|
||||
+ " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");
|
||||
mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) +
|
||||
DataUtils.getTruncatedCommandOutput(reportString.toString(),
|
||||
ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, false);
|
||||
} else if (truncated) {
|
||||
mReportActivityMarkdownString =
|
||||
this.getString(R.string.msg_report_truncated) + reportString.toString();
|
||||
} else {
|
||||
mReportActivityMarkdownString = reportString.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public static class NewInstanceResult {
|
||||
|
||||
/**
|
||||
* An intent that can be used to start the {@link ReportActivity}.
|
||||
*/
|
||||
public Intent contentIntent;
|
||||
/**
|
||||
* An intent that can should be adding as the {@link android.app.Notification#deleteIntent} by a
|
||||
* call to {@link android.app.PendingIntent#getBroadcast(Context, int, Intent, int)} so that
|
||||
* {@link ReportActivityBroadcastReceiver} can do cleanup of
|
||||
* {@link #EXTRA_REPORT_INFO_OBJECT_FILE_PATH}.
|
||||
*/
|
||||
public Intent deleteIntent;
|
||||
|
||||
NewInstanceResult(Intent contentIntent, Intent deleteIntent) {
|
||||
this.contentIntent = contentIntent;
|
||||
this.deleteIntent = deleteIntent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the {@link ReportActivity}.
|
||||
*
|
||||
* @param context The {@link Context} for operations.
|
||||
* @param reportInfo The {@link ReportInfo} containing info that needs to be displayed.
|
||||
*/
|
||||
public static void startReportActivity(@NonNull final Context context,
|
||||
@NonNull ReportInfo reportInfo) {
|
||||
NewInstanceResult result = newInstance(context, reportInfo);
|
||||
if (result.contentIntent == null) {
|
||||
return;
|
||||
}
|
||||
context.startActivity(result.contentIntent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content and delete intents for the {@link ReportActivity} that can be used to start it and
|
||||
* do cleanup.
|
||||
* <p>
|
||||
* If {@link ReportInfo} size is too large, then a TransactionTooLargeException will be thrown so
|
||||
* its object may be saved to a file in the {@link Context#getCacheDir()}. Then when activity
|
||||
* starts, its read back and the file is deleted in {@link #onDestroy()}. Note that files may
|
||||
* still be left if {@link #onDestroy()} is not called or doesn't finish. A separate cleanup
|
||||
* routine is implemented from that case by
|
||||
* {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} which should be called
|
||||
* incrementally or at app startup.
|
||||
*
|
||||
* @param context The {@link Context} for operations.
|
||||
* @param reportInfo The {@link ReportInfo} containing info that needs to be displayed.
|
||||
* @return Returns {@link NewInstanceResult}.
|
||||
*/
|
||||
@NonNull
|
||||
public static NewInstanceResult newInstance(@NonNull final Context context,
|
||||
@NonNull final ReportInfo reportInfo) {
|
||||
|
||||
long size = DataUtils.getSerializedSize(reportInfo);
|
||||
if (size > DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES) {
|
||||
String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
|
||||
String reportInfoFilePath =
|
||||
reportInfoDirectoryPath + "/" + CACHE_FILE_BASENAME_PREFIX + reportInfo.reportTimestamp;
|
||||
Logger.logVerbose(LOG_TAG, reportInfo.reportTitle + " " + ReportInfo.class.getSimpleName()
|
||||
+ " serialized object size " + size + " is greater than "
|
||||
+ DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES + " and it will be written to file at path \""
|
||||
+ reportInfoFilePath + "\"");
|
||||
Error error = FileUtils.writeSerializableObjectToFile(ReportInfo.class.getSimpleName(),
|
||||
reportInfoFilePath, reportInfo);
|
||||
if (error != null) {
|
||||
Logger.logErrorExtended(LOG_TAG, error.toString());
|
||||
Logger.showToast(context, Error.getMinimalErrorString(error), true);
|
||||
return new NewInstanceResult(null, null);
|
||||
}
|
||||
|
||||
return new NewInstanceResult(createContentIntent(context, null, reportInfoFilePath),
|
||||
createDeleteIntent(context, reportInfoFilePath));
|
||||
} else {
|
||||
return new NewInstanceResult(createContentIntent(context, reportInfo, null),
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private static Intent createContentIntent(@NonNull final Context context,
|
||||
final ReportInfo reportInfo, final String reportInfoFilePath) {
|
||||
Intent intent = new Intent(context, ReportActivity.class);
|
||||
Bundle bundle = new Bundle();
|
||||
|
||||
if (reportInfoFilePath != null) {
|
||||
bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);
|
||||
} else {
|
||||
bundle.putSerializable(EXTRA_REPORT_INFO_OBJECT, reportInfo);
|
||||
}
|
||||
|
||||
intent.putExtras(bundle);
|
||||
|
||||
// Note that ReportActivity should have `documentLaunchMode="intoExisting"` set in `AndroidManifest.xml`
|
||||
// which has equivalent behaviour to FLAG_ACTIVITY_NEW_DOCUMENT.
|
||||
// FLAG_ACTIVITY_SINGLE_TOP must also be passed for onNewIntent to be called.
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
|
||||
return intent;
|
||||
}
|
||||
|
||||
|
||||
private static Intent createDeleteIntent(@NonNull final Context context,
|
||||
final String reportInfoFilePath) {
|
||||
if (reportInfoFilePath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(context, ReportActivityBroadcastReceiver.class);
|
||||
intent.setAction(ACTION_DELETE_REPORT_INFO_OBJECT_FILE);
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);
|
||||
intent.putExtras(bundle);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
|
||||
@NotNull
|
||||
private static String getReportInfoDirectoryPath(Context context) {
|
||||
// Canonicalize to solve /data/data and /data/user/0 issues when comparing with reportInfoFilePath
|
||||
return FileUtils.getCanonicalPath(context.getCacheDir().getAbsolutePath(), null) + "/"
|
||||
+ CACHE_DIR_BASENAME;
|
||||
}
|
||||
|
||||
private static void deleteReportInfoFile(Context context, String reportInfoFilePath) {
|
||||
if (context == null || reportInfoFilePath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extra protection for mainly if someone set `exported="true"` for ReportActivityBroadcastReceiver
|
||||
String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
|
||||
reportInfoFilePath = FileUtils.getCanonicalPath(reportInfoFilePath, null);
|
||||
if (!reportInfoFilePath.equals(reportInfoDirectoryPath) && reportInfoFilePath.startsWith(
|
||||
reportInfoDirectoryPath + "/")) {
|
||||
Logger.logVerbose(LOG_TAG,
|
||||
"Deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \""
|
||||
+ reportInfoFilePath + "\"");
|
||||
Error error = FileUtils.deleteRegularFile(ReportInfo.class.getSimpleName(),
|
||||
reportInfoFilePath, true);
|
||||
if (error != null) {
|
||||
Logger.logErrorExtended(LOG_TAG, error.toString());
|
||||
}
|
||||
} else {
|
||||
Logger.logError(LOG_TAG,
|
||||
"Not deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \""
|
||||
+ reportInfoFilePath + "\" since its not under \"" + reportInfoDirectoryPath + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete {@link ReportInfo} serialized object files from cache older than x days. If a
|
||||
* notification has still not been opened after x days that's using a PendingIntent to
|
||||
* ReportActivity, then opening the notification will throw a file not found error, so choose days
|
||||
* value appropriately or check if a notification is still active if tracking notification ids.
|
||||
* The {@link Context} object passed must be of the same package with which
|
||||
* {@link #newInstance(Context, ReportInfo)} was called since a call to
|
||||
* {@link Context#getCacheDir()} is made.
|
||||
*
|
||||
* @param context The {@link Context} for operations.
|
||||
* @param days The x amount of days before which files should be deleted. This must be
|
||||
* `>=0`.
|
||||
* @param isSynchronous If set to {@code true}, then the command will be executed in the caller
|
||||
* thread and results returned synchronously. If set to {@code false}, then a
|
||||
* new thread is started run the commands asynchronously in the background
|
||||
* and control is returned to the caller thread.
|
||||
* @return Returns the {@code error} if deleting was not successful, otherwise {@code null}.
|
||||
*/
|
||||
public static Error deleteReportInfoFilesOlderThanXDays(@NonNull final Context context, int days,
|
||||
final boolean isSynchronous) {
|
||||
if (isSynchronous) {
|
||||
return deleteReportInfoFilesOlderThanXDaysInner(context, days);
|
||||
} else {
|
||||
new Thread() {
|
||||
public void run() {
|
||||
Error error = deleteReportInfoFilesOlderThanXDaysInner(context, days);
|
||||
if (error != null) {
|
||||
Logger.logErrorExtended(LOG_TAG, error.toString());
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Error deleteReportInfoFilesOlderThanXDaysInner(@NonNull final Context context,
|
||||
int days) {
|
||||
// Only regular files are deleted and subdirectories are not checked
|
||||
String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
|
||||
Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName()
|
||||
+ " serialized object files under directory path \"" + reportInfoDirectoryPath
|
||||
+ "\" older than " + days + " days");
|
||||
return FileUtils.deleteFilesOlderThanXDays(ReportInfo.class.getSimpleName(),
|
||||
reportInfoDirectoryPath, null, days, true, FileType.REGULAR.getValue());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The {@link BroadcastReceiver} for {@link ReportActivity} that currently does cleanup when
|
||||
* {@link android.app.Notification#deleteIntent} is called. It must be registered in
|
||||
* `AndroidManifest.xml`.
|
||||
*/
|
||||
public static class ReportActivityBroadcastReceiver extends BroadcastReceiver {
|
||||
|
||||
private static final String LOG_TAG = "ReportActivityBroadcastReceiver";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String action = intent.getAction();
|
||||
Logger.logVerbose(LOG_TAG, "onReceive: \"" + action + "\" action");
|
||||
|
||||
if (ACTION_DELETE_REPORT_INFO_OBJECT_FILE.equals(action)) {
|
||||
Bundle bundle = intent.getExtras();
|
||||
if (bundle == null) {
|
||||
return;
|
||||
}
|
||||
if (bundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
|
||||
deleteReportInfoFile(context, bundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.termux.shared.activities;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.InputFilter;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.HorizontalScrollView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
import com.termux.shared.interact.ShareUtils;
|
||||
import com.termux.shared.logger.Logger;
|
||||
import com.termux.shared.R;
|
||||
import com.termux.shared.models.TextIOInfo;
|
||||
import com.termux.shared.view.KeyboardUtils;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* An activity to edit or view text based on config passed as {@link TextIOInfo}.
|
||||
*
|
||||
* Add Following to `AndroidManifest.xml` to use in an app:
|
||||
*
|
||||
* {@code ` <activity android:name="com.termux.shared.activities.TextIOActivity" android:theme="@style/Theme.AppCompat.TermuxTextIOActivity" />` }
|
||||
*/
|
||||
public class TextIOActivity extends AppCompatActivity {
|
||||
|
||||
private static final String CLASS_NAME = ReportActivity.class.getCanonicalName();
|
||||
public static final String EXTRA_TEXT_IO_INFO_OBJECT = CLASS_NAME + ".EXTRA_TEXT_IO_INFO_OBJECT";
|
||||
|
||||
private TextView mTextIOLabel;
|
||||
private View mTextIOLabelSeparator;
|
||||
private EditText mTextIOText;
|
||||
private HorizontalScrollView mTextIOHorizontalScrollView;
|
||||
private LinearLayout mTextIOTextLinearLayout;
|
||||
private TextView mTextIOTextCharacterUsage;
|
||||
|
||||
private TextIOInfo mTextIOInfo;
|
||||
private Bundle mBundle;
|
||||
|
||||
private static final String LOG_TAG = "TextIOActivity";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Logger.logVerbose(LOG_TAG, "onCreate");
|
||||
|
||||
setContentView(R.layout.activity_text_io);
|
||||
|
||||
mTextIOLabel = findViewById(R.id.text_io_label);
|
||||
mTextIOLabelSeparator = findViewById(R.id.text_io_label_separator);
|
||||
mTextIOText = findViewById(R.id.text_io_text);
|
||||
mTextIOHorizontalScrollView = findViewById(R.id.text_io_horizontal_scroll_view);
|
||||
mTextIOTextLinearLayout = findViewById(R.id.text_io_text_linear_layout);
|
||||
mTextIOTextCharacterUsage = findViewById(R.id.text_io_text_character_usage);
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
if (toolbar != null) {
|
||||
setSupportActionBar(toolbar);
|
||||
}
|
||||
|
||||
mBundle = null;
|
||||
Intent intent = getIntent();
|
||||
if (intent != null)
|
||||
mBundle = intent.getExtras();
|
||||
else if (savedInstanceState != null)
|
||||
mBundle = savedInstanceState;
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
Logger.logVerbose(LOG_TAG, "onNewIntent");
|
||||
|
||||
// Views must be re-created since different configs for isEditingTextDisabled() and
|
||||
// isHorizontallyScrollable() will not work or at least reliably
|
||||
finish();
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private void updateUI() {
|
||||
if (mBundle == null) {
|
||||
finish(); return;
|
||||
}
|
||||
|
||||
mTextIOInfo = (TextIOInfo) mBundle.getSerializable(EXTRA_TEXT_IO_INFO_OBJECT);
|
||||
if (mTextIOInfo == null) {
|
||||
finish(); return;
|
||||
}
|
||||
|
||||
final ActionBar actionBar = getSupportActionBar();
|
||||
if (actionBar != null) {
|
||||
if (mTextIOInfo.getTitle() != null)
|
||||
actionBar.setTitle(mTextIOInfo.getTitle());
|
||||
else
|
||||
actionBar.setTitle("Text Input");
|
||||
|
||||
if (mTextIOInfo.shouldShowBackButtonInActionBar()) {
|
||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
||||
actionBar.setDisplayShowHomeEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
mTextIOLabel.setVisibility(View.GONE);
|
||||
mTextIOLabelSeparator.setVisibility(View.GONE);
|
||||
if (mTextIOInfo.isLabelEnabled()) {
|
||||
mTextIOLabel.setVisibility(View.VISIBLE);
|
||||
mTextIOLabelSeparator.setVisibility(View.VISIBLE);
|
||||
mTextIOLabel.setText(mTextIOInfo.getLabel());
|
||||
mTextIOLabel.setFilters(new InputFilter[] { new InputFilter.LengthFilter(TextIOInfo.LABEL_SIZE_LIMIT_IN_BYTES) });
|
||||
mTextIOLabel.setTextSize(mTextIOInfo.getLabelSize());
|
||||
mTextIOLabel.setTextColor(mTextIOInfo.getLabelColor());
|
||||
mTextIOLabel.setTypeface(Typeface.create(mTextIOInfo.getLabelTypeFaceFamily(), mTextIOInfo.getLabelTypeFaceStyle()));
|
||||
}
|
||||
|
||||
|
||||
if (mTextIOInfo.isHorizontallyScrollable()) {
|
||||
mTextIOHorizontalScrollView.setEnabled(true);
|
||||
mTextIOText.setHorizontallyScrolling(true);
|
||||
} else {
|
||||
// Remove mTextIOHorizontalScrollView and add mTextIOText in its place
|
||||
ViewGroup parent = (ViewGroup) mTextIOHorizontalScrollView.getParent();
|
||||
if (parent != null && parent.indexOfChild(mTextIOText) < 0) {
|
||||
ViewGroup.LayoutParams params = mTextIOHorizontalScrollView.getLayoutParams();
|
||||
int index = parent.indexOfChild(mTextIOHorizontalScrollView);
|
||||
mTextIOTextLinearLayout.removeAllViews();
|
||||
mTextIOHorizontalScrollView.removeAllViews();
|
||||
parent.removeView(mTextIOHorizontalScrollView);
|
||||
parent.addView(mTextIOText, index, params);
|
||||
mTextIOText.setHorizontallyScrolling(false);
|
||||
}
|
||||
}
|
||||
|
||||
mTextIOText.setText(mTextIOInfo.getText());
|
||||
mTextIOText.setFilters(new InputFilter[] { new InputFilter.LengthFilter(mTextIOInfo.getTextLengthLimit()) });
|
||||
mTextIOText.setTextSize(mTextIOInfo.getTextSize());
|
||||
mTextIOText.setTextColor(mTextIOInfo.getTextColor());
|
||||
mTextIOText.setTypeface(Typeface.create(mTextIOInfo.getTextTypeFaceFamily(), mTextIOInfo.getTextTypeFaceStyle()));
|
||||
|
||||
// setTextIsSelectable must be called after changing KeyListener to regain focusability and selectivity
|
||||
if (mTextIOInfo.isEditingTextDisabled()) {
|
||||
mTextIOText.setCursorVisible(false);
|
||||
mTextIOText.setKeyListener(null);
|
||||
mTextIOText.setTextIsSelectable(true);
|
||||
}
|
||||
|
||||
if (mTextIOInfo.shouldShowTextCharacterUsage()) {
|
||||
mTextIOTextCharacterUsage.setVisibility(View.VISIBLE);
|
||||
updateTextIOTextCharacterUsage(mTextIOInfo.getText());
|
||||
|
||||
mTextIOText.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
if (editable != null)
|
||||
updateTextIOTextCharacterUsage(editable.toString());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mTextIOTextCharacterUsage.setVisibility(View.GONE);
|
||||
mTextIOText.addTextChangedListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateTextIOInfoText() {
|
||||
if (mTextIOText != null)
|
||||
mTextIOInfo.setText(mTextIOText.getText().toString());
|
||||
}
|
||||
|
||||
private void updateTextIOTextCharacterUsage(String text) {
|
||||
if (text == null) text = "";
|
||||
if (mTextIOTextCharacterUsage != null)
|
||||
mTextIOTextCharacterUsage.setText(String.format(Locale.getDefault(), "%1$d/%2$d", text.length(), mTextIOInfo.getTextLengthLimit()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
|
||||
updateTextIOInfoText();
|
||||
outState.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, mTextIOInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(final Menu menu) {
|
||||
final MenuInflater inflater = getMenuInflater();
|
||||
inflater.inflate(R.menu.menu_text_io, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
String text = "";
|
||||
if (mTextIOText != null)
|
||||
text = mTextIOText.getText().toString();
|
||||
|
||||
int id = item.getItemId();
|
||||
if (id == android.R.id.home) {
|
||||
confirm();
|
||||
} if (id == R.id.menu_item_cancel) {
|
||||
cancel();
|
||||
} else if (id == R.id.menu_item_share_text) {
|
||||
ShareUtils.shareText(this, mTextIOInfo.getTitle(), text);
|
||||
} else if (id == R.id.menu_item_copy_text) {
|
||||
ShareUtils.copyTextToClipboard(this, text, null);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
confirm();
|
||||
}
|
||||
|
||||
/** Confirm current text and send it back to calling {@link Activity}. */
|
||||
private void confirm() {
|
||||
updateTextIOInfoText();
|
||||
KeyboardUtils.hideSoftKeyboard(this, mTextIOText);
|
||||
setResult(Activity.RESULT_OK, getResultIntent());
|
||||
finish();
|
||||
}
|
||||
|
||||
/** Cancel current text and notify calling {@link Activity}. */
|
||||
private void cancel() {
|
||||
KeyboardUtils.hideSoftKeyboard(this, mTextIOText);
|
||||
setResult(Activity.RESULT_CANCELED, getResultIntent());
|
||||
finish();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private Intent getResultIntent() {
|
||||
Intent intent = new Intent();
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, mTextIOInfo);
|
||||
intent.putExtras(bundle);
|
||||
return intent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link Intent} that can be used to start the {@link TextIOActivity}.
|
||||
*
|
||||
* @param context The {@link Context} for operations.
|
||||
* @param textIOInfo The {@link TextIOInfo} containing info for the edit text.
|
||||
*/
|
||||
public static Intent newInstance(@NonNull final Context context, @NonNull final TextIOInfo textIOInfo) {
|
||||
Intent intent = new Intent(context, TextIOActivity.class);
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, textIOInfo);
|
||||
intent.putExtras(bundle);
|
||||
return intent;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.termux.shared.activity;
|
||||
|
||||
import com.termux.shared.errors.Errno;
|
||||
|
||||
public class ActivityErrno extends Errno {
|
||||
|
||||
public static final String TYPE = "Activity Error";
|
||||
|
||||
|
||||
/* Errors for starting activities (100-150) */
|
||||
public static final Errno ERRNO_START_ACTIVITY_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Failed to start \"%1$s\" activity.\nException: %2$s");
|
||||
public static final Errno ERRNO_START_ACTIVITY_FOR_RESULT_FAILED_WITH_EXCEPTION = new Errno(TYPE, 101, "Failed to start \"%1$s\" activity for result.\nException: %2$s");
|
||||
public static final Errno ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT = new Errno(TYPE, 102, "Cannot start \"%1$s\" activity with null Context");
|
||||
|
||||
|
||||
ActivityErrno(final String type, final int code, final String message) {
|
||||
super(type, code, message);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user