Refactor TermuxActivity

This commit majorly refactors `TermuxActivity` and moves its view components and functions into dedicated classes.

- The view layouts and ids have been given meaningful names, like `termux_activity.xml`.
- The `TerminalToolbarViewPager` class has been created to handle the now called toolbar that shows on the bottom of the terminal view. It currently contains extra keys view defined by `terminal_toolbar_extra_keys_view.xml` file and a text input view defined by `terminal_toolbar_text_input_view.xml` file when user can switch to by swiping left. The input text will now be preserved if android destroys the activity or its recreated.
- The `TermuxSessionsListViewController` class has been created to handle view related functionality of the termux sessions list shown in the left drawer, namely view creation, `onItemClick()`, `onItemLongClick()`, etc. Its list view is defined by `termux_activity.xml` file and each item's layout is defined by the `terminal_sessions_list_item.xml` file.
- The `TextDataUtils` class has been added to the `com.termux.app.utils` package for text utils.
- The design for the `SessionChangedCallback` interface for `TerminalSession` has been majorly changed. Firstly, it has been renamed and moved from `TerminalSession` to the dedicated `TerminalSessionClient` class file. The interface now also supports the termux app centralized logging framework so that `TerminalSession` and `TerminalEmulator` can use them. Previously, `TermuxService` was implementing a wrapper interface, which would then call the real interface defined by the `TermuxActivity` if it was currently bound to the service. This cluttered and partially duplicated the code. Now, the implementation is defined by the `TermuxSessionClientBase` and `TermuxSessionClient` classes. The `TermuxSessionClientBase` implements the `TerminalSessionClient` interface but the definition of the activity related functions do not do anything, only the background ones like the logging functions are fully implemented. The `TermuxSessionClient` class inherits from the `TermuxSessionClientBase` class and provides the implementation for the activity related functions. The design for how this works is that if the `TermuxService` is not bound to `TermuxActivity`, it just passes the `TermuxSessionClientBase` implementation to `TerminalSession`. If the activity is bound at some point, then in `onServiceConnected()` it replaces/updates the client objects stored in `TerminalSession` and `TerminalEmulator` with `TermuxSessionClient`, and then replaces them back with `TermuxSessionClientBase` in `onDestroy()`. This seems to be working for now without an issue.
This commit is contained in:
agnostic-apollo
2021-03-16 05:01:09 +05:00
parent 5e0b29bb6d
commit c9e18e5b93
19 changed files with 1659 additions and 998 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,12 @@ import com.termux.R;
import com.termux.app.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY;
import com.termux.app.TermuxConstants.TERMUX_APP.TERMUX_SERVICE;
import com.termux.app.settings.preferences.TermuxSharedPreferences;
import com.termux.app.terminal.TermuxSessionClient;
import com.termux.app.terminal.TermuxSessionClientBase;
import com.termux.app.utils.Logger;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSession.SessionChangedCallback;
import com.termux.terminal.TerminalSessionClient;
import java.io.File;
import java.util.ArrayList;
@@ -44,13 +47,11 @@ import java.util.List;
* Optionally may hold a wake and a wifi lock, in which case that is shown in the notification - see
* {@link #buildNotification()}.
*/
public final class TermuxService extends Service implements SessionChangedCallback {
public final class TermuxService extends Service {
private static final String NOTIFICATION_CHANNEL_ID = "termux_notification_channel";
private static final int NOTIFICATION_ID = 1337;
private static final String LOG_TAG = "TermuxService";
/** This service is only bound from inside the same process and never uses IPC. */
class LocalBinder extends Binder {
public final TermuxService service = TermuxService.this;
@@ -63,15 +64,23 @@ public final class TermuxService extends Service implements SessionChangedCallba
/**
* The terminal sessions which this service manages.
* <p/>
* Note that this list is observed by {@link TermuxActivity#mListViewAdapter}, so any changes must be made on the UI
* Note that this list is observed by {@link TermuxActivity#mTermuxSessionListViewController}, so any changes must be made on the UI
* thread and followed by a call to {@link ArrayAdapter#notifyDataSetChanged()} }.
*/
final List<TerminalSession> mTerminalSessions = new ArrayList<>();
final List<BackgroundJob> mBackgroundTasks = new ArrayList<>();
/** Note that the service may often outlive the activity, so need to clear this reference. */
SessionChangedCallback mSessionChangeCallback;
/** The full implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that holds activity references for activity related functions.
* Note that the service may often outlive the activity, so need to clear this reference.
*/
TermuxSessionClient mTermuxSessionClient;
/** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that does not hold activity references.
*/
final TermuxSessionClientBase mTermuxSessionClientBase = new TermuxSessionClientBase();;
/** The wake lock and wifi lock are always acquired and released together. */
private PowerManager.WakeLock mWakeLock;
@@ -80,6 +89,8 @@ public final class TermuxService extends Service implements SessionChangedCallba
/** If the user has executed the {@link TermuxConstants.TERMUX_APP.TERMUX_SERVICE#ACTION_STOP_SERVICE} intent. */
boolean mWantsToStop = false;
private static final String LOG_TAG = "TermuxService";
@SuppressLint("Wakelock")
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
@@ -266,7 +277,7 @@ public final class TermuxService extends Service implements SessionChangedCallba
return mTerminalSessions;
}
TerminalSession createTermSession(String executablePath, String[] arguments, String cwd, boolean failSafe) {
public TerminalSession createTermSession(String executablePath, String[] arguments, String cwd, boolean failSafe) {
TermuxConstants.TERMUX_HOME_DIR.mkdirs();
if (cwd == null || cwd.isEmpty()) cwd = TermuxConstants.TERMUX_HOME_DIR_PATH;
@@ -302,7 +313,7 @@ public final class TermuxService extends Service implements SessionChangedCallba
args[0] = processName;
if (processArgs.length > 1) System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1);
TerminalSession session = new TerminalSession(executablePath, cwd, args, env, this);
TerminalSession session = new TerminalSession(executablePath, cwd, args, env, getTermuxSessionClient());
mTerminalSessions.add(session);
updateNotification();
@@ -327,35 +338,48 @@ public final class TermuxService extends Service implements SessionChangedCallba
return indexOfRemoved;
}
@Override
public void onTitleChanged(TerminalSession changedSession) {
if (mSessionChangeCallback != null) mSessionChangeCallback.onTitleChanged(changedSession);
/** If {@link TermuxActivity} has not bound to the {@link TermuxService} yet or is destroyed, then
* interface functions requiring the activity should not be available to the terminal sessions,
* so we just return the {@link #mTermuxSessionClientBase}. Once {@link TermuxActivity} bind
* callback is received, it should call {@link #setTermuxSessionClient} to set the
* {@link TermuxService#mTermuxSessionClient} so that further terminal sessions are directly
* passed the {@link TermuxSessionClient} object which fully implements the
* {@link TerminalSessionClient} interface.
*
* @return Returns the {@link TermuxSessionClient} if {@link TermuxActivity} has bound with
* {@link TermuxService}, otherwise {@link TermuxSessionClientBase}.
*/
public TermuxSessionClientBase getTermuxSessionClient() {
if (mTermuxSessionClient != null)
return mTermuxSessionClient;
else
return mTermuxSessionClientBase;
}
@Override
public void onSessionFinished(final TerminalSession finishedSession) {
if (mSessionChangeCallback != null)
mSessionChangeCallback.onSessionFinished(finishedSession);
/** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the
* {@link TermuxService#mTermuxSessionClient} variable and update the {@link TerminalSession}
* and {@link TerminalEmulator} clients in case they were passed {@link TermuxSessionClientBase}
* earlier.
*
* @param termuxSessionClient The {@link TermuxSessionClient} object that fully
* implements the {@link TerminalSessionClient} interface.
*/
public void setTermuxSessionClient(TermuxSessionClient termuxSessionClient) {
mTermuxSessionClient = termuxSessionClient;
for (int i = 0; i < mTerminalSessions.size(); i++)
mTerminalSessions.get(i).updateTerminalSessionClient(mTermuxSessionClient);
}
@Override
public void onTextChanged(TerminalSession changedSession) {
if (mSessionChangeCallback != null) mSessionChangeCallback.onTextChanged(changedSession);
}
/** This should be called when {@link TermuxActivity} has been destroyed so that the
* {@link TermuxService} and {@link TerminalSession} and {@link TerminalEmulator} clients do not
* hold an activity references.
*/
public void unsetTermuxSessionClient() {
mTermuxSessionClient = null;
@Override
public void onClipboardText(TerminalSession session, String text) {
if (mSessionChangeCallback != null) mSessionChangeCallback.onClipboardText(session, text);
}
@Override
public void onBell(TerminalSession session) {
if (mSessionChangeCallback != null) mSessionChangeCallback.onBell(session);
}
@Override
public void onColorsChanged(TerminalSession session) {
if (mSessionChangeCallback != null) mSessionChangeCallback.onColorsChanged(session);
for (int i = 0; i < mTerminalSessions.size(); i++)
mTerminalSessions.get(i).updateTerminalSessionClient(mTermuxSessionClientBase);
}
public void onBackgroundJobExited(final BackgroundJob task) {

View File

@@ -26,9 +26,9 @@ import com.termux.app.TermuxConstants;
*/
public final class TermuxPreferenceConstants {
/** Defines the key for whether to show extra keys in termux terminal view */
public static final String KEY_SHOW_EXTRA_KEYS = "show_extra_keys";
public static final boolean DEFAULT_VALUE_SHOW_EXTRA_KEYS = true;
/** Defines the key for whether to show terminal toolbar containing extra keys and text input field */
public static final String KEY_SHOW_TERMINAL_TOOLBAR = "show_extra_keys";
public static final boolean DEFAULT_VALUE_SHOW_TERMINAL_TOOLBAR = true;

View File

@@ -33,17 +33,17 @@ public class TermuxSharedPreferences {
public boolean getShowExtraKeys() {
return mSharedPreferences.getBoolean(TermuxPreferenceConstants.KEY_SHOW_EXTRA_KEYS, TermuxPreferenceConstants.DEFAULT_VALUE_SHOW_EXTRA_KEYS);
public boolean getShowTerminalToolbar() {
return mSharedPreferences.getBoolean(TermuxPreferenceConstants.KEY_SHOW_TERMINAL_TOOLBAR, TermuxPreferenceConstants.DEFAULT_VALUE_SHOW_TERMINAL_TOOLBAR);
}
public void setShowExtraKeys(boolean value) {
mSharedPreferences.edit().putBoolean(TermuxPreferenceConstants.KEY_SHOW_EXTRA_KEYS, value).apply();
public void setShowTerminalToolbar(boolean value) {
mSharedPreferences.edit().putBoolean(TermuxPreferenceConstants.KEY_SHOW_TERMINAL_TOOLBAR, value).apply();
}
public boolean toggleShowExtraKeys() {
boolean currentValue = getShowExtraKeys();
setShowExtraKeys(!currentValue);
public boolean toogleShowTerminalToolbar() {
boolean currentValue = getShowTerminalToolbar();
setShowTerminalToolbar(!currentValue);
return !currentValue;
}
@@ -108,13 +108,6 @@ public class TermuxSharedPreferences {
setFontSize(Integer.toString(fontSize));
}
/**
* If value is not in the range [min, max], set it to either min or max.
*/
static int clamp(int value, int min, int max) {
return Math.min(Math.max(value, min), max);
}
public String getCurrentSession() {
@@ -152,4 +145,13 @@ public class TermuxSharedPreferences {
mSharedPreferences.edit().putBoolean(TermuxPreferenceConstants.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, value).apply();
}
/**
* If value is not in the range [min, max], set it to either min or max.
*/
static int clamp(int value, int min, int max) {
return Math.min(Math.max(value, min), max);
}
}

View File

@@ -0,0 +1,315 @@
package com.termux.app.terminal;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.Typeface;
import android.media.AudioAttributes;
import android.media.SoundPool;
import android.text.TextUtils;
import android.widget.ListView;
import com.termux.R;
import com.termux.app.utils.DialogUtils;
import com.termux.app.TermuxActivity;
import com.termux.app.TermuxConstants;
import com.termux.app.TermuxService;
import com.termux.app.settings.properties.TermuxPropertyConstants;
import com.termux.app.terminal.io.BellHandler;
import com.termux.app.utils.Logger;
import com.termux.terminal.TerminalColors;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TextStyle;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.List;
import java.util.Properties;
public class TermuxSessionClient extends TermuxSessionClientBase {
final TermuxActivity mActivity;
private static final int MAX_SESSIONS = 8;
final SoundPool mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes(
new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build();
int mBellSoundId;
private static final String LOG_TAG = "TermuxSessionClient";
public TermuxSessionClient(TermuxActivity activity) {
this.mActivity = activity;
mBellSoundId = mBellSoundPool.load(activity, R.raw.bell, 1);
}
@Override
public void onTextChanged(TerminalSession changedSession) {
if (!mActivity.isVisible()) return;
if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated();
}
@Override
public void onTitleChanged(TerminalSession updatedSession) {
if (!mActivity.isVisible()) return;
if (updatedSession != mActivity.getCurrentSession()) {
// Only show toast for other sessions than the current one, since the user
// probably consciously caused the title change to change in the current session
// and don't want an annoying toast for that.
mActivity.showToast(toToastTitle(updatedSession), true);
}
mActivity.terminalSessionListNotifyUpdated();
}
@Override
public void onSessionFinished(final TerminalSession finishedSession) {
if (mActivity.getTermuxService().wantsToStop()) {
// The service wants to stop as soon as possible.
mActivity.finishActivityIfNotFinishing();
return;
}
if (mActivity.isVisible() && finishedSession != mActivity.getCurrentSession()) {
// Show toast for non-current sessions that exit.
int indexOfSession = mActivity.getTermuxService().getSessions().indexOf(finishedSession);
// Verify that session was not removed before we got told about it finishing:
if (indexOfSession >= 0)
mActivity.showToast(toToastTitle(finishedSession) + " - exited", true);
}
if (mActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
// On Android TV devices we need to use older behaviour because we may
// not be able to have multiple launcher icons.
if (mActivity.getTermuxService().getSessions().size() > 1) {
removeFinishedSession(finishedSession);
}
} else {
// Once we have a separate launcher icon for the failsafe session, it
// should be safe to auto-close session on exit code '0' or '130'.
if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130) {
removeFinishedSession(finishedSession);
}
}
mActivity.terminalSessionListNotifyUpdated();
}
@Override
public void onClipboardText(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)));
}
@Override
public void onBell(TerminalSession session) {
if (!mActivity.isVisible()) return;
switch (mActivity.getProperties().getBellBehaviour()) {
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_VIBRATE:
BellHandler.getInstance(mActivity).doBell();
break;
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_BEEP:
mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f);
break;
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_IGNORE:
// Ignore the bell character.
break;
}
}
@Override
public void onColorsChanged(TerminalSession changedSession) {
if (mActivity.getCurrentSession() == changedSession)
updateBackgroundColor();
}
/** Try switching to session and note about it, but do nothing if already displaying the session. */
public void setCurrentSession(TerminalSession session) {
if (mActivity.getTerminalView().attachSession(session)) {
noteSessionInfo();
updateBackgroundColor();
}
}
void noteSessionInfo() {
if (!mActivity.isVisible()) return;
TerminalSession session = mActivity.getCurrentSession();
final int indexOfSession = mActivity.getTermuxService().getSessions().indexOf(session);
mActivity.showToast(toToastTitle(session), false);
mActivity.terminalSessionListNotifyUpdated();
final ListView lv = mActivity.findViewById(R.id.terminal_sessions_list);
lv.setItemChecked(indexOfSession, true);
lv.smoothScrollToPosition(indexOfSession);
}
public void switchToSession(boolean forward) {
TermuxService service = mActivity.getTermuxService();
TerminalSession currentSession = mActivity.getCurrentSession();
int index = service.getSessions().indexOf(currentSession);
if (forward) {
if (++index >= service.getSessions().size()) index = 0;
} else {
if (--index < 0) index = service.getSessions().size() - 1;
}
setCurrentSession(service.getSessions().get(index));
}
@SuppressLint("InflateParams")
public void renameSession(final TerminalSession sessionToRename) {
if (sessionToRename == null) return;
DialogUtils.textInput(mActivity, R.string.session_rename_title, sessionToRename.mSessionName, R.string.session_rename_positive_button, text -> {
sessionToRename.mSessionName = text;
mActivity.terminalSessionListNotifyUpdated();
}, -1, null, -1, null, null);
}
public void addNewSession(boolean failSafe, String sessionName) {
if (mActivity.getTermuxService().getSessions().size() >= MAX_SESSIONS) {
new AlertDialog.Builder(mActivity).setTitle(R.string.max_terminals_reached_title).setMessage(R.string.max_terminals_reached_message)
.setPositiveButton(android.R.string.ok, null).show();
} else {
TerminalSession currentSession = mActivity.getCurrentSession();
String workingDirectory;
if (currentSession == null) {
workingDirectory = mActivity.getProperties().getDefaultWorkingDirectory();
} else {
workingDirectory = currentSession.getCwd();
}
TerminalSession newSession = mActivity.getTermuxService().createTermSession(null, null, workingDirectory, failSafe);
if (sessionName != null) {
newSession.mSessionName = sessionName;
}
setCurrentSession(newSession);
mActivity.getDrawer().closeDrawers();
}
}
public void setCurrentStoredSession() {
TerminalSession currentSession = mActivity.getCurrentSession();
if (currentSession != null)
mActivity.getPreferences().setCurrentSession(currentSession.mHandle);
else
mActivity.getPreferences().setCurrentSession(null);
}
/** The current session as stored or the last one if that does not exist. */
public TerminalSession getCurrentStoredSessionOrLast() {
TerminalSession stored = getCurrentStoredSession(mActivity);
if (stored != null) {
// If a stored session is in the list of currently running sessions, then return it
return stored;
} else {
// Else return the last session currently running
List<TerminalSession> sessions = mActivity.getTermuxService().getSessions();
return sessions.isEmpty() ? null : sessions.get(sessions.size() - 1);
}
}
private TerminalSession getCurrentStoredSession(TermuxActivity context) {
String sessionHandle = mActivity.getPreferences().getCurrentSession();
// If no session is stored in shared preferences
if(sessionHandle == null)
return null;
// Check if the session handle found matches one of the currently running sessions
List<TerminalSession> sessions = context.getTermuxService().getSessions();
for (int i = 0, len = sessions.size(); i < len; i++) {
TerminalSession session = sessions.get(i);
if (session.mHandle.equals(sessionHandle))
return session;
}
return null;
}
public void removeFinishedSession(TerminalSession finishedSession) {
// Return pressed with finished session - remove it.
TermuxService service = mActivity.getTermuxService();
int index = service.removeTermSession(finishedSession);
mActivity.terminalSessionListNotifyUpdated();
if (mActivity.getTermuxService().getSessions().isEmpty()) {
// There are no sessions to show, so finish the activity.
mActivity.finishActivityIfNotFinishing();
} else {
if (index >= service.getSessions().size()) {
index = service.getSessions().size() - 1;
}
setCurrentSession(service.getSessions().get(index));
}
}
String toToastTitle(TerminalSession session) {
final int indexOfSession = mActivity.getTermuxService().getSessions().indexOf(session);
StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]");
if (!TextUtils.isEmpty(session.mSessionName)) {
toastTitle.append(" ").append(session.mSessionName);
}
String title = session.getTitle();
if (!TextUtils.isEmpty(title)) {
// Space to "[${NR}] or newline after session name:
toastTitle.append(session.mSessionName == null ? " " : "\n");
toastTitle.append(title);
}
return toastTitle.toString();
}
public void checkForFontAndColors() {
try {
File colorsFile = TermuxConstants.TERMUX_COLOR_PROPERTIES_FILE;
File fontFile = TermuxConstants.TERMUX_FONT_FILE;
final Properties props = new Properties();
if (colorsFile.isFile()) {
try (InputStream in = new FileInputStream(colorsFile)) {
props.load(in);
}
}
TerminalColors.COLOR_SCHEME.updateWith(props);
TerminalSession session = mActivity.getCurrentSession();
if (session != null && session.getEmulator() != null) {
session.getEmulator().mColors.reset();
}
updateBackgroundColor();
final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE;
mActivity.getTerminalView().setTypeface(newTypeface);
} catch (Exception e) {
Logger.logStackTraceWithMessage(LOG_TAG, "Error in checkForFontAndColors()", e);
}
}
public void updateBackgroundColor() {
TerminalSession session = mActivity.getCurrentSession();
if (session != null && session.getEmulator() != null) {
mActivity.getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]);
}
}
}

View File

@@ -0,0 +1,71 @@
package com.termux.app.terminal;
import com.termux.app.utils.Logger;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
public class TermuxSessionClientBase implements TerminalSessionClient {
public TermuxSessionClientBase() {
}
@Override
public void onTextChanged(TerminalSession changedSession) {
}
@Override
public void onTitleChanged(TerminalSession updatedSession) {
}
@Override
public void onSessionFinished(final TerminalSession finishedSession) {
}
@Override
public void onClipboardText(TerminalSession session, String text) {
}
@Override
public void onBell(TerminalSession session) {
}
@Override
public void onColorsChanged(TerminalSession changedSession) {
}
@Override
public void logError(String tag, String message) {
Logger.logError(tag, message);
}
@Override
public void logWarn(String tag, String message) {
Logger.logWarn(tag, message);
}
@Override
public void logInfo(String tag, String message) {
Logger.logInfo(tag, message);
}
@Override
public void logDebug(String tag, String message) {
Logger.logDebug(tag, message);
}
@Override
public void logVerbose(String tag, String message) {
Logger.logVerbose(tag, message);
}
@Override
public void logStackTraceWithMessage(String tag, String message, Exception e) {
Logger.logStackTraceWithMessage(tag, message, e);
}
@Override
public void logStackTrace(String tag, Exception e) {
Logger.logStackTrace(tag, e);
}
}

View File

@@ -0,0 +1,107 @@
package com.termux.app.terminal;
import android.annotation.SuppressLint;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.terminal.TerminalSession;
import java.util.List;
public class TermuxSessionsListViewController extends ArrayAdapter<TerminalSession> implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener {
final TermuxActivity mActivity;
final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD);
final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC);
public TermuxSessionsListViewController(TermuxActivity activity, List<TerminalSession> sessionList) {
super(activity.getApplicationContext(), R.layout.terminal_sessions_list_item, sessionList);
this.mActivity = activity;
}
@SuppressLint("SetTextI18n")
@NonNull
@Override
public View getView(int position, View convertView, @NonNull ViewGroup parent) {
View sessionRowView = convertView;
if (sessionRowView == null) {
LayoutInflater inflater = mActivity.getLayoutInflater();
sessionRowView = inflater.inflate(R.layout.terminal_sessions_list_item, parent, false);
}
TextView sessionTitleView = sessionRowView.findViewById(R.id.session_title);
TerminalSession sessionAtRow = getItem(position);
if (sessionAtRow == null) {
sessionTitleView.setText("null session");
return sessionRowView;
}
boolean isUsingBlackUI = mActivity.getProperties().isUsingBlackUI();
if (isUsingBlackUI) {
sessionTitleView.setBackground(
ContextCompat.getDrawable(mActivity, R.drawable.selected_session_background_black)
);
}
String name = sessionAtRow.mSessionName;
String sessionTitle = sessionAtRow.getTitle();
String numberPart = "[" + (position + 1) + "] ";
String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name);
String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle));
String fullSessionTitle = numberPart + sessionNamePart + sessionTitlePart;
SpannableString fullSessionTitleStyled = new SpannableString(fullSessionTitle);
fullSessionTitleStyled.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
fullSessionTitleStyled.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), fullSessionTitle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
sessionTitleView.setText(fullSessionTitleStyled);
boolean sessionRunning = sessionAtRow.isRunning();
if (sessionRunning) {
sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
} else {
sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
}
int defaultColor = isUsingBlackUI ? Color.WHITE : Color.BLACK;
int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED;
sessionTitleView.setTextColor(color);
return sessionRowView;
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
TerminalSession clickedSession = getItem(position);
mActivity.getTermuxSessionClient().setCurrentSession(clickedSession);
mActivity.getDrawer().closeDrawers();
}
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
final TerminalSession selectedSession = getItem(position);
mActivity.getTermuxSessionClient().renameSession(selectedSession);
return true;
}
}

View File

@@ -1,45 +1,62 @@
package com.termux.app.terminal;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
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.text.TextUtils;
import android.view.Gravity;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.inputmethod.InputMethodManager;
import android.widget.ListView;
import android.widget.Toast;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.app.TermuxService;
import com.termux.app.terminal.extrakeys.ExtraKeysView;
import com.termux.app.terminal.io.KeyboardShortcut;
import com.termux.app.terminal.io.extrakeys.ExtraKeysView;
import com.termux.app.settings.properties.TermuxPropertyConstants;
import com.termux.app.utils.TextDataUtils;
import com.termux.app.utils.Logger;
import com.termux.terminal.KeyHandler;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalSession;
import com.termux.view.TerminalViewClient;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import androidx.drawerlayout.widget.DrawerLayout;
public final class TermuxViewClient implements TerminalViewClient {
public class TermuxViewClient implements TerminalViewClient {
final TermuxActivity mActivity;
final TermuxSessionClient mTermuxSessionClient;
/** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */
boolean mVirtualControlKeyDown, mVirtualFnKeyDown;
public TermuxViewClient(TermuxActivity activity) {
public TermuxViewClient(TermuxActivity activity, TermuxSessionClient termuxSessionClient) {
this.mActivity = activity;
this.mTermuxSessionClient = termuxSessionClient;
}
@Override
public float onScale(float scale) {
if (scale < 0.9f || scale > 1.1f) {
boolean increase = scale > 1.f;
mActivity.changeFontSize(increase);
changeFontSize(increase);
return 1.0f;
}
return scale;
@@ -84,16 +101,16 @@ public final class TermuxViewClient implements TerminalViewClient {
if (handleVirtualKeys(keyCode, e, true)) return true;
if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) {
mActivity.removeFinishedSession(currentSession);
mTermuxSessionClient.removeFinishedSession(currentSession);
return true;
} else if (e.isCtrlPressed() && e.isAltPressed()) {
// Get the unmodified code point:
int unicodeChar = e.getUnicodeChar(0);
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) {
mActivity.switchToSession(true);
mTermuxSessionClient.switchToSession(true);
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) {
mActivity.switchToSession(false);
mTermuxSessionClient.switchToSession(false);
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
mActivity.getDrawer().openDrawer(Gravity.LEFT);
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
@@ -104,24 +121,24 @@ public final class TermuxViewClient implements TerminalViewClient {
} else if (unicodeChar == 'm'/* menu */) {
mActivity.getTerminalView().showContextMenu();
} else if (unicodeChar == 'r'/* rename */) {
mActivity.renameSession(currentSession);
mTermuxSessionClient.renameSession(currentSession);
} else if (unicodeChar == 'c'/* create */) {
mActivity.addNewSession(false, null);
mTermuxSessionClient.addNewSession(false, null);
} else if (unicodeChar == 'u' /* urls */) {
mActivity.showUrlSelection();
showUrlSelection();
} else if (unicodeChar == 'v') {
mActivity.doPaste();
doPaste();
} else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') {
// We also check for the shifted char here since shift may be required to produce '+',
// see https://github.com/termux/termux-api/issues/2
mActivity.changeFontSize(true);
changeFontSize(true);
} else if (unicodeChar == '-') {
mActivity.changeFontSize(false);
changeFontSize(false);
} else if (unicodeChar >= '1' && unicodeChar <= '9') {
int num = unicodeChar - '1';
TermuxService service = mActivity.getTermService();
TermuxService service = mActivity.getTermuxService();
if (service.getSessions().size() > num)
mActivity.switchToSession(service.getSessions().get(num));
mTermuxSessionClient.setCurrentSession(service.getSessions().get(num));
}
return true;
}
@@ -262,7 +279,7 @@ public final class TermuxViewClient implements TerminalViewClient {
// Writing mode:
case 'q':
case 'k':
mActivity.toggleShowExtraKeys();
mActivity.toggleTerminalToolbar();
mVirtualFnKeyDown=false; // force disable fn key down to restore keyboard input into terminal view, fixes termux/termux-app#1420
break;
}
@@ -276,7 +293,7 @@ public final class TermuxViewClient implements TerminalViewClient {
return true;
} else if (ctrlDown) {
if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) {
mActivity.removeFinishedSession(session);
mTermuxSessionClient.removeFinishedSession(session);
return true;
}
@@ -288,16 +305,16 @@ public final class TermuxViewClient implements TerminalViewClient {
if (codePointLowerCase == shortcut.codePoint) {
switch (shortcut.shortcutAction) {
case TermuxPropertyConstants.ACTION_SHORTCUT_CREATE_SESSION:
mActivity.addNewSession(false, null);
mTermuxSessionClient.addNewSession(false, null);
return true;
case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION:
mActivity.switchToSession(true);
mTermuxSessionClient.switchToSession(true);
return true;
case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION:
mActivity.switchToSession(false);
mTermuxSessionClient.switchToSession(false);
return true;
case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION:
mActivity.renameSession(mActivity.getCurrentTermSession());
mTermuxSessionClient.renameSession(mActivity.getCurrentSession());
return true;
}
}
@@ -310,6 +327,90 @@ public final class TermuxViewClient implements TerminalViewClient {
public void changeFontSize(boolean increase) {
mActivity.getPreferences().changeFontSize(mActivity, increase);
mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize());
}
public void shareSessionTranscript() {
TerminalSession session = mActivity.getCurrentSession();
if (session == null) return;
String transcriptText = session.getEmulator().getScreen().getTranscriptTextWithoutJoinedLines().trim();
try {
// See https://github.com/termux/termux-app/issues/1166.
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
transcriptText = TextDataUtils.getTruncatedCommandOutput(transcriptText, 100_000);
intent.putExtra(Intent.EXTRA_TEXT, transcriptText);
intent.putExtra(Intent.EXTRA_SUBJECT, mActivity.getString(R.string.share_transcript_title));
mActivity.startActivity(Intent.createChooser(intent, mActivity.getString(R.string.share_transcript_chooser_title)));
} catch (Exception e) {
Logger.logStackTraceWithMessage("Failed to get share session transcript of length " + transcriptText.length(), e);
}
}
public void showUrlSelection() {
TerminalSession session = mActivity.getCurrentSession();
if (session == null) return;
String text = session.getEmulator().getScreen().getTranscriptTextWithFullLinesJoined();
LinkedHashSet<CharSequence> urlSet = TextDataUtils.extractUrls(text);
if (urlSet.isEmpty()) {
new AlertDialog.Builder(mActivity).setMessage(R.string.select_url_no_found).show();
return;
}
final CharSequence[] urls = urlSet.toArray(new CharSequence[0]);
Collections.reverse(Arrays.asList(urls)); // Latest first.
// Click to copy url to clipboard:
final AlertDialog dialog = new AlertDialog.Builder(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.select_url_copied_to_clipboard, Toast.LENGTH_LONG).show();
}).setTitle(R.string.select_url_dialog_title).create();
// Long press to open URL:
dialog.setOnShowListener(di -> {
ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it
lv.setOnItemLongClickListener((parent, view, position, id) -> {
dialog.dismiss();
String url = (String) urls[position];
Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
try {
mActivity.startActivity(i, null);
} catch (ActivityNotFoundException e) {
// If no applications match, Android displays a system message.
mActivity.startActivity(Intent.createChooser(i, null));
}
return true;
});
});
dialog.show();
}
public void doPaste() {
TerminalSession session = mActivity.getCurrentSession();
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());
}
@Override
public void logError(String tag, String message) {
Logger.logError(tag, message);

View File

@@ -0,0 +1,114 @@
package com.termux.app.terminal.io;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.app.terminal.io.extrakeys.ExtraKeysView;
import com.termux.terminal.TerminalSession;
public class TerminalToolbarViewPager {
public static class PageAdapter extends PagerAdapter {
final TermuxActivity mActivity;
String mSavedTextInput;
public PageAdapter(TermuxActivity activity, String savedTextInput) {
this.mActivity = activity;
this.mSavedTextInput = savedTextInput;
}
@Override
public int getCount() {
return 2;
}
@Override
public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
return view == object;
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup collection, int position) {
LayoutInflater inflater = LayoutInflater.from(mActivity);
View layout;
if (position == 0) {
layout = inflater.inflate(R.layout.terminal_toolbar_extra_keys_view, collection, false);
ExtraKeysView extraKeysView = (ExtraKeysView) layout;
mActivity.setExtraKeysView(extraKeysView);
extraKeysView.reload(mActivity.getProperties().getExtraKeysInfo());
// apply extra keys fix if enabled in prefs
if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) {
FullScreenWorkAround.apply(mActivity);
}
} else {
layout = inflater.inflate(R.layout.terminal_toolbar_text_input_view, collection, false);
final EditText editText = layout.findViewById(R.id.terminal_toolbar_text_input);
if(mSavedTextInput != null) {
editText.setText(mSavedTextInput);
mSavedTextInput = null;
}
editText.setOnEditorActionListener((v, actionId, event) -> {
TerminalSession session = mActivity.getCurrentSession();
if (session != null) {
if (session.isRunning()) {
String textToSend = editText.getText().toString();
if (textToSend.length() == 0) textToSend = "\r";
session.write(textToSend);
} else {
mActivity.getTermuxSessionClient().removeFinishedSession(session);
}
editText.setText("");
}
return true;
});
}
collection.addView(layout);
return layout;
}
@Override
public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) {
collection.removeView((View) view);
}
}
public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
final TermuxActivity mActivity;
final ViewPager mTerminalToolbarViewPager;
public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) {
this.mActivity = activity;
this.mTerminalToolbarViewPager = viewPager;
}
@Override
public void onPageSelected(int position) {
if (position == 0) {
mActivity.getTerminalView().requestFocus();
} else {
final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input);
if (editText != null) editText.requestFocus();
}
}
}
}

View File

@@ -1,9 +1,14 @@
package com.termux.app.utils;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ResolveInfo;
import com.termux.app.TermuxConstants;
import java.util.List;
public class TermuxUtils {
public static Context getTermuxPackageContext(Context context) {
@@ -15,4 +20,26 @@ public class TermuxUtils {
return context;
}
}
/**
* Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux
* app has been opened.
*
* @param context The Context to send the broadcast.
*/
public static void sendTermuxOpenedBroadcast(Context context) {
Intent broadcast = new Intent(TermuxConstants.BROADCAST_TERMUX_OPENED);
List<ResolveInfo> matches = context.getPackageManager().queryBroadcastReceivers(broadcast, 0);
// send broadcast to registered Termux receivers
// this technique is needed to work around broadcast changes that Oreo introduced
for (ResolveInfo info : matches) {
Intent explicitBroadcast = new Intent(broadcast);
ComponentName cname = new ComponentName(info.activityInfo.applicationInfo.packageName,
info.activityInfo.name);
explicitBroadcast.setComponent(cname);
context.sendBroadcast(explicitBroadcast);
}
}
}

View File

@@ -0,0 +1,111 @@
package com.termux.app.utils;
import java.util.LinkedHashSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class TextDataUtils {
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:media2/media2-session/src/main/java/androidx/media2/session/MediaUtils.java
public static final int TRANSACTION_SIZE_LIMIT_IN_BYTES = 256 * 1024; // 256KB
public static String getTruncatedCommandOutput(String text, int maxLength) {
if (text.length() > maxLength) {
int cutOffIndex = text.length() - maxLength;
int nextNewlineIndex = text.indexOf('\n', cutOffIndex);
if (nextNewlineIndex != -1 && nextNewlineIndex != text.length() - 1) {
cutOffIndex = nextNewlineIndex + 1;
}
text = text.substring(cutOffIndex).trim();
}
return text;
}
public static LinkedHashSet<CharSequence> extractUrls(String text) {
StringBuilder regex_sb = new StringBuilder();
regex_sb.append("("); // Begin first matching group.
regex_sb.append("(?:"); // Begin scheme group.
regex_sb.append("dav|"); // The DAV proto.
regex_sb.append("dict|"); // The DICT proto.
regex_sb.append("dns|"); // The DNS proto.
regex_sb.append("file|"); // File path.
regex_sb.append("finger|"); // The Finger proto.
regex_sb.append("ftp(?:s?)|"); // The FTP proto.
regex_sb.append("git|"); // The Git proto.
regex_sb.append("gopher|"); // The Gopher proto.
regex_sb.append("http(?:s?)|"); // The HTTP proto.
regex_sb.append("imap(?:s?)|"); // The IMAP proto.
regex_sb.append("irc(?:[6s]?)|"); // The IRC proto.
regex_sb.append("ip[fn]s|"); // The IPFS proto.
regex_sb.append("ldap(?:s?)|"); // The LDAP proto.
regex_sb.append("pop3(?:s?)|"); // The POP3 proto.
regex_sb.append("redis(?:s?)|"); // The Redis proto.
regex_sb.append("rsync|"); // The Rsync proto.
regex_sb.append("rtsp(?:[su]?)|"); // The RTSP proto.
regex_sb.append("sftp|"); // The SFTP proto.
regex_sb.append("smb(?:s?)|"); // The SAMBA proto.
regex_sb.append("smtp(?:s?)|"); // The SMTP proto.
regex_sb.append("svn(?:(?:\\+ssh)?)|"); // The Subversion proto.
regex_sb.append("tcp|"); // The TCP proto.
regex_sb.append("telnet|"); // The Telnet proto.
regex_sb.append("tftp|"); // The TFTP proto.
regex_sb.append("udp|"); // The UDP proto.
regex_sb.append("vnc|"); // The VNC proto.
regex_sb.append("ws(?:s?)"); // The Websocket proto.
regex_sb.append(")://"); // End scheme group.
regex_sb.append(")"); // End first matching group.
// Begin second matching group.
regex_sb.append("(");
// User name and/or password in format 'user:pass@'.
regex_sb.append("(?:\\S+(?::\\S*)?@)?");
// Begin host group.
regex_sb.append("(?:");
// IP address (from http://www.regular-expressions.info/examples.html).
regex_sb.append("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|");
// Host name or domain.
regex_sb.append("(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))?|");
// Just path. Used in case of 'file://' scheme.
regex_sb.append("/(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)");
// End host group.
regex_sb.append(")");
// Port number.
regex_sb.append("(?::\\d{1,5})?");
// Resource path with optional query string.
regex_sb.append("(?:/[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?");
// Fragment.
regex_sb.append("(?:#[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?");
// End second matching group.
regex_sb.append(")");
final Pattern urlPattern = Pattern.compile(
regex_sb.toString(),
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
LinkedHashSet<CharSequence> urlSet = new LinkedHashSet<>();
Matcher matcher = urlPattern.matcher(text);
while (matcher.find()) {
int matchStart = matcher.start(1);
int matchEnd = matcher.end();
String url = text.substring(matchStart, matchEnd);
urlSet.add(url);
}
return urlSet;
}
}

View File

@@ -0,0 +1,9 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/session_title"
android:layout_width="fill_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:background="@drawable/selected_session_background"
android:ellipsize="marquee"
android:gravity="start|center_vertical"
android:padding="6dip"
android:textSize="14sp" />

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<com.termux.app.terminal.io.extrakeys.ExtraKeysView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/terminal_toolbar_extra_keys"
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:orientation="horizontal" />

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<EditText xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/terminal_toolbar_text_input"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionSend|flagNoFullscreen"
android:maxLines="1"
android:inputType="text"
android:importantForAutofill="no"
android:textColor="@android:color/white"
android:textColorHighlight="@android:color/darker_gray"
android:paddingTop="0dp"
android:textCursorDrawable="@null"
android:paddingBottom="0dp"
tools:ignore="LabelFor" />

View File

@@ -0,0 +1,80 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="true">
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_alignParentTop="true"
android:layout_above="@+id/terminal_toolbar_view_pager"
android:layout_height="match_parent">
<com.termux.view.TerminalView
android:id="@+id/terminal_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginRight="3dp"
android:layout_marginLeft="3dp"
android:focusableInTouchMode="true"
android:scrollbarThumbVertical="@drawable/terminal_scroll_shape"
android:scrollbars="vertical"
android:importantForAutofill="no"
android:autofillHints="password" />
<LinearLayout
android:id="@+id/left_drawer"
android:layout_width="240dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="@android:color/white"
android:choiceMode="singleChoice"
android:divider="@android:color/transparent"
android:dividerHeight="0dp"
android:descendantFocusability="blocksDescendants"
android:orientation="vertical">
<ListView
android:id="@+id/terminal_sessions_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="top"
android:layout_weight="1"
android:choiceMode="singleChoice"
android:longClickable="true" />
<LinearLayout
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/toggle_keyboard_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/toggle_soft_keyboard" />
<Button
android:id="@+id/new_session_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/new_session" />
</LinearLayout>
</LinearLayout>
</androidx.drawerlayout.widget.DrawerLayout>
<androidx.viewpager.widget.ViewPager
android:id="@+id/terminal_toolbar_view_pager"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="37.5dp"
android:background="@android:drawable/screen_background_dark_transparent"
android:layout_alignParentBottom="true" />
</RelativeLayout>

View File

@@ -1,5 +1,7 @@
package com.termux.app;
import com.termux.app.utils.TextDataUtils;
import org.junit.Assert;
import org.junit.Test;
@@ -11,7 +13,7 @@ public class TermuxActivityTest {
private void assertUrlsAre(String text, String... urls) {
LinkedHashSet<String> expected = new LinkedHashSet<>();
Collections.addAll(expected, urls);
Assert.assertEquals(expected, TermuxActivity.extractUrls(text));
Assert.assertEquals(expected, TextDataUtils.extractUrls(text));
}
@Test

View File

@@ -1,7 +1,6 @@
package com.termux.terminal;
import android.util.Base64;
import android.util.Log;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -153,6 +152,8 @@ public final class TerminalEmulator {
/** The terminal session this emulator is bound to. */
private final TerminalOutput mSession;
TerminalSessionClient mClient;
/** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */
private int mArgIndex;
/** Holds the arguments of the current escape sequence. */
@@ -227,6 +228,8 @@ public final class TerminalEmulator {
public final TerminalColors mColors = new TerminalColors();
private static final String LOG_TAG = "TerminalEmulator";
private boolean isDecsetInternalBitSet(int bit) {
return (mCurrentDecSetFlags & bit) != 0;
}
@@ -279,16 +282,21 @@ public final class TerminalEmulator {
}
}
public TerminalEmulator(TerminalOutput session, int columns, int rows, int transcriptRows) {
public TerminalEmulator(TerminalOutput session, int columns, int rows, int transcriptRows, TerminalSessionClient client) {
mSession = session;
mScreen = mMainBuffer = new TerminalBuffer(columns, transcriptRows, rows);
mAltBuffer = new TerminalBuffer(columns, rows, rows);
mClient = client;
mRows = rows;
mColumns = columns;
mTabStop = new boolean[mColumns];
reset();
}
public void updateTerminalSessionClient(TerminalSessionClient client) {
mClient = client;
}
public TerminalBuffer getScreen() {
return mScreen;
}
@@ -751,7 +759,7 @@ public final class TerminalEmulator {
if (internalBit != -1) {
value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset.
} else {
Log.e(EmulatorDebug.LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode);
mClient.logError(LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode);
value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset
}
}
@@ -888,7 +896,7 @@ public final class TerminalEmulator {
case "&8": // Undo key - ignore.
break;
default:
Log.w(EmulatorDebug.LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'");
mClient.logWarn(LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'");
}
// Respond with invalid request:
mSession.write("\033P0+r" + part + "\033\\");
@@ -900,12 +908,12 @@ public final class TerminalEmulator {
mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\");
}
} else {
Log.e(EmulatorDebug.LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part);
mClient.logError(LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part);
}
}
} else {
if (LOG_ESCAPE_SEQUENCES)
Log.e(EmulatorDebug.LOG_TAG, "Unrecognized device control string: " + dcs);
mClient.logError(LOG_TAG, "Unrecognized device control string: " + dcs);
}
finishSequence();
}
@@ -995,7 +1003,7 @@ public final class TerminalEmulator {
int externalBit = mArgs[i];
int internalBit = mapDecSetBitToInternalBit(externalBit);
if (internalBit == -1) {
Log.w(EmulatorDebug.LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit);
mClient.logWarn(LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit);
} else {
if (b == 's') {
mSavedDecSetFlags |= internalBit;
@@ -1182,7 +1190,7 @@ public final class TerminalEmulator {
// (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and
// some special control character cases, e.g., Control-Space to make a NUL.
// (2) enables this feature for keys including the exceptions listed.
Log.e(EmulatorDebug.LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1));
mClient.logError(LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1));
break;
default:
parseArg(b);
@@ -1729,7 +1737,7 @@ public final class TerminalEmulator {
int firstArg = mArgs[i + 1];
if (firstArg == 2) {
if (i + 4 > mArgIndex) {
Log.w(EmulatorDebug.LOG_TAG, "Too few CSI" + code + ";2 RGB arguments");
mClient.logWarn(LOG_TAG, "Too few CSI" + code + ";2 RGB arguments");
} else {
int red = mArgs[i + 2], green = mArgs[i + 3], blue = mArgs[i + 4];
if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) {
@@ -1754,7 +1762,7 @@ public final class TerminalEmulator {
mBackColor = color;
}
} else {
if (LOG_ESCAPE_SEQUENCES) Log.w(EmulatorDebug.LOG_TAG, "Invalid color index: " + color);
if (LOG_ESCAPE_SEQUENCES) mClient.logWarn(LOG_TAG, "Invalid color index: " + color);
}
} else {
finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg);
@@ -1771,7 +1779,7 @@ public final class TerminalEmulator {
mBackColor = code - 100 + 8;
} else {
if (LOG_ESCAPE_SEQUENCES)
Log.w(EmulatorDebug.LOG_TAG, String.format("SGR unknown code %d", code));
mClient.logWarn(LOG_TAG, String.format("SGR unknown code %d", code));
}
}
}
@@ -1905,7 +1913,7 @@ public final class TerminalEmulator {
String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8);
mSession.clipboardText(clipboardText);
} catch (Exception e) {
Log.e(EmulatorDebug.LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + "");
mClient.logError(LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + "");
}
break;
case 104:
@@ -2101,7 +2109,7 @@ public final class TerminalEmulator {
}
private void finishSequenceAndLogError(String error) {
if (LOG_ESCAPE_SEQUENCES) Log.w(EmulatorDebug.LOG_TAG, error);
if (LOG_ESCAPE_SEQUENCES) mClient.logWarn(LOG_TAG, error);
finishSequence();
}

View File

@@ -6,7 +6,6 @@ import android.os.Message;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.util.Log;
import java.io.File;
import java.io.FileDescriptor;
@@ -31,41 +30,6 @@ import java.util.UUID;
*/
public final class TerminalSession extends TerminalOutput {
/** Callback to be invoked when a {@link TerminalSession} changes. */
public interface SessionChangedCallback {
void onTextChanged(TerminalSession changedSession);
void onTitleChanged(TerminalSession changedSession);
void onSessionFinished(TerminalSession finishedSession);
void onClipboardText(TerminalSession session, String text);
void onBell(TerminalSession session);
void onColorsChanged(TerminalSession session);
}
private static FileDescriptor wrapFileDescriptor(int fileDescriptor) {
FileDescriptor result = new FileDescriptor();
try {
Field descriptorField;
try {
descriptorField = FileDescriptor.class.getDeclaredField("descriptor");
} catch (NoSuchFieldException e) {
// For desktop java:
descriptorField = FileDescriptor.class.getDeclaredField("fd");
}
descriptorField.setAccessible(true);
descriptorField.set(result, fileDescriptor);
} catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
Log.wtf(EmulatorDebug.LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e);
System.exit(1);
}
return result;
}
private static final int MSG_NEW_INPUT = 1;
private static final int MSG_PROCESS_EXITED = 4;
@@ -87,7 +51,7 @@ public final class TerminalSession extends TerminalOutput {
private final byte[] mUtf8InputBuffer = new byte[5];
/** Callback which gets notified when a session finishes or changes title. */
final SessionChangedCallback mChangeCallback;
TerminalSessionClient mClient;
/** The pid of the shell process. 0 if not started and -1 if finished running. */
int mShellPid;
@@ -104,52 +68,32 @@ public final class TerminalSession extends TerminalOutput {
/** Set by the application for user identification of session, not by terminal. */
public String mSessionName;
@SuppressLint("HandlerLeak")
final Handler mMainThreadHandler = new Handler() {
final byte[] mReceiveBuffer = new byte[4 * 1024];
@Override
public void handleMessage(Message msg) {
int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false);
if (bytesRead > 0) {
mEmulator.append(mReceiveBuffer, bytesRead);
notifyScreenUpdate();
}
if (msg.what == MSG_PROCESS_EXITED) {
int exitCode = (Integer) msg.obj;
cleanupResources(exitCode);
mChangeCallback.onSessionFinished(TerminalSession.this);
String exitDescription = "\r\n[Process completed";
if (exitCode > 0) {
// Non-zero process exit.
exitDescription += " (code " + exitCode + ")";
} else if (exitCode < 0) {
// Negated signal.
exitDescription += " (signal " + (-exitCode) + ")";
}
exitDescription += " - press Enter]";
byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8);
mEmulator.append(bytesToWrite, bytesToWrite.length);
notifyScreenUpdate();
}
}
};
final Handler mMainThreadHandler = new MainThreadHandler();
private final String mShellPath;
private final String mCwd;
private final String[] mArgs;
private final String[] mEnv;
public TerminalSession(String shellPath, String cwd, String[] args, String[] env, SessionChangedCallback changeCallback) {
mChangeCallback = changeCallback;
private static final String LOG_TAG = "TerminalSession";
public TerminalSession(String shellPath, String cwd, String[] args, String[] env, TerminalSessionClient client) {
this.mShellPath = shellPath;
this.mCwd = cwd;
this.mArgs = args;
this.mEnv = env;
this.mClient = client;
}
/**
* @param client The {@link TerminalSessionClient} interface implementation to allow
* for communication between {@link TerminalSession} and its client.
*/
public void updateTerminalSessionClient(TerminalSessionClient client) {
mClient = client;
if (mEmulator != null)
mEmulator.updateTerminalSessionClient(client);
}
/** Inform the attached pty of the new size and reflow or initialize the emulator. */
@@ -174,13 +118,13 @@ public final class TerminalSession extends TerminalOutput {
* @param rows The number of rows in the terminal window.
*/
public void initializeEmulator(int columns, int rows) {
mEmulator = new TerminalEmulator(this, columns, rows, /* transcript= */2000);
mEmulator = new TerminalEmulator(this, columns, rows, /* transcript= */2000, mClient);
int[] processId = new int[1];
mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns);
mShellPid = processId[0];
final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor);
final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient);
new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
@Override
@@ -246,23 +190,23 @@ public final class TerminalSession extends TerminalOutput {
} else if (codePoint <= /* 11 bits */0b11111111111) {
/* 110xxxxx leading byte with leading 5 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6));
/* 10xxxxxx continuation byte with following 6 bits */
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
} else if (codePoint <= /* 16 bits */0b1111111111111111) {
/* 1110xxxx leading byte with leading 4 bits */
/* 1110xxxx leading byte with leading 4 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12));
/* 10xxxxxx continuation byte with following 6 bits */
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
} else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */
/* 11110xxx leading byte with leading 3 bits */
/* 11110xxx leading byte with leading 3 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18));
/* 10xxxxxx continuation byte with following 6 bits */
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
}
write(mUtf8InputBuffer, 0, bufferPosition);
@@ -272,9 +216,9 @@ public final class TerminalSession extends TerminalOutput {
return mEmulator;
}
/** Notify the {@link #mChangeCallback} that the screen has changed. */
/** Notify the {@link #mClient} that the screen has changed. */
protected void notifyScreenUpdate() {
mChangeCallback.onTextChanged(this);
mClient.onTextChanged(this);
}
/** Reset state for terminal emulator state. */
@@ -289,7 +233,7 @@ public final class TerminalSession extends TerminalOutput {
try {
Os.kill(mShellPid, OsConstants.SIGKILL);
} catch (ErrnoException e) {
Log.w("termux", "Failed sending SIGKILL: " + e.getMessage());
mClient.logWarn(LOG_TAG, "Failed sending SIGKILL: " + e.getMessage());
}
}
}
@@ -309,7 +253,7 @@ public final class TerminalSession extends TerminalOutput {
@Override
public void titleChanged(String oldTitle, String newTitle) {
mChangeCallback.onTitleChanged(this);
mClient.onTitleChanged(this);
}
public synchronized boolean isRunning() {
@@ -323,17 +267,17 @@ public final class TerminalSession extends TerminalOutput {
@Override
public void clipboardText(String text) {
mChangeCallback.onClipboardText(this, text);
mClient.onClipboardText(this, text);
}
@Override
public void onBell() {
mChangeCallback.onBell(this);
mClient.onBell(this);
}
@Override
public void onColorsChanged() {
mChangeCallback.onColorsChanged(this);
mClient.onColorsChanged(this);
}
public int getPid() {
@@ -356,10 +300,64 @@ public final class TerminalSession extends TerminalOutput {
return outputPath;
}
} catch (IOException | SecurityException e) {
Log.e(EmulatorDebug.LOG_TAG, "Error getting current directory", e);
mClient.logStackTraceWithMessage(LOG_TAG, "Error getting current directory", e);
}
return null;
}
private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) {
FileDescriptor result = new FileDescriptor();
try {
Field descriptorField;
try {
descriptorField = FileDescriptor.class.getDeclaredField("descriptor");
} catch (NoSuchFieldException e) {
// For desktop java:
descriptorField = FileDescriptor.class.getDeclaredField("fd");
}
descriptorField.setAccessible(true);
descriptorField.set(result, fileDescriptor);
} catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
client.logStackTraceWithMessage(LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e);
System.exit(1);
}
return result;
}
@SuppressLint("HandlerLeak")
class MainThreadHandler extends Handler {
final byte[] mReceiveBuffer = new byte[4 * 1024];
@Override
public void handleMessage(Message msg) {
int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false);
if (bytesRead > 0) {
mEmulator.append(mReceiveBuffer, bytesRead);
notifyScreenUpdate();
}
if (msg.what == MSG_PROCESS_EXITED) {
int exitCode = (Integer) msg.obj;
cleanupResources(exitCode);
mClient.onSessionFinished(TerminalSession.this);
String exitDescription = "\r\n[Process completed";
if (exitCode > 0) {
// Non-zero process exit.
exitDescription += " (code " + exitCode + ")";
} else if (exitCode < 0) {
// Negated signal.
exitDescription += " (signal " + (-exitCode) + ")";
}
exitDescription += " - press Enter]";
byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8);
mEmulator.append(bytesToWrite, bytesToWrite.length);
notifyScreenUpdate();
}
}
}
}

View File

@@ -0,0 +1,37 @@
package com.termux.terminal;
/**
* The interface for communication between {@link TerminalSession} and its client. It is used to
* send callbacks to the client when {@link TerminalSession} changes or for sending other
* back data to the client like logs.
*/
public interface TerminalSessionClient {
void onTextChanged(TerminalSession changedSession);
void onTitleChanged(TerminalSession changedSession);
void onSessionFinished(TerminalSession finishedSession);
void onClipboardText(TerminalSession session, String text);
void onBell(TerminalSession session);
void onColorsChanged(TerminalSession session);
void logError(String tag, String message);
void logWarn(String tag, String message);
void logInfo(String tag, String message);
void logDebug(String tag, String message);
void logVerbose(String tag, String message);
void logStackTraceWithMessage(String tag, String message, Exception e);
void logStackTrace(String tag, Exception e);
}