diff --git a/README.md b/README.md index 5c6f20b121..3c6b2d1918 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The core [Termux](https://github.com/termux/termux-app) app comes with the follo ## Installation -Latest version is `v0.118.0`. +Latest version is `v0.118.1`. Termux can be obtained through various sources listed below for **only** Android `>= 7`. Support was dropped for Android `5` and `6` on [2020-01-01](https://www.reddit.com/r/termux/comments/dnzdbs/end_of_android56_support_on_20200101/) at `v0.83`, old builds are available on [archive.org](https://archive.org/details/termux-repositories-legacy). diff --git a/app/build.gradle b/app/build.gradle index 10b0db1f5d..9a5d8081aa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,8 +30,8 @@ android { applicationId "com.termux" minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() - versionCode 1000 - versionName "0.118.1" + versionCode 1001 + versionName "0.118.2" if (appVersionName) versionName = appVersionName validateVersionName(versionName) @@ -195,11 +195,11 @@ clean { task downloadBootstraps() { doLast { - def version = "2024.06.17-r1+apt-android-7" - downloadBootstrap("aarch64", "91a90661597fe14bb3c3563f5f65b243c0baaec42f2bc3d2243ff459e3942fb6", version) - downloadBootstrap("arm", "d54b5eb2a305d72f267f9704deaca721b2bebbd3d4cca134aec31da719707997", version) - downloadBootstrap("i686", "06a51ac1c679d68d52045509f1a705622c8f41748ef753660e31e3b6a846eba2", version) - downloadBootstrap("x86_64", "4c8e43474c8d9543e01d4cbf3c4d7f59bbe4d696c38f6dece2b6ab3ba8881f2e", version) + def version = "2025.03.28-r1+apt-android-7" + downloadBootstrap("aarch64", "c8d702b6f742935001c37cda81b8ac69504a95d5cf28f2899532dd8cd4b057eb", version) + downloadBootstrap("arm", "f3bb9d1b32552b34fff41861dbf193ec5ba2848d67d779ac1c7256da6640f85d", version) + downloadBootstrap("i686", "36db3e1ac3547f9a174fd763bd9a484fa1a3449cdd81e1cf2408ff0454f839c6", version) + downloadBootstrap("x86_64", "1c124ec2396ee70a51b0b0a574f29aa659526aa2b9f558f993b2fb05d1e51855", version) } } diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 7a55489223..243e1709dc 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -14,7 +14,6 @@ import android.content.pm.PackageManager; import android.graphics.Color; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.view.ContextMenu; @@ -25,7 +24,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; -import android.view.autofill.AutofillManager; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ListView; @@ -36,6 +34,7 @@ import com.termux.app.terminal.TermuxActivityRootView; import com.termux.shared.activities.ReportActivity; import com.termux.shared.packages.PermissionUtils; +import com.termux.shared.data.DataUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; import com.termux.app.activities.HelpActivity; @@ -162,7 +161,9 @@ public final class TermuxActivity extends Activity implements ServiceConnection private static final int CONTEXT_MENU_SELECT_URL_ID = 0; private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1; - private static final int CONTEXT_MENU_AUTOFILL_ID = 2; + private static final int CONTEXT_MENU_SHARE_SELECTED_TEXT = 10; + private static final int CONTEXT_MENU_AUTOFILL_USERNAME = 11; + private static final int CONTEXT_MENU_AUTOFILL_PASSWORD = 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; @@ -587,17 +588,16 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn TerminalSession currentSession = getCurrentSession(); if (currentSession == null) return; - boolean addAutoFillMenu = false; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillManager autofillManager = getSystemService(AutofillManager.class); - if (autofillManager != null && autofillManager.isEnabled()) { - addAutoFillMenu = true; - } - } + boolean autoFillEnabled = mTerminalView.isAutoFillEnabled(); menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.action_select_url); menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.action_share_transcript); - if (addAutoFillMenu) menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.action_autofill_password); + if (!DataUtils.isNullOrEmpty(mTerminalView.getStoredSelectedText())) + menu.add(Menu.NONE, CONTEXT_MENU_SHARE_SELECTED_TEXT, Menu.NONE, R.string.action_share_selected_text); + if (autoFillEnabled) + menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_USERNAME, Menu.NONE, R.string.action_autofill_username); + if (autoFillEnabled) + menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_PASSWORD, Menu.NONE, R.string.action_autofill_password); menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.action_reset_terminal); menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.action_kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning()); menu.add(Menu.NONE, CONTEXT_MENU_STYLING_ID, Menu.NONE, R.string.action_style_terminal); @@ -625,8 +625,14 @@ public boolean onContextItemSelected(MenuItem item) { case CONTEXT_MENU_SHARE_TRANSCRIPT_ID: mTermuxTerminalViewClient.shareSessionTranscript(); return true; - case CONTEXT_MENU_AUTOFILL_ID: - requestAutoFill(); + case CONTEXT_MENU_SHARE_SELECTED_TEXT: + mTermuxTerminalViewClient.shareSelectedText(); + return true; + case CONTEXT_MENU_AUTOFILL_USERNAME: + mTerminalView.requestAutoFillUsername(); + return true; + case CONTEXT_MENU_AUTOFILL_PASSWORD: + mTerminalView.requestAutoFillPassword(); return true; case CONTEXT_MENU_RESET_TERMINAL_ID: onResetTerminalSession(session); @@ -654,6 +660,13 @@ public boolean onContextItemSelected(MenuItem 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 + mTerminalView.onContextMenuClosed(menu); + } + private void showKillSessionDialog(TerminalSession session) { if (session == null) return; @@ -700,15 +713,6 @@ private void toggleKeepScreenOn() { } } - private void requestAutoFill() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillManager autofillManager = getSystemService(AutofillManager.class); - if (autofillManager != null && autofillManager.isEnabled()) { - autofillManager.requestAutofill(mTerminalView); - } - } - } - /** diff --git a/app/src/main/java/com/termux/app/TermuxInstaller.java b/app/src/main/java/com/termux/app/TermuxInstaller.java index e81634b383..98157a051c 100644 --- a/app/src/main/java/com/termux/app/TermuxInstaller.java +++ b/app/src/main/java/com/termux/app/TermuxInstaller.java @@ -212,24 +212,30 @@ public void run() { throw new RuntimeException("Moving termux prefix staging to prefix directory failed"); } - // Run Termux bootstrap second stage - Logger.logInfo(LOG_TAG, "Running Termux bootstrap second stage."); + // Run Termux bootstrap second stage. String termuxBootstrapSecondStageFile = TERMUX_PREFIX_DIR_PATH + "/etc/termux/bootstrap/termux-bootstrap-second-stage.sh"; - if (FileUtils.fileExists(termuxBootstrapSecondStageFile, false)) { + if (!FileUtils.fileExists(termuxBootstrapSecondStageFile, false)) { + Logger.logInfo(LOG_TAG, "Not running Termux bootstrap second stage since script not found at \"" + termuxBootstrapSecondStageFile + "\" path."); + } else { + if (!FileUtils.fileExists(TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/bash", true)) { + Logger.logInfo(LOG_TAG, "Not running Termux bootstrap second stage since bash not found."); + } + Logger.logInfo(LOG_TAG, "Running Termux bootstrap second stage."); + ExecutionCommand executionCommand = new ExecutionCommand(-1, termuxBootstrapSecondStageFile, null, null, null, true, false); executionCommand.commandLabel = "Termux Bootstrap Second Stage Command"; executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_NORMAL; TermuxTask termuxTask = TermuxTask.execute(activity, executionCommand, null, new TermuxShellEnvironmentClient(), true); - boolean stderrSet = !executionCommand.resultData.stderr.toString().isEmpty(); - if (termuxTask == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0 || stderrSet) { - // Delete prefix directory as otherwise when app is restarted, the broken prefix directory would be used and logged into + if (termuxTask == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0) { + // Generate debug report before deleting broken prefix directory to get `stat` info at time of failure. + showBootstrapErrorDialog(activity, whenDone, MarkdownUtils.getMarkdownCodeForString(executionCommand.toString(), true)); + + // Delete prefix directory as otherwise when app is restarted, the broken prefix directory would be used and logged into. error = FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true); if (error != null) Logger.logErrorExtended(LOG_TAG, error.toString()); - - showBootstrapErrorDialog(activity, whenDone, MarkdownUtils.getMarkdownCodeForString(executionCommand.toString(), true)); return; } } diff --git a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java index b5cb358ec3..539c8753ab 100644 --- a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java +++ b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java @@ -161,6 +161,13 @@ public Cursor query(@NonNull Uri uri, String[] projection, String selection, Str @Override public String getType(@NonNull Uri uri) { + String path = uri.getLastPathSegment(); + int extIndex = path.lastIndexOf('.') + 1; + if (extIndex > 0) { + MimeTypeMap mimeMap = MimeTypeMap.getSingleton(); + String ext = path.substring(extIndex).toLowerCase(); + return mimeMap.getMimeTypeFromExtension(ext); + } return null; } diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java index 78d25fe3b4..a9e60251c9 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java @@ -15,6 +15,7 @@ import com.termux.R; import com.termux.shared.shell.TermuxSession; import com.termux.shared.interact.TextInputDialogUtils; +import com.termux.shared.interact.ShareUtils; import com.termux.app.TermuxActivity; import com.termux.shared.terminal.TermuxTerminalSessionClientBase; import com.termux.shared.termux.TermuxConstants; @@ -177,20 +178,16 @@ public void onSessionFinished(final TerminalSession finishedSession) { public void onCopyTextToClipboard(TerminalSession session, String text) { if (!mActivity.isVisible()) return; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(text))); + ShareUtils.copyTextToClipboard(mActivity, text); } @Override public void onPasteTextFromClipboard(TerminalSession session) { if (!mActivity.isVisible()) return; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData != null) { - CharSequence paste = clipData.getItemAt(0).coerceToText(mActivity); - if (!TextUtils.isEmpty(paste)) mActivity.getTerminalView().mEmulator.paste(paste.toString()); - } + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + mActivity.getTerminalView().mEmulator.paste(text); } @Override diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java index e91172be49..279e3b04c2 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -6,7 +6,6 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import android.content.Intent; import android.media.AudioManager; import android.net.Uri; import android.os.Environment; @@ -647,17 +646,17 @@ public void shareSessionTranscript() { String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); if (transcriptText == null) return; - try { - // See https://github.com/termux/termux-app/issues/1166. - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim(); - intent.putExtra(Intent.EXTRA_TEXT, transcriptText); - intent.putExtra(Intent.EXTRA_SUBJECT, mActivity.getString(R.string.title_share_transcript)); - mActivity.startActivity(Intent.createChooser(intent, mActivity.getString(R.string.title_share_transcript_with))); - } catch (Exception e) { - Logger.logStackTraceWithMessage(LOG_TAG,"Failed to get share session transcript of length " + transcriptText.length(), e); - } + // 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() { @@ -678,9 +677,7 @@ public void showUrlSelection() { // Click to copy url to clipboard: final AlertDialog dialog = new AlertDialog.Builder(mActivity).setItems(urls, (di, which) -> { String url = (String) urls[which]; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(url))); - Toast.makeText(mActivity, R.string.msg_select_url_copied_to_clipboard, Toast.LENGTH_LONG).show(); + 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: @@ -756,12 +753,9 @@ public void doPaste() { if (session == null) return; if (!session.isRunning()) return; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null) return; - CharSequence paste = clipData.getItemAt(0).coerceToText(mActivity); - if (!TextUtils.isEmpty(paste)) - session.getEmulator().paste(paste.toString()); + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + session.getEmulator().paste(text); } } diff --git a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java index 2dae743695..b4807e539b 100644 --- a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java +++ b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java @@ -46,6 +46,8 @@ public class TermuxFileReceiverActivity extends Activity { private static final String LOG_TAG = "TermuxFileReceiverActivity"; static boolean isSharedTextAnUrl(String sharedText) { + if (sharedText == null || sharedText.isEmpty()) return false; + return Patterns.WEB_URL.matcher(sharedText).matches() || Pattern.matches("magnet:\\?xt=urn:btih:.*?", sharedText); } diff --git a/app/src/main/res/layout/activity_termux.xml b/app/src/main/res/layout/activity_termux.xml index 8a8b798381..c30354ef9c 100644 --- a/app/src/main/res/layout/activity_termux.xml +++ b/app/src/main/res/layout/activity_termux.xml @@ -31,8 +31,6 @@ android:focusableInTouchMode="true" android:scrollbarThumbVertical="@drawable/terminal_scroll_shape" android:scrollbars="vertical" - android:importantForAutofill="no" - android:autofillHints="password" tools:ignore="UnusedAttribute" /> Terminal transcript Send transcript to: + Share selected text + Terminal Text + Send selected text to: + + Autofill username Autofill password Reset diff --git a/app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java b/app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java index 9becbefcd0..a693ef500c 100644 --- a/app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java +++ b/app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java @@ -24,6 +24,8 @@ public void testIsSharedTextAnUrl() { List invalidUrls = new ArrayList<>(); invalidUrls.add("a test with example.com"); + invalidUrls.add(""); + invalidUrls.add(null); for (String url : invalidUrls) { Assert.assertFalse(TermuxFileReceiverActivity.isSharedTextAnUrl(url)); } diff --git a/gradle.properties b/gradle.properties index c8f9ae0514..61881c4f49 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,12 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -org.gradle.jvmargs=-Xmx2048M +org.gradle.jvmargs=-Xmx2048M \ +--add-exports=java.base/sun.nio.ch=ALL-UNNAMED \ +--add-opens=java.base/java.lang=ALL-UNNAMED \ +--add-opens=java.base/java.lang.reflect=ALL-UNNAMED \ +--add-opens=java.base/java.io=ALL-UNNAMED \ +--add-exports=jdk.unsupported/sun.misc=ALL-UNNAMED android.useAndroidX=true minSdkVersion=24 diff --git a/terminal-emulator/src/main/java/com/termux/terminal/JNI.java b/terminal-emulator/src/main/java/com/termux/terminal/JNI.java index 99229a6528..e2fc477b91 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/JNI.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/JNI.java @@ -23,10 +23,10 @@ final class JNI { * @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); + public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns, int cellWidth, int cellHeight); /** 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); + public static native void setPtyWindowSize(int fd, int rows, int cols, int cellWidth, int cellHeight); /** * Causes the calling thread to wait for the process associated with the receiver to finish executing. diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index 127ef6d975..8d7eaa8ccc 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -79,9 +79,17 @@ public final class TerminalEmulator { private static final int ESC_CSI_SINGLE_QUOTE = 18; /** Escape processing: CSI ! */ private static final int ESC_CSI_EXCLAMATION = 19; - - /** The number of parameter arguments. This name comes from the ANSI standard for terminal escape codes. */ - private static final int MAX_ESCAPE_PARAMETERS = 16; + /** Escape processing: "ESC _" or Application Program Command (APC). */ + private static final int ESC_APC = 20; + /** Escape processing: "ESC _" or Application Program Command (APC), followed by Escape. */ + private static final int ESC_APC_ESCAPE = 21; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE = 23; + + /** The number of parameter arguments including colon separated sub-parameters. */ + private static final int MAX_ESCAPE_PARAMETERS = 32; /** Needs to be large enough to contain reasonable OSC 52 pastes. */ private static final int MAX_OSC_STRING_LENGTH = 8192; @@ -126,17 +134,15 @@ public final class TerminalEmulator { private String mTitle; private final Stack mTitleStack = new Stack<>(); - /** If processing first character of first parameter of {@link #ESC_CSI}. */ - private boolean mIsCSIStart; - /** The last character processed of a parameter of {@link #ESC_CSI}. */ - private Integer mLastCSIArg; - /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ private int mCursorRow, mCursorCol; /** The number of character rows and columns in the terminal screen. */ public int mRows, mColumns; + /** Size of a terminal cell in pixels. */ + private int mCellWidthPixels, mCellHeightPixels; + /** The number of terminal transcript rows that can be scrolled back to. */ public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100; public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000; @@ -176,6 +182,8 @@ public final class TerminalEmulator { private int mArgIndex; /** Holds the arguments of the current escape sequence. */ private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS]; + /** Holds the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */ + private int mArgsSubParamsBitSet = 0; /** Holds OSC and device control arguments, which can be strings. */ private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); @@ -236,15 +244,17 @@ public final class TerminalEmulator { private boolean mCursorBlinkState; /** - * Current foreground and background colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. + * Current foreground, background and underline colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. * For a 24-bit value the top byte (0xff000000) is set. * + *

Note that the underline color is currently parsed but not yet used during rendering. + * * @see TextStyle */ - int mForeColor, mBackColor; + int mForeColor, mBackColor, mUnderlineColor; /** Current {@link TextStyle} effect. */ - private int mEffect; + int mEffect; /** * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along @@ -312,13 +322,15 @@ static int mapDecSetBitToInternalBit(int decsetBit) { } } - public TerminalEmulator(TerminalOutput session, int columns, int rows, Integer transcriptRows, TerminalSessionClient client) { + public TerminalEmulator(TerminalOutput session, int columns, int rows, int cellWidthPixels, int cellHeightPixels, Integer transcriptRows, TerminalSessionClient client) { mSession = session; mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows); mAltBuffer = new TerminalBuffer(columns, rows, rows); mClient = client; mRows = rows; mColumns = columns; + mCellWidthPixels = cellWidthPixels; + mCellHeightPixels = cellHeightPixels; mTabStop = new boolean[mColumns]; reset(); } @@ -368,7 +380,10 @@ public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed } } - public void resize(int columns, int rows) { + public void resize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + this.mCellWidthPixels = cellWidthPixels; + this.mCellHeightPixels = cellHeightPixels; + if (mRows == rows && mColumns == columns) { return; } else if (columns < 2 || rows < 2) { @@ -550,6 +565,15 @@ private void processByte(byte byteToProcess) { } public void processCodePoint(int b) { + // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early. + if (mEscapeState == ESC_APC) { + doApc(b); + return; + } else if (mEscapeState == ESC_APC_ESCAPE) { + doApcEscape(b); + return; + } + switch (b) { case 0: // Null character (NUL, ^@). Do nothing. break; @@ -635,6 +659,10 @@ public void processCodePoint(int b) { case ESC_CSI: doCsi(b); break; + case ESC_CSI_UNSUPPORTED_PARAMETER_BYTE: + case ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE: + doCsiUnsupportedParameterOrIntermediateByte(b); + break; case ESC_CSI_EXCLAMATION: if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). reset(); @@ -1006,12 +1034,67 @@ private void doDeviceControl(int b) { } } + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApc(int b) { + if (b == 27) { + continueSequence(ESC_APC_ESCAPE); + } + // Eat APC sequences silently for now. + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApcEscape(int b) { + if (b == '\\') { + // A String Terminator (ST), ending the APC escape sequence. + finishSequence(); + } else { + // The Escape character was not the start of a String Terminator (ST), + // but instead just data inside of the APC escape sequence. + continueSequence(ESC_APC); + } + } + private int nextTabStop(int numTabs) { for (int i = mCursorCol + 1; i < mColumns; i++) if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); return mRightMargin - 1; } + /** + * Process byte while in the {@link #ESC_CSI_UNSUPPORTED_PARAMETER_BYTE} or + * {@link #ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE} escape state. + * + * Parse unsupported parameter, intermediate and final bytes but ignore them. + * + * > For Control Sequence Introducer, ... the ESC [ is followed by + * > - any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?), + * > - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), + * > - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~). + * + * - https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands + * - https://invisible-island.net/xterm/ecma-48-parameter-format.html#section5.4 + */ + private void doCsiUnsupportedParameterOrIntermediateByte(int b) { + if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) { + // Supported `0–9:;>?` or unsupported `<=` parameter byte after an + // initial unsupported parameter byte in `doCsi()`, or a sequential parameter byte. + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + } else if (b >= 0x20 && b <= 0x2F) { + // Optional intermediate byte `!"#$%&'()*+,-./` after parameter or intermediate byte. + continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE); + } else if (b >= 0x40 && b <= 0x7E) { + // Final byte `@A–Z[\]^_`a–z{|}~` after parameter or intermediate byte. + // Calling `unknownSequence()` would log an error with only a final byte, so ignore it for now. + finishSequence(); + } else { + unknownSequence(b); + } + } + /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ private void doCsiQuestionMark(int b) { switch (b) { @@ -1281,6 +1364,7 @@ private void startEscapeSequence() { mEscapeState = ESC; mArgIndex = 0; Arrays.fill(mArgs, -1); + mArgsSubParamsBitSet = 0; } private void doLinefeed() { @@ -1375,8 +1459,8 @@ private void doEsc(int b) { // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal // position on the preceding line. If the active position is at the top margin, a scroll down is performed". if (mCursorRow <= mTopMargin) { - mScreen.blockCopy(0, mTopMargin, mColumns, mBottomMargin - (mTopMargin + 1), 0, mTopMargin + 1); - blockClear(0, mTopMargin, mColumns); + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin); } else { mCursorRow--; } @@ -1390,8 +1474,6 @@ private void doEsc(int b) { break; case '[': continueSequence(ESC_CSI); - mIsCSIStart = true; - mLastCSIArg = null; break; case '=': // DECKPAM setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); @@ -1403,6 +1485,9 @@ private void doEsc(int b) { case '>': // DECKPNM setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); break; + case '_': // APC - Application Program Command. + continueSequence(ESC_APC); + break; default: unknownSequence(b); break; @@ -1584,8 +1669,8 @@ private void doCsi(int b) { final int linesToScrollArg = getArg0(1); final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin; final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg); - mScreen.blockCopy(0, mTopMargin, mColumns, linesBetweenTopAndBottomMargins - linesToScroll, 0, mTopMargin + linesToScroll); - blockClear(0, mTopMargin, mColumns, linesToScroll); + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll); } else { // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. unimplementedSequence(b); @@ -1607,12 +1692,16 @@ private void doCsi(int b) { } mCursorCol = newCol; break; - case '?': // Esc [ ? -- start of a private mode set + case '?': // Esc [ ? -- start of a private parameter byte continueSequence(ESC_CSI_QUESTIONMARK); break; - case '>': // "Esc [ >" -- + case '>': // "Esc [ >" -- start of a private parameter byte continueSequence(ESC_CSI_BIGGERTHAN); break; + case '<': // "Esc [ <" -- start of a private parameter byte + case '=': // "Esc [ =" -- start of a private parameter byte + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + break; case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). setCursorColRespectingOriginMode(getArg0(1) - 1); break; @@ -1712,8 +1801,10 @@ private void doCsi(int b) { mSession.write("\033[3;0;0t"); break; case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t - // We just report characters time 12 here. - mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * 12, mColumns * 12)); + mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels)); + break; + case 16: // Report xterm character cell size in pixels. Result is CSI 6 ; height ; width t + mSession.write(String.format(Locale.US, "\033[6;%d;%dt", mCellHeightPixels, mCellWidthPixels)); break; case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); @@ -1762,7 +1853,12 @@ private void doCsi(int b) { private void selectGraphicRendition() { if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; for (int i = 0; i <= mArgIndex; i++) { - int code = mArgs[i]; + // Skip leading sub parameters: + if ((mArgsSubParamsBitSet & (1 << i)) != 0) { + continue; + } + + int code = getArg(i, 0, false); if (code < 0) { if (mArgIndex > 0) { continue; @@ -1781,7 +1877,19 @@ private void selectGraphicRendition() { } else if (code == 3) { mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC; } else if (code == 4) { - mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet & (1 << (i + 1))) != 0)) { + // Sub parameter, see https://sw.kovidgoyal.net/kitty/underlines/ + i++; + if (mArgs[i] == 0) { + // No underline. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else { + // Different variations of underlines: https://sw.kovidgoyal.net/kitty/underlines/ + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } } else if (code == 5) { mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; } else if (code == 7) { @@ -1810,8 +1918,8 @@ private void selectGraphicRendition() { mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; } else if (code >= 30 && code <= 37) { mForeColor = code - 30; - } else if (code == 38 || code == 48) { - // Extended set foreground(38)/background (48) color. + } else if (code == 38 || code == 48 || code == 58) { + // Extended set foreground(38)/background(48)/underline(58) color. // This is followed by either "2;$R;$G;$B" to set a 24-bit color or // "5;$INDEX" to set an indexed color. if (i + 2 > mArgIndex) continue; @@ -1820,27 +1928,30 @@ private void selectGraphicRendition() { if (i + 4 > mArgIndex) { Logger.logWarn(mClient, LOG_TAG, "Too few CSI" + code + ";2 RGB arguments"); } else { - int red = mArgs[i + 2], green = mArgs[i + 3], blue = mArgs[i + 4]; + int red = getArg(i + 2, 0, false); + int green = getArg(i + 3, 0, false); + int blue = getArg(i + 4, 0, false); + if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); } else { - int argbColor = 0xff000000 | (red << 16) | (green << 8) | blue; - if (code == 38) { - mForeColor = argbColor; - } else { - mBackColor = argbColor; + int argbColor = 0xff_00_00_00 | (red << 16) | (green << 8) | blue; + switch (code) { + case 38: mForeColor = argbColor; break; + case 48: mBackColor = argbColor; break; + case 58: mUnderlineColor = argbColor; break; } } i += 4; // "2;P_r;P_g;P_r" } } else if (firstArg == 5) { - int color = mArgs[i + 2]; + int color = getArg(i + 2, 0, false); i += 2; // "5;P_s" if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { - if (code == 38) { - mForeColor = color; - } else { - mBackColor = color; + switch (code) { + case 38: mForeColor = color; break; + case 48: mBackColor = color; break; + case 58: mUnderlineColor = color; break; } } else { if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color); @@ -1854,6 +1965,8 @@ private void selectGraphicRendition() { mBackColor = code - 40; } else if (code == 49) { // Set default background color. mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (code == 59) { // Set default underline color. + mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND; } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). mForeColor = code - 90 + 8; } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). @@ -2089,67 +2202,64 @@ private void setCursorPosition(int x, int y) { private void scrollDownOneLine() { mScrollCounter++; + long currentStyle = getStyle(); if (mLeftMargin != 0 || mRightMargin != mColumns) { // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); // .. and blank bottom row between margins: - mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', mEffect); + mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', currentStyle); } else { - mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, getStyle()); + mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle); } } /** * Process the next ASCII character of a parameter. * - * Parameter characters modify the action or interpretation of the sequence. You can use up to - * 16 parameters per sequence. You must use the ; character to separate parameters. - * All parameters are unsigned, positive decimal integers, with the most significant + *

You must use the ; character to separate parameters and : to separate sub-parameters. + * + *

Parameter characters modify the action or interpretation of the sequence. Originally + * you can use up to 16 parameters per sequence, but following at least xterm and alacritty + * we use a common space for parameters and sub-parameters, allowing 32 in total. + * + *

All parameters are unsigned, positive decimal integers, with the most significant * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999 * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value * or omitted parameter indicates a default value for the sequence. For most * sequences, the default value is 1. * - * https://vt100.net/docs/vt510-rm/chapter4.html#S4.3.3 + *

References: + * VT510 Video Terminal Programmer Information: Control Sequences + * alacritty/vte: Implement colon separated CSI parameters * */ - private void parseArg(int inputByte) { - int[] bytes = new int[]{inputByte}; - // Only doing this for ESC_CSI and not for other ESC_CSI_* since they seem to be using their - // own defaults with getArg*() calls, but there may be missed cases - if (mEscapeState == ESC_CSI) { - if ((mIsCSIStart && inputByte == ';') || // If sequence starts with a ; character, like \033[;m - (!mIsCSIStart && mLastCSIArg != null && mLastCSIArg == ';' && inputByte == ';')) { // If sequence contains sequential ; characters, like \033[;;m - bytes = new int[]{'0', ';'}; // Assume 0 was passed - } - } - - mIsCSIStart = false; - - for (int b : bytes) { - if (b >= '0' && b <= '9') { - if (mArgIndex < mArgs.length) { - int oldValue = mArgs[mArgIndex]; - int thisDigit = b - '0'; - int value; - if (oldValue >= 0) { - value = oldValue * 10 + thisDigit; - } else { - value = thisDigit; - } - if (value > 9999) - value = 9999; - mArgs[mArgIndex] = value; + private void parseArg(int b) { + if (b >= '0' && b <= '9') { + if (mArgIndex < mArgs.length) { + int oldValue = mArgs[mArgIndex]; + int thisDigit = b - '0'; + int value; + if (oldValue >= 0) { + value = oldValue * 10 + thisDigit; + } else { + value = thisDigit; } - continueSequence(mEscapeState); - } else if (b == ';') { - if (mArgIndex < mArgs.length) { - mArgIndex++; + if (value > 9999) + value = 9999; + mArgs[mArgIndex] = value; + } + continueSequence(mEscapeState); + } else if (b == ';' || b == ':') { + if (mArgIndex + 1 < mArgs.length) { + mArgIndex++; + if (b == ':') { + mArgsSubParamsBitSet |= 1 << mArgIndex; } - continueSequence(mEscapeState); } else { - unknownSequence(b); + logError("Too many parameters when in state: " + mEscapeState); } - mLastCSIArg = b; + continueSequence(mEscapeState); + } else { + unknownSequence(b); } } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java index b06f0dea80..324e0bc01b 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -21,7 +21,7 @@ * A terminal session, consisting of a process coupled to a terminal interface. *

* 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. + * {@link #updateSize(int, int, 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. *

* The child process may be exited forcefully by using the {@link #finishIfRunning()} method. @@ -61,7 +61,7 @@ public final class TerminalSession extends TerminalOutput { /** * 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)}. + * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int, int, int)}. */ private int mTerminalFileDescriptor; @@ -100,12 +100,12 @@ public void updateTerminalSessionClient(TerminalSessionClient client) { } /** Inform the attached pty of the new size and reflow or initialize the emulator. */ - public void updateSize(int columns, int rows) { + public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { if (mEmulator == null) { - initializeEmulator(columns, rows); + initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels); } else { - JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns); - mEmulator.resize(columns, rows); + JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns, cellWidthPixels, cellHeightPixels); + mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels); } } @@ -120,11 +120,11 @@ public String getTitle() { * @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); + public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient); int[] processId = new int[1]; - mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns); + mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns, cellWidthPixels, cellHeightPixels); mShellPid = processId[0]; final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient); diff --git a/terminal-emulator/src/main/jni/termux.c b/terminal-emulator/src/main/jni/termux.c index 22f1d507d1..8cd5e7891d 100644 --- a/terminal-emulator/src/main/jni/termux.c +++ b/terminal-emulator/src/main/jni/termux.c @@ -29,7 +29,9 @@ static int create_subprocess(JNIEnv* env, char** envp, int* pProcessId, jint rows, - jint columns) + jint columns, + jint cell_width, + jint cell_height) { int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC); if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx"); @@ -57,7 +59,7 @@ static int create_subprocess(JNIEnv* env, tcsetattr(ptm, TCSANOW, &tios); /** Set initial winsize. */ - struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns }; + struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns, .ws_xpixel = (unsigned short) (columns * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height)}; ioctl(ptm, TIOCSWINSZ, &sz); pid_t pid = fork(); @@ -121,7 +123,9 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess( jobjectArray envVars, jintArray processIdArray, jint rows, - jint columns) + jint columns, + jint cell_width, + jint cell_height) { jsize size = args ? (*env)->GetArrayLength(env, args) : 0; char** argv = NULL; @@ -156,7 +160,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess( 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); + int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns, cell_width, cell_height); (*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8); (*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd); @@ -178,9 +182,9 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess( 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) +JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyWindowSize(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd, jint rows, jint cols, jint cell_width, jint cell_height) { - struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols }; + struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols, .ws_xpixel = (unsigned short) (cols * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height) }; ioctl(fd, TIOCSWINSZ, &sz); } diff --git a/terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java b/terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java new file mode 100644 index 0000000000..4f6292aaa9 --- /dev/null +++ b/terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java @@ -0,0 +1,21 @@ +package com.termux.terminal; + +public class ApcTest extends TerminalTestCase { + + public void testApcConsumed() { + // At time of writing this is part of what yazi sends for probing for kitty graphics protocol support: + // https://github.com/sxyazi/yazi/blob/0cdaff98d0b3723caff63eebf1974e7907a43a2c/yazi-adapter/src/emulator.rs#L129 + // This should not result in anything being written to the screen: If kitty graphics protocol support + // is implemented it should instead result in an error code on stdin, and if not it should be consumed + // silently just as xterm does. See https://sw.kovidgoyal.net/kitty/graphics-protocol/. + withTerminalSized(2, 2) + .enterString("\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\") + .assertLinesAre(" ", " "); + + // It is ok for the APC content to be non printable characters: + withTerminalSized(12, 2) + .enterString("hello \033_some\023\033_\\apc#end\033\\ world") + .assertLinesAre("hello world", " "); + } + +} diff --git a/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java b/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java index 5a12edd749..9f123bc1d1 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java @@ -1,5 +1,7 @@ package com.termux.terminal; +import java.util.List; + /** "\033[" is the Control Sequence Introducer char sequence (CSI). */ public class ControlSequenceIntroducerTest extends TerminalTestCase { @@ -62,4 +64,68 @@ public void testCsi3J() { assertEquals("y\nz", mTerminal.getScreen().getTranscriptText()); } + public void testReportPixelSize() { + int columns = 3; + int rows = 3; + withTerminalSized(columns, rows); + int cellWidth = TerminalTest.INITIAL_CELL_WIDTH_PIXELS; + int cellHeight = TerminalTest.INITIAL_CELL_HEIGHT_PIXELS; + assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t"); + assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t"); + columns = 23; + rows = 33; + resize(columns, rows); + assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t"); + assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t"); + cellWidth = 8; + cellHeight = 18; + mTerminal.resize(columns, rows, cellWidth, cellHeight); + assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t"); + assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t"); + } + + /** + * See Colored and styled underlines: + * + *

+     *  [4:0m  # no underline
+     *  [4:1m  # straight underline
+     *  [4:2m  # double underline
+     *  [4:3m  # curly underline
+     *  [4:4m  # dotted underline
+     *  [4:5m  # dashed underline
+     *  [4m    # straight underline (for backwards compat)
+     *  [24m   # no underline (for backwards compat)
+     * 
+ *

+ * We currently parse the variants, but map them to normal/no underlines as appropriate + */ + public void testUnderlineVariants() { + for (String suffix : List.of("", ":1", ":2", ":3", ":4", ":5")) { + for (String stop : List.of("24", "4:0")) { + withTerminalSized(3, 3); + enterString("\033[4" + suffix + "m").assertLinesAre(" ", " ", " "); + assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect); + enterString("\033[4;1m").assertLinesAre(" ", " ", " "); + assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect); + enterString("\033[" + stop + "m").assertLinesAre(" ", " ", " "); + assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD, mTerminal.mEffect); + } + } + } + + public void testManyParameters() { + StringBuilder b = new StringBuilder("\033["); + for (int i = 0; i < 30; i++) { + b.append("0;"); + } + b.append("4:2"); + // This clearing of underline should be ignored as the parameters pass the threshold for too many parameters: + b.append("4:0m"); + withTerminalSized(3, 3) + .enterString(b.toString()) + .assertLinesAre(" ", " ", " "); + assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect); + } + } diff --git a/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java b/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java index a252e1a8cb..17f8111205 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java @@ -11,10 +11,10 @@ public void testHistory() { assertLinesAre("777", "888", "999"); assertHistoryStartsWith("666", "555"); - mTerminal.resize(cols, 2); + resize(cols, 2); assertHistoryStartsWith("777", "666", "555"); - mTerminal.resize(cols, 3); + resize(cols, 3); assertHistoryStartsWith("666", "555"); } diff --git a/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java b/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java index fa4a702473..6c32d663b4 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java @@ -72,11 +72,11 @@ public void testResizeAfterHistoryWraparound() { enterString("\r\n"); } assertLinesAre("998 ", "999 ", " "); - mTerminal.resize(cols, 2); + resize(cols, 2); assertLinesAre("999 ", " "); - mTerminal.resize(cols, 5); + resize(cols, 5); assertLinesAre("996 ", "997 ", "998 ", "999 ", " "); - mTerminal.resize(cols, rows); + resize(cols, rows); assertLinesAre("998 ", "999 ", " "); } diff --git a/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java b/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java index cc01c0454b..039605a144 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java @@ -75,6 +75,16 @@ public void testNELRespectsLeftMargin() { withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033[?6h\033ED").assertLinesAre("ABC", " D ", " "); } + public void testRiRespectsLeftMargin() { + // Reverse Index (RI), ${ESC}M, should respect horizontal margins: + withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033M").assertLinesAre("A D", " BC ", " "); + } + + public void testSdRespectsLeftMargin() { + // Scroll Down (SD), ${CSI}${N}T, should respect horizontal margins: + withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033[2T").assertLinesAre("A D", " ", " BC "); + } + public void testBackwardIndex() { // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 7. // Without margins: @@ -127,4 +137,31 @@ public void testScrollRegionDoesNotLimitCursorMovement() { " xxx" ); } + + /** + * See reported issue. + */ + public void testClearingWhenScrollingWithMargins() { + int newForeground = 2; + int newBackground = 3; + int size = 3; + TerminalTestCase terminal = withTerminalSized(size, size) + // Enable horizontal margin and set left margin to 1: + .enterString("\033[?69h\033[2s") + // Set foreground and background color: + .enterString("\033[" + (30 + newForeground) + ";" + (40 + newBackground) + "m") + // Enter newlines to scroll down: + .enterString("\r\n\r\n\r\n\r\n\r\n"); + for (int row = 0; row < size; row++) { + for (int col = 0; col < size; col++) { + // The first column (outside of the scrolling area, due to us setting a left scroll + // margin of 1) should be unmodified, the others should use the current style: + int expectedForeground = col == 0 ? TextStyle.COLOR_INDEX_FOREGROUND : newForeground; + int expectedBackground = col == 0 ? TextStyle.COLOR_INDEX_BACKGROUND : newBackground; + terminal.assertForegroundColorAt(row, col, expectedForeground); + terminal.assertBackgroundColorAt(row, col, expectedBackground); + } + } + } + } diff --git a/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java b/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java index 89ad9eca67..3e1f824265 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java @@ -82,7 +82,7 @@ public void testReportTerminalSize() throws Exception { 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); + resize(width, height); assertEnteringStringGivesResponse("\033[18t", "\033[8;" + height + ";" + width + "t"); } } @@ -137,6 +137,11 @@ public void testPaste() { } public void testSelectGraphics() { + selectGraphicsTestRun(';'); + selectGraphicsTestRun(':'); + } + + public void selectGraphicsTestRun(char separator) { withTerminalSized(5, 5); enterString("\033[31m"); assertEquals(mTerminal.mForeColor, 1); @@ -155,40 +160,59 @@ public void testSelectGraphics() { // Check TerminalEmulator.parseArg() enterString("\033[31m\033[m"); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); - enterString("\033[31m\033[;m"); + enterString("\033[31m\033[;m".replace(';', separator)); 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"); + enterString("\033[31m\033[0;m".replace(';', separator)); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); enterString("\033[31;;m"); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + enterString("\033[31::m"); + assertEquals(1, mTerminal.mForeColor); + enterString("\033[31;m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + enterString("\033[31:m"); + assertEquals(1, mTerminal.mForeColor); + enterString("\033[31;;41m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + assertEquals(1, mTerminal.mBackColor); + enterString("\033[0m"); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); // 256 colors: - enterString("\033[38;5;119m"); + enterString("\033[38;5;119m".replace(';', separator)); assertEquals(119, mTerminal.mForeColor); assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); - enterString("\033[48;5;129m"); + enterString("\033[48;5;129m".replace(';', separator)); assertEquals(119, mTerminal.mForeColor); assertEquals(129, mTerminal.mBackColor); // Invalid parameter: - enterString("\033[48;8;129m"); + enterString("\033[48;8;129m".replace(';', separator)); assertEquals(119, mTerminal.mForeColor); assertEquals(129, mTerminal.mBackColor); // Multiple parameters at once: - enterString("\033[38;5;178;48;5;179;m"); + enterString("\033[38;5;178".replace(';', separator) + ";" + "48;5;179m".replace(';', separator)); assertEquals(178, mTerminal.mForeColor); assertEquals(179, mTerminal.mBackColor); + // Omitted parameter means zero: + enterString("\033[38;5;m".replace(';', separator)); + assertEquals(0, mTerminal.mForeColor); + assertEquals(179, mTerminal.mBackColor); + enterString("\033[48;5;m".replace(';', separator)); + assertEquals(0, mTerminal.mForeColor); + assertEquals(0, mTerminal.mBackColor); + // 24 bit colors: enterString(("\033[0m")); // Reset fg and bg colors. - enterString("\033[38;2;255;127;2m"); + enterString("\033[38;2;255;127;2m".replace(';', separator)); 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"); + enterString("\033[48;2;1;2;254m".replace(';', separator)); int expectedBackground = 0xff000000 | (1 << 16) | (2 << 8) | 254; assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(expectedBackground, mTerminal.mBackColor); @@ -197,14 +221,30 @@ public void testSelectGraphics() { 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"); + enterString("\033[38;2;255;127;2".replace(';', separator) + ";" + "48;2;1;2;254m".replace(';', separator)); 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"); + enterString("\033[38;2;300;127;2;48;2;1;300;254m".replace(';', separator)); assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(expectedBackground, mTerminal.mBackColor); + + // 24 bit colors, omitted parameter means zero: + enterString("\033[38;2;255;127;m".replace(';', separator)); + expectedForeground = 0xff000000 | (255 << 16) | (127 << 8); + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(expectedBackground, mTerminal.mBackColor); + enterString("\033[38;2;123;;77m".replace(';', separator)); + expectedForeground = 0xff000000 | (123 << 16) | 77; + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(expectedBackground, mTerminal.mBackColor); + + // 24 bit colors, extra sub-parameters are skipped: + expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2; + enterString("\033[0;38:2:255:127:2:48:2:1:2:254m"); + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); } public void testBackgroundColorErase() { diff --git a/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java b/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java index eb1da8c094..4490207932 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java @@ -13,7 +13,10 @@ public abstract class TerminalTestCase extends TestCase { - public static class MockTerminalOutput extends TerminalOutput { + public static final int INITIAL_CELL_WIDTH_PIXELS = 13; + public static final int INITIAL_CELL_HEIGHT_PIXELS = 15; + + public static class MockTerminalOutput extends TerminalOutput { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); public final List titleChanges = new ArrayList<>(); public final List clipboardPuts = new ArrayList<>(); @@ -108,7 +111,7 @@ protected void setUp() throws Exception { 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); + mTerminal = new TerminalEmulator(mOutput, columns, rows, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS, rows * 2, null); return this; } @@ -201,7 +204,7 @@ public TerminalTestCase assertLinesAre(String... lines) { } public TerminalTestCase resize(int cols, int rows) { - mTerminal.resize(cols, rows); + mTerminal.resize(cols, rows, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS); assertInvariants(); return this; } @@ -301,6 +304,11 @@ public void assertForegroundColorAt(int externalRow, int column, int color) { assertEquals(color, TextStyle.decodeForeColor(style)); } + public void assertBackgroundColorAt(int externalRow, int column, int color) { + long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column); + assertEquals(color, TextStyle.decodeBackColor(style)); + } + public TerminalTestCase assertColor(int colorIndex, int expected) { int actual = mTerminal.mColors.mCurrentColors[colorIndex]; if (expected != actual) { diff --git a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java index 307e422694..a4bef7d37c 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -233,7 +233,7 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int mTextPaint.setColor(foreColor); // The text alignment is the default Paint.Align.LEFT. - canvas.drawText(text, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, mTextPaint); + canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); } if (savedMatrix) canvas.restore(); diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index 2b56d66ea6..351f47a4b7 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -2,6 +2,7 @@ import android.annotation.SuppressLint; import android.annotation.TargetApi; +import android.app.Activity; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -10,6 +11,7 @@ import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.text.Editable; import android.text.InputType; import android.text.TextUtils; @@ -19,17 +21,20 @@ import android.view.InputDevice; import android.view.KeyCharacterMap; import android.view.KeyEvent; +import android.view.Menu; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityManager; +import android.view.autofill.AutofillManager; import android.view.autofill.AutofillValue; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.Scroller; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.termux.terminal.KeyHandler; @@ -81,6 +86,43 @@ public final class TerminalView extends View { /** If non-zero, this is the last unicode code point received if that was a combining character. */ int mCombiningAccent; + /** + * The current AutoFill type returned for {@link View#getAutofillType()} by {@link #getAutofillType()}. + * + * The default is {@link #AUTOFILL_TYPE_NONE} so that AutoFill UI, like toolbar above keyboard + * is not shown automatically, like on Activity starts/View create. This value should be updated + * to required value, like {@link #AUTOFILL_TYPE_TEXT} before calling + * {@link AutofillManager#requestAutofill(View)} so that AutoFill UI shows. The updated value + * set will automatically be restored to {@link #AUTOFILL_TYPE_NONE} in + * {@link #autofill(AutofillValue)} so that AutoFill UI isn't shown anymore by calling + * {@link #resetAutoFill()}. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int mAutoFillType = AUTOFILL_TYPE_NONE; + + /** + * The current AutoFill type returned for {@link View#getImportantForAutofill()} by + * {@link #getImportantForAutofill()}. + * + * The default is {@link #IMPORTANT_FOR_AUTOFILL_NO} so that view is not considered important + * for AutoFill. This value should be updated to required value, like + * {@link #IMPORTANT_FOR_AUTOFILL_YES} before calling {@link AutofillManager#requestAutofill(View)} + * so that Android and apps consider the view as important for AutoFill to process the request. + * The updated value set will automatically be restored to {@link #IMPORTANT_FOR_AUTOFILL_NO} in + * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; + + /** + * The current AutoFill hints returned for {@link View#getAutofillHints()} ()} by {@link #getAutofillHints()} ()}. + * + * The default is an empty `string[]`. This value should be updated to required value. The + * updated value set will automatically be restored an empty `string[]` in + * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. + */ + private String[] mAutoFillHints = new String[0]; + private final boolean mAccessibilityEnabled; private static final String LOG_TAG = "TerminalView"; @@ -440,6 +482,14 @@ public void onScreenUpdated() { if (mAccessibilityEnabled) setContentDescription(getText()); } + /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)} + * when context menu for the {@link TerminalView} is started by + * {@link TextSelectionCursorController#ACTION_MORE} is closed. */ + public void onContextMenuClosed(Menu menu) { + // Unset the stored text since it shouldn't be used anymore and should be cleared from memory + unsetStoredSelectedText(); + } + /** * Sets the text size, which in turn sets the number of rows and columns. * @@ -550,11 +600,14 @@ public boolean onTouchEvent(MotionEvent event) { if (action == MotionEvent.ACTION_DOWN) showContextMenu(); return true; } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) { - ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); + ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); if (clipData != null) { - CharSequence paste = clipData.getItemAt(0).coerceToText(getContext()); - if (!TextUtils.isEmpty(paste)) mEmulator.paste(paste.toString()); + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem != null) { + CharSequence text = clipItem.coerceToText(getContext()); + if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString()); + } } } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY. switch (event.getAction()) { @@ -578,6 +631,7 @@ public boolean onKeyPreIme(int keyCode, KeyEvent event) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")"); if (keyCode == KeyEvent.KEYCODE_BACK) { + cancelRequestAutoFill(); if (isSelectingText()) { stopTextSelectionMode(); return true; @@ -839,6 +893,9 @@ public boolean handleKeyCode(int keyCode, int keyMod) { if (mEmulator != null) mEmulator.setCursorBlinkState(true); + if (handleKeyCodeAction(keyCode, keyMod)) + return true; + TerminalEmulator term = mTermSession.getEmulator(); String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()); if (code == null) return false; @@ -846,6 +903,26 @@ public boolean handleKeyCode(int keyCode, int keyMod) { return true; } + public boolean handleKeyCodeAction(int keyCode, int keyMod) { + boolean shiftDown = (keyMod & KeyHandler.KEYMOD_SHIFT) != 0; + + switch (keyCode) { + case KeyEvent.KEYCODE_PAGE_UP: + case KeyEvent.KEYCODE_PAGE_DOWN: + // shift+page_up and shift+page_down should scroll scrollback history instead of + // scrolling command history or changing pages + if (shiftDown) { + long time = SystemClock.uptimeMillis(); + MotionEvent motionEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0); + doScroll(motionEvent, keyCode == KeyEvent.KEYCODE_PAGE_UP ? -mEmulator.mRows : mEmulator.mRows); + motionEvent.recycle(); + return true; + } + } + + return false; + } + /** * Called when a key is released in the view. * @@ -893,7 +970,7 @@ public void updateSize() { int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) { - mTermSession.updateSize(newColumns, newRows); + mTermSession.updateSize(newColumns, newRows, (int) mRenderer.getFontWidth(), mRenderer.getFontLineSpacing()); mEmulator = mTermSession.getEmulator(); mClient.onEmulatorSet(); @@ -971,12 +1048,20 @@ public void autofill(AutofillValue value) { if (value.isText()) { mTermSession.write(value.getTextValue().toString()); } + + resetAutoFill(); } @RequiresApi(api = Build.VERSION_CODES.O) @Override public int getAutofillType() { - return AUTOFILL_TYPE_TEXT; + return mAutoFillType; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public String[] getAutofillHints() { + return mAutoFillHints; } @RequiresApi(api = Build.VERSION_CODES.O) @@ -985,6 +1070,95 @@ public AutofillValue getAutofillValue() { return AutofillValue.forText(""); } + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public int getImportantForAutofill() { + return mAutoFillImportance; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private synchronized void resetAutoFill() { + // Restore none type so that AutoFill UI isn't shown anymore. + mAutoFillType = AUTOFILL_TYPE_NONE; + mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; + mAutoFillHints = new String[0]; + } + + public AutofillManager getAutoFillManagerService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null; + + try { + Context context = getContext(); + if (context == null) return null; + return context.getSystemService(AutofillManager.class); + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to get AutofillManager service", e); + return null; + } + } + + public boolean isAutoFillEnabled() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + return autofillManager != null && autofillManager.isEnabled(); + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to check if Autofill is enabled", e); + return false; + } + } + + public synchronized void requestAutoFillUsername() { + requestAutoFill( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_USERNAME} : + null); + } + + public synchronized void requestAutoFillPassword() { + requestAutoFill( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_PASSWORD} : + null); + } + + public synchronized void requestAutoFill(String[] autoFillHints) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (autoFillHints == null || autoFillHints.length < 1) return; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + if (autofillManager != null && autofillManager.isEnabled()) { + // Update type that will be returned by `getAutofillType()` so that AutoFill UI is shown. + mAutoFillType = AUTOFILL_TYPE_TEXT; + // Update importance that will be returned by `getImportantForAutofill()` so that + // AutoFill considers the view as important. + mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_YES; + // Update hints that will be returned by `getAutofillHints()` for which to show AutoFill UI. + mAutoFillHints = autoFillHints; + autofillManager.requestAutofill(this); + } + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to request Autofill", e); + } + } + + public synchronized void cancelRequestAutoFill() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (mAutoFillType == AUTOFILL_TYPE_NONE) return; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + if (autofillManager != null && autofillManager.isEnabled()) { + resetAutoFill(); + autofillManager.cancel(); + } + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to cancel Autofill request", e); + } + } + + + /** @@ -1184,6 +1358,25 @@ public boolean isSelectingText() { } } + /** Get the currently selected text if selecting. */ + public String getSelectedText() { + if (isSelectingText() && mTextSelectionCursorController != null) + return mTextSelectionCursorController.getSelectedText(); + else + return null; + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mTextSelectionCursorController != null ? mTextSelectionCursorController.getStoredSelectedText() : null; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + if (mTextSelectionCursorController != null) mTextSelectionCursorController.unsetStoredSelectedText(); + } + private ActionMode getTextSelectionActionMode() { if (mTextSelectionCursorController != null) { return mTextSelectionCursorController.getActionMode(); diff --git a/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java b/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java index 09e9ba224c..714d56e4fa 100644 --- a/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java +++ b/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java @@ -12,6 +12,8 @@ import android.view.MotionEvent; import android.view.View; +import androidx.annotation.Nullable; + import com.termux.terminal.TerminalBuffer; import com.termux.terminal.WcWidth; import com.termux.view.R; @@ -21,6 +23,7 @@ public class TextSelectionCursorController implements CursorController { private final TerminalView terminalView; private final TextSelectionHandleView mStartHandle, mEndHandle; + private String mStoredSelectedText; private boolean mIsSelectingText = false; private long mShowStartTime = System.currentTimeMillis(); @@ -28,9 +31,9 @@ public class TextSelectionCursorController implements CursorController { private int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1; private ActionMode mActionMode; - private final int ACTION_COPY = 1; - private final int ACTION_PASTE = 2; - private final int ACTION_MORE = 3; + public final int ACTION_COPY = 1; + public final int ACTION_PASTE = 2; + public final int ACTION_MORE = 3; public TextSelectionCursorController(TerminalView terminalView) { this.terminalView = terminalView; @@ -113,7 +116,7 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy_text).setShowAsAction(show); - menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard.hasPrimaryClip()).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show); menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more); return true; } @@ -132,7 +135,7 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { switch (item.getItemId()) { case ACTION_COPY: - String selectedText = terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2).trim(); + String selectedText = getSelectedText(); terminalView.mTermSession.onCopyTextToClipboard(selectedText); terminalView.stopTextSelectionMode(); break; @@ -141,7 +144,13 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { terminalView.mTermSession.onPasteTextFromClipboard(); break; case ACTION_MORE: - terminalView.stopTextSelectionMode(); //we stop text selection first, otherwise handles will show above popup + // We first store the selected text in case TerminalViewClient needs the + // selected text before MORE button was pressed since we are going to + // stop selection mode + mStoredSelectedText = getSelectedText(); + // The text selection needs to be stopped before showing context menu, + // otherwise handles will show above popup + terminalView.stopTextSelectionMode(); terminalView.showContextMenu(); break; } @@ -356,6 +365,22 @@ public void getSelectors(int[] sel) { sel[3] = mSelX2; } + /** Get the currently selected text. */ + public String getSelectedText() { + return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2); + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mStoredSelectedText; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + mStoredSelectedText = null; + } + public ActionMode getActionMode() { return mActionMode; } diff --git a/termux-shared/build.gradle b/termux-shared/build.gradle index 297e115791..2bbd716cfb 100644 --- a/termux-shared/build.gradle +++ b/termux-shared/build.gradle @@ -14,6 +14,7 @@ android { implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" implementation "io.noties.markwon:linkify:$markwonVersion" implementation "io.noties.markwon:recycler:$markwonVersion" + implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:5.0' // Do not increment version higher than 1.0.0-alpha09 since it will break ViewUtils and needs to be looked into // noinspection GradleDependency diff --git a/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java b/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java new file mode 100644 index 0000000000..13810eb7b3 --- /dev/null +++ b/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java @@ -0,0 +1,96 @@ +package com.termux.shared.android; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; + +import java.lang.reflect.Method; + +public class SELinuxUtils { + + public static final String ANDROID_OS_SELINUX_CLASS = "android.os.SELinux"; + + private static final String LOG_TAG = "SELinuxUtils"; + + /** + * Gets the security context of the current process. + * + * @return Returns a {@link String} representing the security context of the current process. + * This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getContext() { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + String methodName = "getContext"; + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(ANDROID_OS_SELINUX_CLASS); + Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName); + if (method == null) { + Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class"); + return null; + } + + return (String) ReflectionUtils.invokeMethod(method, null).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e); + return null; + } + } + + /** + * Get the security context of a given process id. + * + * @param pid The pid of process. + * @return Returns a {@link String} representing the security context of the given pid. + * This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getPidContext(int pid) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + String methodName = "getPidContext"; + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(ANDROID_OS_SELINUX_CLASS); + Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName, int.class); + if (method == null) { + Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class"); + return null; + } + + return (String) ReflectionUtils.invokeMethod(method, null, pid).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e); + return null; + } + } + + /** + * Get the security context of a file object. + * + * @param path The pathname of the file object. + * @return Returns a {@link String} representing the security context of the file. + * This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getFileContext(@NonNull String path) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + String methodName = "getFileContext"; + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(ANDROID_OS_SELINUX_CLASS); + Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName, String.class); + if (method == null) { + Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class"); + return null; + } + + return (String) ReflectionUtils.invokeMethod(method, null, path).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e); + return null; + } + } + +} diff --git a/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java b/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java index 030ce47dba..22adab3968 100644 --- a/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java @@ -11,7 +11,6 @@ import android.os.Environment; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; import com.termux.shared.R; import com.termux.shared.data.DataUtils; @@ -23,6 +22,8 @@ import java.nio.charset.Charset; +import javax.annotation.Nullable; + public class ShareUtils { private static final String LOG_TAG = "ShareUtils"; @@ -56,6 +57,18 @@ private static void openSystemAppChooser(final Context context, final Intent int * @param text The text to share. */ public static void shareText(final Context context, final String subject, final String text) { + shareText(context, subject, text, null); + } + + /** + * Share text. + * + * @param context The context for operations. + * @param subject The subject for sharing. + * @param text The text to share. + * @param title The title for share menu. + */ + public static void shareText(final Context context, final String subject, final String text, @Nullable final String title) { if (context == null || text == null) return; final Intent shareTextIntent = new Intent(Intent.ACTION_SEND); @@ -63,29 +76,85 @@ public static void shareText(final Context context, final String subject, final shareTextIntent.putExtra(Intent.EXTRA_SUBJECT, subject); shareTextIntent.putExtra(Intent.EXTRA_TEXT, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, true, false, false)); - openSystemAppChooser(context, shareTextIntent, context.getString(R.string.title_share_with)); + openSystemAppChooser(context, shareTextIntent, DataUtils.isNullOrEmpty(title) ? context.getString(R.string.title_share_with) : title); + } + + + + /** Wrapper for {@link #copyTextToClipboard(Context, String, String, String)} with `null` `clipDataLabel` and `toastString`. */ + public static void copyTextToClipboard(Context context, final String text) { + copyTextToClipboard(context, null, text, null); + } + + /** Wrapper for {@link #copyTextToClipboard(Context, String, String, String)} with `null` `clipDataLabel`. */ + public static void copyTextToClipboard(Context context, final String text, final String toastString) { + copyTextToClipboard(context, null, text, toastString); } /** - * Copy the text to clipboard. + * Copy the text to primary clip of the clipboard. * * @param context The context for operations. + * @param clipDataLabel The label to show to the user describing the copied text. * @param text The text to copy. * @param toastString If this is not {@code null} or empty, then a toast is shown if copying to * clipboard is successful. */ - public static void copyTextToClipboard(final Context context, final String text, final String toastString) { + public static void copyTextToClipboard(Context context, @Nullable final String clipDataLabel, + final String text, final String toastString) { if (context == null || text == null) return; - final ClipboardManager clipboardManager = ContextCompat.getSystemService(context, ClipboardManager.class); + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboardManager == null) return; - if (clipboardManager != null) { - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, true, false, false))); - if (toastString != null && !toastString.isEmpty()) - Logger.showToast(context, toastString, true); - } + clipboardManager.setPrimaryClip(ClipData.newPlainText(clipDataLabel, + DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, + true, false, false))); + + if (toastString != null && !toastString.isEmpty()) + Logger.showToast(context, toastString, true); } + + + /** + * Wrapper for {@link #getTextFromClipboard(Context, boolean)} that returns primary text {@link String} + * if its set and not empty. + */ + @Nullable + public static String getTextStringFromClipboardIfSet(Context context, boolean coerceToText) { + CharSequence textCharSequence = getTextFromClipboard(context, coerceToText); + if (textCharSequence == null) return null; + String textString = textCharSequence.toString(); + return !textString.isEmpty() ? textString : null; + } + + /** + * Get the text from primary clip of the clipboard. + * + * @param context The context for operations. + * @param coerceToText Whether to call {@link ClipData.Item#coerceToText(Context)} to coerce + * non-text data to text. + * @return Returns the {@link CharSequence} of primary text. This will be `null` if failed to get it. + */ + @Nullable + public static CharSequence getTextFromClipboard(Context context, boolean coerceToText) { + if (context == null) return null; + + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboardManager == null) return null; + + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData == null) return null; + + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem == null) return null; + + return coerceToText ? clipItem.coerceToText(context) : clipItem.getText(); + } + + + /** * Open a url. * diff --git a/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java b/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java index 60997b2730..dbe8d2c25c 100644 --- a/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java @@ -8,6 +8,7 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; +import android.os.Build; import android.os.UserManager; import androidx.annotation.NonNull; @@ -17,8 +18,10 @@ import com.termux.shared.data.DataUtils; import com.termux.shared.interact.MessageDialogUtils; import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; import com.termux.shared.termux.TermuxConstants; +import java.lang.reflect.Field; import java.security.MessageDigest; import java.util.List; @@ -94,6 +97,55 @@ public static PackageInfo getPackageInfoForPackage(@NonNull final Context contex } } + /** + * Get the {@code seInfo} {@link Field} of the {@link ApplicationInfo} class. + * + * String retrieved from the seinfo tag found in selinux policy. This value can be set through + * the mac_permissions.xml policy construct. This value is used for setting an SELinux security + * context on the process as well as its data directory. + * + * https://cs.android.com/android/platform/superproject/+/android-7.1.0_r1:frameworks/base/core/java/android/content/pm/ApplicationInfo.java;l=609 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/content/pm/ApplicationInfo.java;l=981 + * https://cs.android.com/android/platform/superproject/+/android-7.0.0_r1:frameworks/base/services/core/java/com/android/server/pm/SELinuxMMAC.java;l=282 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/pm/SELinuxMMAC.java;l=375 + * https://cs.android.com/android/_/android/platform/frameworks/base/+/be0b8896d1bc385d4c8fb54c21929745935dcbea + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the selinux info or {@code null} if an exception was raised. + */ + @Nullable + public static String getApplicationInfoSeInfoForPackage(@NonNull final ApplicationInfo applicationInfo) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (String) ReflectionUtils.invokeField(ApplicationInfo.class, Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? "seinfo" : "seInfo", applicationInfo).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get seInfo field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Get the {@code seInfoUser} {@link Field} of the {@link ApplicationInfo} class. + * + * Also check {@link #getApplicationInfoSeInfoForPackage(ApplicationInfo)}. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the selinux info user or {@code null} if an exception was raised. + */ + @Nullable + public static String getApplicationInfoSeInfoUserForPackage(@NonNull final ApplicationInfo applicationInfo) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null; + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (String) ReflectionUtils.invokeField(ApplicationInfo.class, "seInfoUser", applicationInfo).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get seInfoUser field value for ApplicationInfo class", e); + return null; + } + } + /** * Get the app name for the package associated with the {@code context}. * diff --git a/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java b/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java new file mode 100644 index 0000000000..d4f2ac2876 --- /dev/null +++ b/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java @@ -0,0 +1,275 @@ +package com.termux.shared.reflection; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; + +import org.lsposed.hiddenapibypass.HiddenApiBypass; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; + +public class ReflectionUtils { + + private static boolean HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED = Build.VERSION.SDK_INT < Build.VERSION_CODES.P; + + private static final String LOG_TAG = "ReflectionUtils"; + + /** + * Bypass android hidden API reflection restrictions. + * https://github.com/LSPosed/AndroidHiddenApiBypass + * https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces + */ + public static void bypassHiddenAPIReflectionRestrictions() { + if (!HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Logger.logDebug(LOG_TAG, "Bypassing android hidden api reflection restrictions"); + HiddenApiBypass.addHiddenApiExemptions(""); + HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED = true; + } + } + + /** Check if android hidden API reflection restrictions are bypassed. */ + public static boolean areHiddenAPIReflectionRestrictionsBypassed() { + return HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED; + } + + + + + + /** + * Get a {@link Field} for the specified class. + * + * @param clazz The {@link Class} for which to return the field. + * @param fieldName The name of the {@link Field}. + * @return Returns the {@link Field} if getting the it was successful, otherwise {@code null}. + */ + @Nullable + public static Field getDeclaredField(@NonNull Class clazz, @NonNull String fieldName) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return field; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field for \"" + clazz.getName() + "\" class", e); + return null; + } + } + + + + /** Class that represents result of invoking a field. */ + public static class FieldInvokeResult { + public boolean success; + public Object value; + + FieldInvokeResult(boolean success, Object value) { + this.value = success; + this.value = value; + } + } + + /** + * Get a value for a {@link Field} of an object for the specified class. + * + * @param clazz The {@link Class} to which the object belongs to. + * @param fieldName The name of the {@link Field}. + * @param object The {@link Object} instance from which to get the field value. + * @return Returns the {@link FieldInvokeResult} of invoking the field. The + * {@link FieldInvokeResult#success} will be {@code true} if invoking the field was successful, + * otherwise {@code false}. The {@link FieldInvokeResult#value} will contain the field + * {@link Object} value. + */ + @NonNull + public static FieldInvokeResult invokeField(@NonNull Class clazz, @NonNull String fieldName, T object) { + try { + Field field = getDeclaredField(clazz, fieldName); + if (field == null) return new FieldInvokeResult(false, null); + return new FieldInvokeResult(true, field.get(object)); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field value for \"" + clazz.getName() + "\" class", e); + return new FieldInvokeResult(false, null); + } + } + + + + + + /** + * Wrapper for {@link #getDeclaredMethod(Class, String, Class[])} without parameters. + */ + @Nullable + public static Method getDeclaredMethod(@NonNull Class clazz, @NonNull String methodName) { + return getDeclaredMethod(clazz, methodName, new Class[0]); + } + + /** + * Get a {@link Method} for the specified class with the specified parameters. + * + * @param clazz The {@link Class} for which to return the method. + * @param methodName The name of the {@link Method}. + * @param parameterTypes The parameter types of the method. + * @return Returns the {@link Method} if getting the it was successful, otherwise {@code null}. + */ + @Nullable + public static Method getDeclaredMethod(@NonNull Class clazz, @NonNull String methodName, Class... parameterTypes) { + try { + Method method = clazz.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return method; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + methodName + "\" method for \"" + clazz.getName() + "\" class with parameter types: " + Arrays.toString(parameterTypes), e); + return null; + } + } + + + + /** + * Wrapper for {@link #invokeVoidMethod(Method, Object, Object...)} without arguments. + */ + public static boolean invokeVoidMethod(@NonNull Method method, Object obj) { + return invokeVoidMethod(method, obj, new Object[0]); + } + + /** + * Invoke a {@link Method} on the specified object with the specified arguments that returns + * {@code void}. + * + * @param method The {@link Method} to invoke. + * @param obj The {@link Object} the method should be invoked from. + * @param args The arguments to pass to the method. + * @return Returns {@code true} if invoking the method was successful, otherwise {@code false}. + */ + public static boolean invokeVoidMethod(@NonNull Method method, Object obj, Object... args) { + try { + method.setAccessible(true); + method.invoke(obj, args); + return true; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + method.getName() + "\" method with object \"" + obj + "\" and args: " + Arrays.toString(args), e); + return false; + } + } + + + + /** Class that represents result of invoking a method that has a non-void return type. */ + public static class MethodInvokeResult { + public boolean success; + public Object value; + + MethodInvokeResult(boolean success, Object value) { + this.value = success; + this.value = value; + } + } + + /** + * Wrapper for {@link #invokeMethod(Method, Object, Object...)} without arguments. + */ + @NonNull + public static MethodInvokeResult invokeMethod(@NonNull Method method, Object obj) { + return invokeMethod(method, obj, new Object[0]); + } + + /** + * Invoke a {@link Method} on the specified object with the specified arguments. + * + * @param method The {@link Method} to invoke. + * @param obj The {@link Object} the method should be invoked from. + * @param args The arguments to pass to the method. + * @return Returns the {@link MethodInvokeResult} of invoking the method. The + * {@link MethodInvokeResult#success} will be {@code true} if invoking the method was successful, + * otherwise {@code false}. The {@link MethodInvokeResult#value} will contain the {@link Object} + * returned by the method. + */ + @NonNull + public static MethodInvokeResult invokeMethod(@NonNull Method method, Object obj, Object... args) { + try { + method.setAccessible(true); + return new MethodInvokeResult(true, method.invoke(obj, args)); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + method.getName() + "\" method with object \"" + obj + "\" and args: " + Arrays.toString(args), e); + return new MethodInvokeResult(false, null); + } + } + + + + /** + * Wrapper for {@link #getConstructor(String, Class[])} without parameters. + */ + @Nullable + public static Constructor getConstructor(@NonNull String className) { + return getConstructor(className, new Class[0]); + } + + /** + * Wrapper for {@link #getConstructor(Class, Class[])} to get a {@link Constructor} for the + * {@code className}. + */ + @Nullable + public static Constructor getConstructor(@NonNull String className, Class... parameterTypes) { + try { + return getConstructor(Class.forName(className), parameterTypes); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get constructor for \"" + className + "\" class with parameter types: " + Arrays.toString(parameterTypes), e); + return null; + } + } + + /** + * Get a {@link Constructor} for the specified class with the specified parameters. + * + * @param clazz The {@link Class} for which to return the constructor. + * @param parameterTypes The parameter types of the constructor. + * @return Returns the {@link Constructor} if getting the it was successful, otherwise {@code null}. + */ + @Nullable + public static Constructor getConstructor(@NonNull Class clazz, Class... parameterTypes) { + try { + Constructor constructor = clazz.getConstructor(parameterTypes); + constructor.setAccessible(true); + return constructor; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get constructor for \"" + clazz.getName() + "\" class with parameter types: " + Arrays.toString(parameterTypes), e); + return null; + } + } + + + + /** + * Wrapper for {@link #invokeConstructor(Constructor, Object...)} without arguments. + */ + @Nullable + public static Object invokeConstructor(@NonNull Constructor constructor) { + return invokeConstructor(constructor, new Object[0]); + } + + /** + * Invoke a {@link Constructor} with the specified arguments. + * + * @param constructor The {@link Constructor} to invoke. + * @param args The arguments to pass to the constructor. + * @return Returns the new instance if invoking the constructor was successful, otherwise {@code null}. + */ + @Nullable + public static Object invokeConstructor(@NonNull Constructor constructor, Object... args) { + try { + constructor.setAccessible(true); + return constructor.newInstance(args); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + constructor.getName() + "\" constructor with args: " + Arrays.toString(args), e); + return null; + } + } + +} diff --git a/termux-shared/src/main/java/com/termux/shared/shell/TermuxShellUtils.java b/termux-shared/src/main/java/com/termux/shared/shell/TermuxShellUtils.java index c41ca55c46..f15761785f 100644 --- a/termux-shared/src/main/java/com/termux/shared/shell/TermuxShellUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/shell/TermuxShellUtils.java @@ -1,9 +1,13 @@ package com.termux.shared.shell; import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.os.Build; import androidx.annotation.NonNull; +import com.termux.shared.android.SELinuxUtils; +import com.termux.shared.data.DataUtils; import com.termux.shared.models.errors.Error; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.file.FileUtils; @@ -57,10 +61,42 @@ public static String[] buildEnvironment(Context currentPackageContext, boolean i if (TERMUX_API_VERSION_NAME != null) environment.add("TERMUX_API_VERSION=" + TERMUX_API_VERSION_NAME); + + environment.add("TERM=xterm-256color"); environment.add("COLORTERM=truecolor"); + + try { + ApplicationInfo applicationInfo = currentPackageContext.getPackageManager().getApplicationInfo( + TermuxConstants.TERMUX_PACKAGE_NAME, 0); + if (applicationInfo != null && !applicationInfo.enabled) { + applicationInfo = null; + } + + if (applicationInfo != null) { + environment.add("TERMUX_APP__DATA_DIR=" + applicationInfo.dataDir); + environment.add("TERMUX_APP__LEGACY_DATA_DIR=" + "/data/data/" + applicationInfo.packageName); + environment.add("TERMUX_APP__BUILD_DATA_DIR=" + TermuxConstants.TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH); + + environment.add("TERMUX_APP__SE_FILE_CONTEXT=" + SELinuxUtils.getFileContext(applicationInfo.dataDir)); + + String seInfoUser = PackageUtils.getApplicationInfoSeInfoUserForPackage(applicationInfo); + environment.add("TERMUX_APP__SE_INFO=" + PackageUtils.getApplicationInfoSeInfoForPackage(applicationInfo) + + (DataUtils.isNullOrEmpty(seInfoUser) ? "" : seInfoUser)); + } + + } catch (final Exception e) { + // Ignore + } + + environment.add("TERMUX__ROOTFS=" + TermuxConstants.TERMUX_FILES_DIR_PATH); environment.add("HOME=" + TermuxConstants.TERMUX_HOME_DIR_PATH); + environment.add("TERMUX__HOME=" + TermuxConstants.TERMUX_HOME_DIR_PATH); environment.add("PREFIX=" + TermuxConstants.TERMUX_PREFIX_DIR_PATH); + environment.add("TERMUX__PREFIX=" + TermuxConstants.TERMUX_PREFIX_DIR_PATH); + + environment.add("TERMUX__SE_PROCESS_CONTEXT=" + SELinuxUtils.getContext()); + environment.add("BOOTCLASSPATH=" + System.getenv("BOOTCLASSPATH")); environment.add("ANDROID_ROOT=" + System.getenv("ANDROID_ROOT")); environment.add("ANDROID_DATA=" + System.getenv("ANDROID_DATA")); @@ -75,6 +111,8 @@ public static String[] buildEnvironment(Context currentPackageContext, boolean i addToEnvIfPresent(environment, "ANDROID_RUNTIME_ROOT"); addToEnvIfPresent(environment, "ANDROID_TZDATA_ROOT"); + environment.add("ANDROID__BUILD_VERSION_SDK=" + Build.VERSION.SDK_INT); + if (isFailSafe) { // Keep the default path so that system binaries can be used in the failsafe session. environment.add("PATH= " + System.getenv("PATH")); diff --git a/termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysConstants.java b/termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysConstants.java index f05ea48162..d5aced0360 100644 --- a/termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysConstants.java +++ b/termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysConstants.java @@ -10,7 +10,10 @@ public class ExtraKeysConstants { /** Defines the repetitive keys that can be passed to {@link ExtraKeysView#setRepetitiveKeys(List)}. */ - public static List PRIMARY_REPETITIVE_KEYS = Arrays.asList("UP", "DOWN", "LEFT", "RIGHT", "BKSP", "DEL"); + public static List PRIMARY_REPETITIVE_KEYS = Arrays.asList( + "UP", "DOWN", "LEFT", "RIGHT", + "BKSP", "DEL", + "PGUP", "PGDN"); diff --git a/termux-shared/src/main/java/com/termux/shared/termux/AndroidUtils.java b/termux-shared/src/main/java/com/termux/shared/termux/AndroidUtils.java index 2b1ac94296..554f10a7d9 100644 --- a/termux-shared/src/main/java/com/termux/shared/termux/AndroidUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/termux/AndroidUtils.java @@ -2,11 +2,16 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.content.pm.ApplicationInfo; import android.os.Build; +import android.system.Os; +import android.system.OsConstants; import androidx.annotation.NonNull; import com.google.common.base.Joiner; +import com.termux.shared.android.SELinuxUtils; +import com.termux.shared.data.DataUtils; import com.termux.shared.logger.Logger; import com.termux.shared.markdown.MarkdownUtils; import com.termux.shared.packages.PackageUtils; @@ -32,6 +37,8 @@ public class AndroidUtils { */ public static String getAppInfoMarkdownString(@NonNull final Context context) { StringBuilder markdownString = new StringBuilder(); + ApplicationInfo applicationInfo = context.getApplicationInfo(); + if (applicationInfo == null) return null; AndroidUtils.appendPropertyToMarkdown(markdownString,"APP_NAME", PackageUtils.getAppNameForPackage(context)); AndroidUtils.appendPropertyToMarkdown(markdownString,"PACKAGE_NAME", PackageUtils.getPackageNameForPackage(context)); @@ -44,6 +51,13 @@ public static String getAppInfoMarkdownString(@NonNull final Context context) { AndroidUtils.appendPropertyToMarkdown(markdownString,"IS_INSTALLED_ON_EXTERNAL_STORAGE", true); } + AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_PROCESS_CONTEXT", SELinuxUtils.getContext()); + AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_FILE_CONTEXT", SELinuxUtils.getFileContext(context.getFilesDir().getAbsolutePath())); + + String seInfoUser = PackageUtils.getApplicationInfoSeInfoUserForPackage(applicationInfo); + AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_INFO", PackageUtils.getApplicationInfoSeInfoForPackage(applicationInfo) + + (DataUtils.isNullOrEmpty(seInfoUser) ? "" : seInfoUser)); + String filesDir = context.getFilesDir().getAbsolutePath(); if (!filesDir.equals("/data/user/0/" + context.getPackageName() + "/files") && !filesDir.equals("/data/data/" + context.getPackageName() + "/files")) @@ -99,7 +113,19 @@ public static String getDeviceInfoMarkdownString(@NonNull final Context context) appendPropertyToMarkdown(markdownString, "BOARD", Build.BOARD); appendPropertyToMarkdown(markdownString, "HARDWARE", Build.HARDWARE); appendPropertyToMarkdown(markdownString, "DEVICE", Build.DEVICE); + appendPropertyToMarkdown(markdownString, "SUPPORTED_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_ABIS)); + appendPropertyToMarkdown(markdownString, "SUPPORTED_32_BIT_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_32_BIT_ABIS)); + appendPropertyToMarkdown(markdownString, "SUPPORTED_64_BIT_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_64_BIT_ABIS)); + + // If on Android >= 15 + if (Build.VERSION.SDK_INT >= 35) { + try { + appendPropertyToMarkdownIfSet(markdownString, "PAGE_SIZE", Os.sysconf(OsConstants._SC_PAGESIZE)); + } catch (Throwable t) { + // Ignore + } + } markdownString.append("\n##\n");