From c9e18e5b933ca0abb5647b7f69468825791c05e4 Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 16 Mar 2021 05:01:09 +0500 Subject: [PATCH] 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. --- .../java/com/termux/app/TermuxActivity.java | 1275 ++++++----------- .../java/com/termux/app/TermuxService.java | 90 +- .../TermuxPreferenceConstants.java | 6 +- .../preferences/TermuxSharedPreferences.java | 30 +- .../app/terminal/TermuxSessionClient.java | 315 ++++ .../app/terminal/TermuxSessionClientBase.java | 71 + .../TermuxSessionsListViewController.java | 107 ++ .../termux/app/terminal/TermuxViewClient.java | 145 +- .../terminal/io/TerminalToolbarViewPager.java | 114 ++ .../com/termux/app/utils/TermuxUtils.java | 27 + .../com/termux/app/utils/TextDataUtils.java | 111 ++ .../layout/terminal_sessions_list_item.xml | 9 + .../terminal_toolbar_extra_keys_view.xml | 8 + .../terminal_toolbar_text_input_view.xml | 16 + app/src/main/res/layout/termux_activity.xml | 80 ++ .../com/termux/app/TermuxActivityTest.java | 4 +- .../com/termux/terminal/TerminalEmulator.java | 34 +- .../com/termux/terminal/TerminalSession.java | 178 ++- .../terminal/TerminalSessionClient.java | 37 + 19 files changed, 1659 insertions(+), 998 deletions(-) create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java create mode 100644 app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java create mode 100644 app/src/main/java/com/termux/app/utils/TextDataUtils.java create mode 100644 app/src/main/res/layout/terminal_sessions_list_item.xml create mode 100644 app/src/main/res/layout/terminal_toolbar_extra_keys_view.xml create mode 100644 app/src/main/res/layout/terminal_toolbar_text_input_view.xml create mode 100644 app/src/main/res/layout/termux_activity.xml create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 8b75f7ee..f2fc1688 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -6,32 +6,19 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Typeface; -import android.media.AudioAttributes; -import android.media.SoundPool; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.IBinder; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.Gravity; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -39,43 +26,31 @@ import android.view.ViewGroup; import android.view.WindowManager; import android.view.autofill.AutofillManager; import android.view.inputmethod.InputMethodManager; -import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.ListView; -import android.widget.TextView; import android.widget.Toast; import com.termux.R; import com.termux.app.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; import com.termux.app.settings.preferences.TermuxSharedPreferences; -import com.termux.app.terminal.BellHandler; +import com.termux.app.terminal.TermuxSessionsListViewController; +import com.termux.app.terminal.io.TerminalToolbarViewPager; +import com.termux.app.terminal.TermuxSessionClient; import com.termux.app.terminal.TermuxViewClient; -import com.termux.app.terminal.extrakeys.ExtraKeysView; -import com.termux.app.terminal.FullScreenWorkAround; -import com.termux.app.settings.properties.TermuxPropertyConstants; +import com.termux.app.terminal.io.extrakeys.ExtraKeysView; import com.termux.app.settings.properties.TermuxSharedProperties; +import com.termux.app.utils.DialogUtils; import com.termux.app.utils.Logger; -import com.termux.terminal.TerminalColors; +import com.termux.app.utils.TermuxUtils; import com.termux.terminal.TerminalSession; -import com.termux.terminal.TerminalSession.SessionChangedCallback; -import com.termux.terminal.TextStyle; +import com.termux.terminal.TerminalSessionClient; import com.termux.view.TerminalView; - -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStream; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Properties; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import com.termux.view.TerminalViewClient; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.drawerlayout.widget.DrawerLayout; -import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; /** @@ -90,44 +65,59 @@ import androidx.viewpager.widget.ViewPager; */ public final class TermuxActivity extends Activity implements ServiceConnection { - private static final int CONTEXTMENU_SELECT_URL_ID = 0; - private static final int CONTEXTMENU_SHARE_TRANSCRIPT_ID = 1; - private static final int CONTEXTMENU_PASTE_ID = 3; - private static final int CONTEXTMENU_KILL_PROCESS_ID = 4; - private static final int CONTEXTMENU_RESET_TERMINAL_ID = 5; - private static final int CONTEXTMENU_STYLING_ID = 6; - private static final int CONTEXTMENU_HELP_ID = 8; - private static final int CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON = 9; - private static final int CONTEXTMENU_AUTOFILL_ID = 10; - private static final int CONTEXTMENU_SETTINGS_ID = 11; - - private static final int MAX_SESSIONS = 8; - - private static final int REQUESTCODE_PERMISSION_STORAGE = 1234; - - - private static final String BROADCAST_TERMUX_OPENED = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.OPENED"; /** * The connection to the {@link TermuxService}. Requested in {@link #onCreate(Bundle)} with a call to * {@link #bindService(Intent, ServiceConnection, int)}, and obtained and stored in * {@link #onServiceConnected(ComponentName, IBinder)}. */ - TermuxService mTermService; + TermuxService mTermuxService; - /** The main view of the activity showing the terminal. Initialized in onCreate(). */ + /** + * The main view of the activity showing the terminal. Initialized in onCreate(). + */ TerminalView mTerminalView; - ExtraKeysView mExtraKeysView; + /** + * The {@link TerminalViewClient} interface implementation to allow for communication between + * {@link TerminalView} and {@link TermuxActivity}. + */ + TermuxViewClient mTermuxViewClient; + /** + * The {@link TerminalSessionClient} interface implementation to allow for communication between + * {@link TerminalSession} and {@link TermuxActivity}. + */ + TermuxSessionClient mTermuxSessionClient; + + /** + * Termux app shared preferences manager. + */ private TermuxSharedPreferences mPreferences; + /** + * Termux app shared properties manager, loaded from termux.properties + */ private TermuxSharedProperties mProperties; - /** Initialized in {@link #onServiceConnected(ComponentName, IBinder)}. */ - ArrayAdapter mListViewAdapter; + /** + * The terminal extra keys view. + */ + ExtraKeysView mExtraKeysView; - /** The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}. */ + /** + * The termux sessions list controller. + */ + TermuxSessionsListViewController mTermuxSessionListViewController; + + /** + * The {@link TermuxActivity} broadcast receiver for various things like terminal style configuration changes. + */ + private final BroadcastReceiver mTermuxActivityBroadcastReceiever = new TermuxActivityBroadcastReceiever(); + + /** + * The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}. + */ Toast mLastToast; /** @@ -136,99 +126,40 @@ public final class TermuxActivity extends Activity implements ServiceConnection */ boolean mIsVisible; - boolean mIsUsingBlackUI; - int mNavBarHeight; - 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 int CONTEXT_MENU_SELECT_URL_ID = 0; + private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1; + private static final int CONTEXT_MENU_PASTE_ID = 3; + private static final int CONTEXT_MENU_KILL_PROCESS_ID = 4; + private static final int CONTEXT_MENU_RESET_TERMINAL_ID = 5; + private static final int CONTEXT_MENU_STYLING_ID = 6; + private static final int CONTEXT_MENU_HELP_ID = 8; + private static final int CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON = 9; + private static final int CONTEXT_MENU_AUTOFILL_ID = 10; + private static final int CONTEXT_MENU_SETTINGS_ID = 11; + + + private static final int REQUESTCODE_PERMISSION_STORAGE = 1234; + + private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input"; private static final String LOG_TAG = "TermuxActivity"; - private final BroadcastReceiver mBroadcastReceiever = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (mIsVisible) { - String whatToReload = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); - Logger.logDebug(LOG_TAG, "Reloading termux style for: " + whatToReload); - if ("storage".equals(whatToReload)) { - if (ensureStoragePermissionGranted()) - TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); - return; - } - - checkForFontAndColors(); - - mProperties.loadTermuxPropertiesFromDisk(); - - if (mExtraKeysView != null) { - mExtraKeysView.reload(mProperties.getExtraKeysInfo()); - } - } - } - }; - - 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 = getCurrentTermSession(); - if (session != null && session.getEmulator() != null) { - session.getEmulator().mColors.reset(); - } - updateBackgroundColor(); - - final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE; - mTerminalView.setTypeface(newTypeface); - } catch (Exception e) { - Logger.logStackTraceWithMessage(LOG_TAG, "Error in checkForFontAndColors()", e); - } - } - - void updateBackgroundColor() { - TerminalSession session = getCurrentTermSession(); - if (session != null && session.getEmulator() != null) { - getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); - } - } - - /** For processes to access shared internal storage (/sdcard) we need this permission. */ - public boolean ensureStoragePermissionGranted() { - if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { - return true; - } else { - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUESTCODE_PERMISSION_STORAGE); - return false; - } - } - @Override - public void onCreate(Bundle bundle) { + public void onCreate(Bundle savedInstanceState) { + + Logger.logDebug(LOG_TAG, "onCreate"); + + // Load termux shared preferences and properties mPreferences = new TermuxSharedPreferences(this); mProperties = new TermuxSharedProperties(this); - mIsUsingBlackUI = mProperties.isUsingBlackUI(); - if (mIsUsingBlackUI) { - this.setTheme(R.style.Theme_Termux_Black); - } else { - this.setTheme(R.style.Theme_Termux); - } + setActivityTheme(); - super.onCreate(bundle); + super.onCreate(savedInstanceState); - - setContentView(R.layout.drawer_layout); + setContentView(R.layout.termux_activity); View content = findViewById(android.R.id.content); content.setOnApplyWindowInsetsListener((v, insets) -> { @@ -240,159 +171,57 @@ public final class TermuxActivity extends Activity implements ServiceConnection getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } - if (mIsUsingBlackUI) { - findViewById(R.id.left_drawer).setBackgroundColor( - getResources().getColor(android.R.color.background_dark) - ); - } + setDrawerTheme(); - mTerminalView = findViewById(R.id.terminal_view); + setTermuxTerminalViewAndClients(); - mTerminalView.setTerminalViewClient(new TermuxViewClient(this)); + setTerminalToolbarView(savedInstanceState); - mTerminalView.setTextSize(mPreferences.getFontSize()); - mTerminalView.setKeepScreenOn(mPreferences.getKeepScreenOn()); - mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); - mTerminalView.requestFocus(); + setNewSessionButtonView(); - final ViewPager viewPager = findViewById(R.id.viewpager); - if (mPreferences.getShowExtraKeys()) viewPager.setVisibility(View.VISIBLE); - - - ViewGroup.LayoutParams layoutParams = viewPager.getLayoutParams(); - layoutParams.height = layoutParams.height * (mProperties.getExtraKeysInfo() == null ? 0 : mProperties.getExtraKeysInfo().getMatrix().length); - viewPager.setLayoutParams(layoutParams); - - viewPager.setAdapter(new PagerAdapter() { - @Override - public int getCount() { - return 2; - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } - - @NonNull - @Override - public Object instantiateItem(@NonNull ViewGroup collection, int position) { - LayoutInflater inflater = LayoutInflater.from(TermuxActivity.this); - View layout; - if (position == 0) { - layout = mExtraKeysView = (ExtraKeysView) inflater.inflate(R.layout.extra_keys_main, collection, false); - mExtraKeysView.reload(mProperties.getExtraKeysInfo()); - - // apply extra keys fix if enabled in prefs - if (mProperties.isUsingFullScreen() && mProperties.isUsingFullScreenWorkAround()) { - FullScreenWorkAround.apply(TermuxActivity.this); - } - - } else { - layout = inflater.inflate(R.layout.extra_keys_right, collection, false); - final EditText editText = layout.findViewById(R.id.text_input); - editText.setOnEditorActionListener((v, actionId, event) -> { - TerminalSession session = getCurrentTermSession(); - if (session != null) { - if (session.isRunning()) { - String textToSend = editText.getText().toString(); - if (textToSend.length() == 0) textToSend = "\r"; - session.write(textToSend); - } else { - removeFinishedSession(session); - } - editText.setText(""); - } - return true; - }); - } - collection.addView(layout); - return layout; - } - - @Override - public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { - collection.removeView((View) view); - } - }); - - viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - if (position == 0) { - mTerminalView.requestFocus(); - } else { - final EditText editText = viewPager.findViewById(R.id.text_input); - if (editText != null) editText.requestFocus(); - } - } - }); - - View newSessionButton = findViewById(R.id.new_session_button); - newSessionButton.setOnClickListener(v -> addNewSession(false, null)); - newSessionButton.setOnLongClickListener(v -> { - DialogUtils.textInput(TermuxActivity.this, R.string.session_new_named_title, null, R.string.session_new_named_positive_button, - text -> addNewSession(false, text), R.string.new_session_failsafe, text -> addNewSession(true, text) - , -1, null, null); - return true; - }); - - findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { - InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); - getDrawer().closeDrawers(); - }); - - findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { - toggleShowExtraKeys(); - return true; - }); + setToggleKeyboardView(); registerForContextMenu(mTerminalView); + // Start the {@link TermuxService} and make it run regardless of who is bound to it Intent serviceIntent = new Intent(this, TermuxService.class); - // Start the service and make it run regardless of who is bound to it: startService(serviceIntent); + + // Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)} + // callback if it succeeds. if (!bindService(serviceIntent, this, 0)) throw new RuntimeException("bindService() failed"); - checkForFontAndColors(); - - mBellSoundId = mBellSoundPool.load(this, R.raw.bell, 1); - - sendOpenedBroadcast(); + // Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux + // app has been opened. + TermuxUtils.sendTermuxOpenedBroadcast(this); } - public int getNavBarHeight() { - return mNavBarHeight; - } + @Override + public void onStart() { + super.onStart(); - /** - * Send a broadcast notifying Termux app has been opened - */ - void sendOpenedBroadcast() { - Intent broadcast = new Intent(BROADCAST_TERMUX_OPENED); - List matches = getPackageManager().queryBroadcastReceivers(broadcast, 0); + Logger.logDebug(LOG_TAG, "onStart"); - // 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); - sendBroadcast(explicitBroadcast); + mIsVisible = true; + + if (mTermuxService != null) { + // The service has connected, but data may have changed since we were last in the foreground. + // Get the session stored in shared preferences stored by {@link #onStop} if its valid, + // otherwise get the last session currently running. + mTermuxSessionClient.setCurrentSession(mTermuxSessionClient.getCurrentStoredSessionOrLast()); + terminalSessionListNotifyUpdated(); } - } - public void toggleShowExtraKeys() { - final ViewPager viewPager = findViewById(R.id.viewpager); - final boolean showNow = mPreferences.toggleShowExtraKeys(); - viewPager.setVisibility(showNow ? View.VISIBLE : View.GONE); - if (showNow && viewPager.getCurrentItem() == 1) { - // Focus the text input view if just revealed. - findViewById(R.id.text_input).requestFocus(); - } + registerReceiver(mTermuxActivityBroadcastReceiever, new IntentFilter(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE)); + + // If user changed the preference from {@link TermuxSettings} activity and returns, then + // update the {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value. + mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); + + // The current terminal session may have changed while being away, force + // a refresh of the displayed terminal. + mTerminalView.onScreenUpdated(); } /** @@ -402,164 +231,24 @@ public final class TermuxActivity extends Activity implements ServiceConnection */ @Override public void onServiceConnected(ComponentName componentName, IBinder service) { - mTermService = ((TermuxService.LocalBinder) service).service; - mTermService.mSessionChangeCallback = new SessionChangedCallback() { - @Override - public void onTextChanged(TerminalSession changedSession) { - if (!mIsVisible) return; - if (getCurrentTermSession() == changedSession) mTerminalView.onScreenUpdated(); - } + Logger.logDebug(LOG_TAG, "onServiceConnected"); - @Override - public void onTitleChanged(TerminalSession updatedSession) { - if (!mIsVisible) return; - if (updatedSession != getCurrentTermSession()) { - // Only show toast for other sessions than the current one, since the user - // probably consciously caused the title change to change in the current session - // and don't want an annoying toast for that. - showToast(toToastTitle(updatedSession), false); - } - mListViewAdapter.notifyDataSetChanged(); - } + mTermuxService = ((TermuxService.LocalBinder) service).service; - @Override - public void onSessionFinished(final TerminalSession finishedSession) { - if (mTermService.mWantsToStop) { - // The service wants to stop as soon as possible. - finishActivityIfNotFinishing(); - return; - } - if (mIsVisible && finishedSession != getCurrentTermSession()) { - // Show toast for non-current sessions that exit. - int indexOfSession = mTermService.getSessions().indexOf(finishedSession); - // Verify that session was not removed before we got told about it finishing: - if (indexOfSession >= 0) - showToast(toToastTitle(finishedSession) + " - exited", true); - } + setTermuxSessionsListView(); - if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - // On Android TV devices we need to use older behaviour because we may - // not be able to have multiple launcher icons. - if (mTermService.getSessions().size() > 1) { - removeFinishedSession(finishedSession); - } - } else { - // Once we have a separate launcher icon for the failsafe session, it - // should be safe to auto-close session on exit code '0' or '130'. - if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130) { - removeFinishedSession(finishedSession); - } - } - - mListViewAdapter.notifyDataSetChanged(); - } - - @Override - public void onClipboardText(TerminalSession session, String text) { - if (!mIsVisible) return; - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(text))); - } - - @Override - public void onBell(TerminalSession session) { - if (!mIsVisible) return; - - switch (mProperties.getBellBehaviour()) { - case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_VIBRATE: - BellHandler.getInstance(TermuxActivity.this).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 (getCurrentTermSession() == changedSession) updateBackgroundColor(); - } - }; - - ListView listView = findViewById(R.id.left_drawer_list); - mListViewAdapter = new ArrayAdapter(getApplicationContext(), R.layout.line_in_drawer, mTermService.getSessions()) { - final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); - final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC); - - @NonNull - @Override - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - View row = convertView; - if (row == null) { - LayoutInflater inflater = getLayoutInflater(); - row = inflater.inflate(R.layout.line_in_drawer, parent, false); - } - - TerminalSession sessionAtRow = getItem(position); - if (sessionAtRow == null) return row; - - boolean sessionRunning = false; - sessionRunning = sessionAtRow.isRunning(); - - TextView firstLineView = row.findViewById(R.id.row_line); - if (mIsUsingBlackUI) { - firstLineView.setBackground( - getResources().getDrawable(R.drawable.selected_session_background_black) - ); - } - String name = sessionAtRow.mSessionName; - String sessionTitle = sessionAtRow.getTitle(); - - String numberPart = "[" + (position + 1) + "] "; - String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name); - String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle)); - - String text = numberPart + sessionNamePart + sessionTitlePart; - SpannableString styledText = new SpannableString(text); - styledText.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - styledText.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - firstLineView.setText(styledText); - - if (sessionRunning) { - firstLineView.setPaintFlags(firstLineView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); - } else { - firstLineView.setPaintFlags(firstLineView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); - } - int defaultColor = mIsUsingBlackUI ? Color.WHITE : Color.BLACK; - int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; - firstLineView.setTextColor(color); - return row; - } - }; - listView.setAdapter(mListViewAdapter); - listView.setOnItemClickListener((parent, view, position, id) -> { - TerminalSession clickedSession = mListViewAdapter.getItem(position); - switchToSession(clickedSession); - getDrawer().closeDrawers(); - }); - listView.setOnItemLongClickListener((parent, view, position, id) -> { - final TerminalSession selectedSession = mListViewAdapter.getItem(position); - renameSession(selectedSession); - return true; - }); - - if (mTermService.getSessions().isEmpty()) { + if (mTermuxService.getSessions().isEmpty()) { if (mIsVisible) { TermuxInstaller.setupIfNeeded(TermuxActivity.this, () -> { - if (mTermService == null) return; // Activity might have been destroyed. + if (mTermuxService == null) return; // Activity might have been destroyed. try { Bundle bundle = getIntent().getExtras(); boolean launchFailsafe = false; if (bundle != null) { launchFailsafe = bundle.getBoolean(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); } - addNewSession(launchFailsafe, null); + mTermuxSessionClient.addNewSession(launchFailsafe, null); } catch (WindowManager.BadTokenException e) { // Activity finished - ignore. } @@ -573,81 +262,176 @@ public final class TermuxActivity extends Activity implements ServiceConnection if (i != null && Intent.ACTION_RUN.equals(i.getAction())) { // Android 7.1 app shortcut from res/xml/shortcuts.xml. boolean failSafe = i.getBooleanExtra(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); - addNewSession(failSafe, null); + mTermuxSessionClient.addNewSession(failSafe, null); } else { - switchToSession(getStoredCurrentSessionOrLast()); + mTermuxSessionClient.setCurrentSession(mTermuxSessionClient.getCurrentStoredSessionOrLast()); } } - } - public void switchToSession(boolean forward) { - TerminalSession currentSession = getCurrentTermSession(); - int index = mTermService.getSessions().indexOf(currentSession); - if (forward) { - if (++index >= mTermService.getSessions().size()) index = 0; - } else { - if (--index < 0) index = mTermService.getSessions().size() - 1; - } - switchToSession(mTermService.getSessions().get(index)); - } - - @SuppressLint("InflateParams") - public void renameSession(final TerminalSession sessionToRename) { - if (sessionToRename == null) return; - DialogUtils.textInput(this, R.string.session_rename_title, sessionToRename.mSessionName, R.string.session_rename_positive_button, text -> { - sessionToRename.mSessionName = text; - mListViewAdapter.notifyDataSetChanged(); - }, -1, null, -1, null, null); + // Update the {@link TerminalSession} and {@link TerminalEmulator} clients. + mTermuxService.setTermuxSessionClient(mTermuxSessionClient); } @Override public void onServiceDisconnected(ComponentName name) { - // Respect being stopped from the TermuxService notification action. + + Logger.logDebug(LOG_TAG, "onServiceDisconnected"); + + // Respect being stopped from the {@link TermuxService} notification action. finishActivityIfNotFinishing(); } - public void finishActivityIfNotFinishing() { - // prevent duplicate calls to finish() if called from multiple places - if (!TermuxActivity.this.isFinishing()) { - finish(); - } - } - - @Nullable - public TerminalSession getCurrentTermSession() { - return mTerminalView.getCurrentSession(); - } - - @Override - public void onStart() { - super.onStart(); - mIsVisible = true; - - if (mTermService != null) { - // The service has connected, but data may have changed since we were last in the foreground. - switchToSession(getStoredCurrentSessionOrLast()); - mListViewAdapter.notifyDataSetChanged(); - } - - registerReceiver(mBroadcastReceiever, new IntentFilter(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE)); - - mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); - - // The current terminal session may have changed while being away, force - // a refresh of the displayed terminal: - mTerminalView.onScreenUpdated(); - } - @Override protected void onStop() { super.onStop(); + + Logger.logDebug(LOG_TAG, "onStop"); + mIsVisible = false; - TerminalSession currentSession = getCurrentTermSession(); - if (currentSession != null) mPreferences.setCurrentSession(currentSession.mHandle); - unregisterReceiver(mBroadcastReceiever); + + // Store current session in shared preferences so that it can be restored later in + // {@link #onStart} if needed. + mTermuxSessionClient.setCurrentStoredSession(); + + unregisterReceiver(mTermuxActivityBroadcastReceiever); getDrawer().closeDrawers(); } + @Override + public void onDestroy() { + super.onDestroy(); + + Logger.logDebug(LOG_TAG, "onDestroy"); + + if (mTermuxService != null) { + // Do not leave service and session clients with references to activity. + mTermuxService.unsetTermuxSessionClient(); + mTermuxService = null; + } + unbindService(this); + } + + @Override + public void onSaveInstanceState(Bundle savedInstanceState) { + super.onSaveInstanceState(savedInstanceState); + saveTerminalToolbarTextInput(savedInstanceState); + } + + + + private void setActivityTheme() { + if (mProperties.isUsingBlackUI()) { + this.setTheme(R.style.Theme_Termux_Black); + } else { + this.setTheme(R.style.Theme_Termux); + } + } + + private void setDrawerTheme() { + if (mProperties.isUsingBlackUI()) { + findViewById(R.id.left_drawer).setBackgroundColor(ContextCompat.getColor(this, + android.R.color.background_dark)); + } + } + + + + private void setTerminalToolbarView(Bundle savedInstanceState) { + final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + if (mPreferences.getShowTerminalToolbar()) terminalToolbarViewPager.setVisibility(View.VISIBLE); + + ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams(); + layoutParams.height = layoutParams.height * (mProperties.getExtraKeysInfo() == null ? 0 : mProperties.getExtraKeysInfo().getMatrix().length); + terminalToolbarViewPager.setLayoutParams(layoutParams); + + String savedTextInput = null; + if(savedInstanceState != null) + savedTextInput = savedInstanceState.getString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT); + + terminalToolbarViewPager.setAdapter(new TerminalToolbarViewPager.PageAdapter(this, savedTextInput)); + terminalToolbarViewPager.addOnPageChangeListener(new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager)); + } + + public void toggleTerminalToolbar() { + final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + final boolean showNow = mPreferences.toogleShowTerminalToolbar(); + terminalToolbarViewPager.setVisibility(showNow ? View.VISIBLE : View.GONE); + if (showNow && terminalToolbarViewPager.getCurrentItem() == 1) { + // Focus the text input view if just revealed. + findViewById(R.id.terminal_toolbar_text_input).requestFocus(); + } + } + + private void saveTerminalToolbarTextInput(Bundle savedInstanceState) { + if(savedInstanceState == null) return; + + final EditText textInputView = findViewById(R.id.terminal_toolbar_text_input); + if(textInputView != null) { + String textInput = textInputView.getText().toString(); + if(!textInput.isEmpty()) savedInstanceState.putString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT, textInput); + } + } + + + + private void setNewSessionButtonView() { + View newSessionButton = findViewById(R.id.new_session_button); + newSessionButton.setOnClickListener(v -> mTermuxSessionClient.addNewSession(false, null)); + newSessionButton.setOnLongClickListener(v -> { + DialogUtils.textInput(TermuxActivity.this, R.string.session_new_named_title, null, R.string.session_new_named_positive_button, + text -> mTermuxSessionClient.addNewSession(false, text), R.string.new_session_failsafe, text -> mTermuxSessionClient.addNewSession(true, text) + , -1, null, null); + return true; + }); + } + + private void setToggleKeyboardView() { + findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); + getDrawer().closeDrawers(); + }); + + findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { + toggleTerminalToolbar(); + return true; + }); + } + + + + private void setTermuxTerminalViewAndClients() { + // Set termux terminal view and session clients + mTermuxSessionClient = new TermuxSessionClient(this); + mTermuxViewClient = new TermuxViewClient(this, mTermuxSessionClient); + + // Set termux terminal view + mTerminalView = findViewById(R.id.terminal_view); + mTerminalView.setTerminalViewClient(mTermuxViewClient); + + mTerminalView.setTextSize(mPreferences.getFontSize()); + mTerminalView.setKeepScreenOn(mPreferences.getKeepScreenOn()); + + // Set {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value + mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); + + mTerminalView.requestFocus(); + + mTermuxSessionClient.checkForFontAndColors(); + } + + private void setTermuxSessionsListView() { + ListView termuxSessionsListView = findViewById(R.id.terminal_sessions_list); + mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getSessions()); + termuxSessionsListView.setAdapter(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); + } + + + + + @SuppressLint("RtlHardcoded") @Override public void onBackPressed() { @@ -658,97 +442,45 @@ public final class TermuxActivity extends Activity implements ServiceConnection } } - @Override - public void onDestroy() { - super.onDestroy(); - if (mTermService != null) { - // Do not leave service with references to activity. - mTermService.mSessionChangeCallback = null; - mTermService = null; - } - unbindService(this); - } - - public DrawerLayout getDrawer() { - return (DrawerLayout) findViewById(R.id.drawer_layout); - } - - public void addNewSession(boolean failSafe, String sessionName) { - if (mTermService.getSessions().size() >= MAX_SESSIONS) { - new AlertDialog.Builder(this).setTitle(R.string.max_terminals_reached_title).setMessage(R.string.max_terminals_reached_message) - .setPositiveButton(android.R.string.ok, null).show(); - } else { - TerminalSession currentSession = getCurrentTermSession(); - - String workingDirectory; - if (currentSession == null) { - workingDirectory = mProperties.getDefaultWorkingDirectory(); - } else { - workingDirectory = currentSession.getCwd(); - } - - TerminalSession newSession = mTermService.createTermSession(null, null, workingDirectory, failSafe); - if (sessionName != null) { - newSession.mSessionName = sessionName; - } - switchToSession(newSession); - getDrawer().closeDrawers(); + public void finishActivityIfNotFinishing() { + // prevent duplicate calls to finish() if called from multiple places + if (!TermuxActivity.this.isFinishing()) { + finish(); } } - /** Try switching to session and note about it, but do nothing if already displaying the session. */ - public void switchToSession(TerminalSession session) { - if (mTerminalView.attachSession(session)) { - noteSessionInfo(); - updateBackgroundColor(); - } + /** Show a toast and dismiss the last one if still visible. */ + public void showToast(String text, boolean longDuration) { + if (mLastToast != null) mLastToast.cancel(); + mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT); + mLastToast.setGravity(Gravity.TOP, 0, 0); + mLastToast.show(); } - String toToastTitle(TerminalSession session) { - final int indexOfSession = mTermService.getSessions().indexOf(session); - StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]"); - if (!TextUtils.isEmpty(session.mSessionName)) { - toastTitle.append(" ").append(session.mSessionName); - } - String title = session.getTitle(); - if (!TextUtils.isEmpty(title)) { - // Space to "[${NR}] or newline after session name: - toastTitle.append(session.mSessionName == null ? " " : "\n"); - toastTitle.append(title); - } - return toastTitle.toString(); - } - void noteSessionInfo() { - if (!mIsVisible) return; - TerminalSession session = getCurrentTermSession(); - final int indexOfSession = mTermService.getSessions().indexOf(session); - showToast(toToastTitle(session), false); - mListViewAdapter.notifyDataSetChanged(); - final ListView lv = findViewById(R.id.left_drawer_list); - lv.setItemChecked(indexOfSession, true); - lv.smoothScrollToPosition(indexOfSession); - } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - TerminalSession currentSession = getCurrentTermSession(); + TerminalSession currentSession = getCurrentSession(); if (currentSession == null) return; - menu.add(Menu.NONE, CONTEXTMENU_SELECT_URL_ID, Menu.NONE, R.string.select_url); - menu.add(Menu.NONE, CONTEXTMENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.select_all_and_share); + boolean addAutoFillMenu = false; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { AutofillManager autofillManager = getSystemService(AutofillManager.class); if (autofillManager != null && autofillManager.isEnabled()) { - menu.add(Menu.NONE, CONTEXTMENU_AUTOFILL_ID, Menu.NONE, R.string.autofill_password); + addAutoFillMenu = true; } } - menu.add(Menu.NONE, CONTEXTMENU_RESET_TERMINAL_ID, Menu.NONE, R.string.reset_terminal); - menu.add(Menu.NONE, CONTEXTMENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.kill_process, getCurrentTermSession().getPid())).setEnabled(currentSession.isRunning()); - menu.add(Menu.NONE, CONTEXTMENU_STYLING_ID, Menu.NONE, R.string.style_terminal); - menu.add(Menu.NONE, CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.toggle_keep_screen_on).setCheckable(true).setChecked(mPreferences.getKeepScreenOn()); - menu.add(Menu.NONE, CONTEXTMENU_HELP_ID, Menu.NONE, R.string.help); - menu.add(Menu.NONE, CONTEXTMENU_SETTINGS_ID, Menu.NONE, R.string.settings); + + menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.select_url); + menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.select_all_and_share); + if (addAutoFillMenu) menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.autofill_password); + menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.reset_terminal); + menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning()); + menu.add(Menu.NONE, CONTEXT_MENU_STYLING_ID, Menu.NONE, R.string.style_terminal); + menu.add(Menu.NONE, CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.toggle_keep_screen_on).setCheckable(true).setChecked(mPreferences.getKeepScreenOn()); + menu.add(Menu.NONE, CONTEXT_MENU_HELP_ID, Menu.NONE, R.string.help); + menu.add(Menu.NONE, CONTEXT_MENU_SETTINGS_ID, Menu.NONE, R.string.settings); } /** Hook system menu to show context menu instead. */ @@ -758,316 +490,215 @@ public final class TermuxActivity extends Activity implements ServiceConnection return false; } - static LinkedHashSet extractUrls(String text) { - - StringBuilder regex_sb = new StringBuilder(); - - regex_sb.append("("); // Begin first matching group. - regex_sb.append("(?:"); // Begin scheme group. - regex_sb.append("dav|"); // The DAV proto. - regex_sb.append("dict|"); // The DICT proto. - regex_sb.append("dns|"); // The DNS proto. - regex_sb.append("file|"); // File path. - regex_sb.append("finger|"); // The Finger proto. - regex_sb.append("ftp(?:s?)|"); // The FTP proto. - regex_sb.append("git|"); // The Git proto. - regex_sb.append("gopher|"); // The Gopher proto. - regex_sb.append("http(?:s?)|"); // The HTTP proto. - regex_sb.append("imap(?:s?)|"); // The IMAP proto. - regex_sb.append("irc(?:[6s]?)|"); // The IRC proto. - regex_sb.append("ip[fn]s|"); // The IPFS proto. - regex_sb.append("ldap(?:s?)|"); // The LDAP proto. - regex_sb.append("pop3(?:s?)|"); // The POP3 proto. - regex_sb.append("redis(?:s?)|"); // The Redis proto. - regex_sb.append("rsync|"); // The Rsync proto. - regex_sb.append("rtsp(?:[su]?)|"); // The RTSP proto. - regex_sb.append("sftp|"); // The SFTP proto. - regex_sb.append("smb(?:s?)|"); // The SAMBA proto. - regex_sb.append("smtp(?:s?)|"); // The SMTP proto. - regex_sb.append("svn(?:(?:\\+ssh)?)|"); // The Subversion proto. - regex_sb.append("tcp|"); // The TCP proto. - regex_sb.append("telnet|"); // The Telnet proto. - regex_sb.append("tftp|"); // The TFTP proto. - regex_sb.append("udp|"); // The UDP proto. - regex_sb.append("vnc|"); // The VNC proto. - regex_sb.append("ws(?:s?)"); // The Websocket proto. - regex_sb.append(")://"); // End scheme group. - regex_sb.append(")"); // End first matching group. - - - // Begin second matching group. - regex_sb.append("("); - - // User name and/or password in format 'user:pass@'. - regex_sb.append("(?:\\S+(?::\\S*)?@)?"); - - // Begin host group. - regex_sb.append("(?:"); - - // IP address (from http://www.regular-expressions.info/examples.html). - regex_sb.append("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|"); - - // Host name or domain. - regex_sb.append("(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))?|"); - - // Just path. Used in case of 'file://' scheme. - regex_sb.append("/(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)"); - - // End host group. - regex_sb.append(")"); - - // Port number. - regex_sb.append("(?::\\d{1,5})?"); - - // Resource path with optional query string. - regex_sb.append("(?:/[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); - - // Fragment. - regex_sb.append("(?:#[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); - - // End second matching group. - regex_sb.append(")"); - - final Pattern urlPattern = Pattern.compile( - regex_sb.toString(), - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); - - LinkedHashSet urlSet = new LinkedHashSet<>(); - Matcher matcher = urlPattern.matcher(text); - - while (matcher.find()) { - int matchStart = matcher.start(1); - int matchEnd = matcher.end(); - String url = text.substring(matchStart, matchEnd); - urlSet.add(url); - } - - return urlSet; - } - - public void showUrlSelection() { - String text = null; - if (getCurrentTermSession() != null) { - text = getCurrentTermSession().getEmulator().getScreen().getTranscriptTextWithFullLinesJoined(); - } - LinkedHashSet urlSet = extractUrls(text); - if (urlSet.isEmpty()) { - new AlertDialog.Builder(this).setMessage(R.string.select_url_no_found).show(); - return; - } - - final CharSequence[] urls = urlSet.toArray(new CharSequence[0]); - Collections.reverse(Arrays.asList(urls)); // Latest first. - - // Click to copy url to clipboard: - final AlertDialog dialog = new AlertDialog.Builder(TermuxActivity.this).setItems(urls, (di, which) -> { - String url = (String) urls[which]; - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(url))); - Toast.makeText(TermuxActivity.this, R.string.select_url_copied_to_clipboard, Toast.LENGTH_LONG).show(); - }).setTitle(R.string.select_url_dialog_title).create(); - - // Long press to open URL: - dialog.setOnShowListener(di -> { - ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it - lv.setOnItemLongClickListener((parent, view, position, id) -> { - dialog.dismiss(); - String url = (String) urls[position]; - Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - try { - startActivity(i, null); - } catch (ActivityNotFoundException e) { - // If no applications match, Android displays a system message. - startActivity(Intent.createChooser(i, null)); - } - return true; - }); - }); - - dialog.show(); - } - @Override public boolean onContextItemSelected(MenuItem item) { - TerminalSession session = getCurrentTermSession(); + TerminalSession session = getCurrentSession(); switch (item.getItemId()) { - case CONTEXTMENU_SELECT_URL_ID: - showUrlSelection(); + case CONTEXT_MENU_SELECT_URL_ID: + mTermuxViewClient.showUrlSelection(); return true; - case CONTEXTMENU_SHARE_TRANSCRIPT_ID: - if (session != null) { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - String transcriptText = session.getEmulator().getScreen().getTranscriptTextWithoutJoinedLines().trim(); - // See https://github.com/termux/termux-app/issues/1166. - final int MAX_LENGTH = 100_000; - if (transcriptText.length() > MAX_LENGTH) { - int cutOffIndex = transcriptText.length() - MAX_LENGTH; - int nextNewlineIndex = transcriptText.indexOf('\n', cutOffIndex); - if (nextNewlineIndex != -1 && nextNewlineIndex != transcriptText.length() - 1) { - cutOffIndex = nextNewlineIndex + 1; - } - transcriptText = transcriptText.substring(cutOffIndex).trim(); - } - intent.putExtra(Intent.EXTRA_TEXT, transcriptText); - intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_transcript_title)); - startActivity(Intent.createChooser(intent, getString(R.string.share_transcript_chooser_title))); - } + case CONTEXT_MENU_SHARE_TRANSCRIPT_ID: + mTermuxViewClient.shareSessionTranscript(); return true; - case CONTEXTMENU_PASTE_ID: - doPaste(); + case CONTEXT_MENU_PASTE_ID: + mTermuxViewClient.doPaste(); return true; - case CONTEXTMENU_KILL_PROCESS_ID: - final AlertDialog.Builder b = new AlertDialog.Builder(this); - final TerminalSession terminalSession = getCurrentTermSession(); - if (terminalSession == null) return true; - - b.setIcon(android.R.drawable.ic_dialog_alert); - b.setMessage(R.string.confirm_kill_process); - b.setPositiveButton(android.R.string.yes, (dialog, id) -> { - dialog.dismiss(); - terminalSession.finishIfRunning(); - }); - b.setNegativeButton(android.R.string.no, null); - b.show(); + case CONTEXT_MENU_KILL_PROCESS_ID: + showKillSessionDialog(session); return true; - case CONTEXTMENU_RESET_TERMINAL_ID: { - if (session != null) { - session.reset(); - showToast(getResources().getString(R.string.reset_toast_notification), true); - } + case CONTEXT_MENU_RESET_TERMINAL_ID: + resetSession(session); return true; - } - case CONTEXTMENU_STYLING_ID: { - Intent stylingIntent = new Intent(); - stylingIntent.setClassName(TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, TermuxConstants.TERMUX_STYLING.TERMUX_STYLING_ACTIVITY_NAME); - try { - startActivity(stylingIntent); - } catch (ActivityNotFoundException | IllegalArgumentException e) { - // The startActivity() call is not documented to throw IllegalArgumentException. - // However, crash reporting shows that it sometimes does, so catch it here. - new AlertDialog.Builder(this).setMessage(getString(R.string.styling_not_installed)) - .setPositiveButton(R.string.styling_install, (dialog, which) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://f-droid.org/en/packages/" + TermuxConstants.TERMUX_STYLING_PACKAGE_NAME + " /")))).setNegativeButton(android.R.string.cancel, null).show(); - } + case CONTEXT_MENU_STYLING_ID: + showStylingDialog(); return true; - } - case CONTEXTMENU_HELP_ID: + case CONTEXT_MENU_HELP_ID: startActivity(new Intent(this, TermuxHelpActivity.class)); return true; - case CONTEXTMENU_SETTINGS_ID: + case CONTEXT_MENU_SETTINGS_ID: startActivity(new Intent(this, TermuxSettingsActivity.class)); return true; - case CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON: { - if(mTerminalView.getKeepScreenOn()) { - mTerminalView.setKeepScreenOn(false); - mPreferences.setKeepScreenOn(false); - } else { - mTerminalView.setKeepScreenOn(true); - mPreferences.setKeepScreenOn(true); - } + case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON: + toggleKeepScreenOn(); + return true; + case CONTEXT_MENU_AUTOFILL_ID: + requestAutoFill(); return true; - } - case CONTEXTMENU_AUTOFILL_ID: { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillManager autofillManager = getSystemService(AutofillManager.class); - if (autofillManager != null && autofillManager.isEnabled()) { - autofillManager.requestAutofill(mTerminalView); - } - } - } default: return super.onContextItemSelected(item); } } + private void showKillSessionDialog(TerminalSession session) { + if (session == null) return; + + final AlertDialog.Builder b = new AlertDialog.Builder(this); + b.setIcon(android.R.drawable.ic_dialog_alert); + b.setMessage(R.string.confirm_kill_process); + b.setPositiveButton(android.R.string.yes, (dialog, id) -> { + dialog.dismiss(); + session.finishIfRunning(); + }); + b.setNegativeButton(android.R.string.no, null); + b.show(); + } + + private void resetSession(TerminalSession session) { + if (session != null) { + session.reset(); + showToast(getResources().getString(R.string.reset_toast_notification), true); + } + } + + private void showStylingDialog() { + Intent stylingIntent = new Intent(); + stylingIntent.setClassName(TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, TermuxConstants.TERMUX_STYLING.TERMUX_STYLING_ACTIVITY_NAME); + try { + startActivity(stylingIntent); + } catch (ActivityNotFoundException | IllegalArgumentException e) { + // The startActivity() call is not documented to throw IllegalArgumentException. + // However, crash reporting shows that it sometimes does, so catch it here. + new AlertDialog.Builder(this).setMessage(getString(R.string.styling_not_installed)) + .setPositiveButton(R.string.styling_install, (dialog, which) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://f-droid.org/en/packages/" + TermuxConstants.TERMUX_STYLING_PACKAGE_NAME + " /")))).setNegativeButton(android.R.string.cancel, null).show(); + } + } + private void toggleKeepScreenOn() { + if(mTerminalView.getKeepScreenOn()) { + mTerminalView.setKeepScreenOn(false); + mPreferences.setKeepScreenOn(false); + } else { + mTerminalView.setKeepScreenOn(true); + mPreferences.setKeepScreenOn(true); + } + } + + 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); + } + } + } + + + + /** + * For processes to access shared internal storage (/sdcard) we need this permission. + */ + public boolean ensureStoragePermissionGranted() { + if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { + return true; + } else { + Logger.logDebug(LOG_TAG, "Storage permission not granted, requesting permission."); + requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUESTCODE_PERMISSION_STORAGE); + return false; + } + } + @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (requestCode == REQUESTCODE_PERMISSION_STORAGE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Logger.logDebug(LOG_TAG, "Storage permission granted by user on request."); TermuxInstaller.setupStorageSymlinks(this); - } - } - - public void changeFontSize(boolean increase) { - mPreferences.changeFontSize(this, increase); - mTerminalView.setTextSize(mPreferences.getFontSize()); - } - - public void doPaste() { - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null) return; - CharSequence paste = clipData.getItemAt(0).coerceToText(this); - if (!TextUtils.isEmpty(paste)) - if (getCurrentTermSession() != null) { - getCurrentTermSession().getEmulator().paste(paste.toString()); - } - } - - /** The current session as stored or the last one if that does not exist. */ - public TerminalSession getStoredCurrentSessionOrLast() { - TerminalSession stored = getCurrentSession(this); - if (stored != null) return stored; - List sessions = mTermService.getSessions(); - return sessions.isEmpty() ? null : sessions.get(sessions.size() - 1); - } - - private TerminalSession getCurrentSession(TermuxActivity context) { - String sessionHandle = mPreferences.getCurrentSession(); - for (int i = 0, len = context.getTermService().getSessions().size(); i < len; i++) { - TerminalSession session = context.getTermService().getSessions().get(i); - if (session.mHandle.equals(sessionHandle)) return session; - } - return null; - } - - /** Show a toast and dismiss the last one if still visible. */ - void showToast(String text, boolean longDuration) { - if (mLastToast != null) mLastToast.cancel(); - mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT); - mLastToast.setGravity(Gravity.TOP, 0, 0); - mLastToast.show(); - } - - public void removeFinishedSession(TerminalSession finishedSession) { - // Return pressed with finished session - remove it. - TermuxService service = mTermService; - - int index = service.removeTermSession(finishedSession); - mListViewAdapter.notifyDataSetChanged(); - if (mTermService.getSessions().isEmpty()) { - // There are no sessions to show, so finish the activity. - finishActivityIfNotFinishing(); } else { - if (index >= service.getSessions().size()) { - index = service.getSessions().size() - 1; - } - switchToSession(service.getSessions().get(index)); + Logger.logDebug(LOG_TAG, "Storage permission denied by user on request."); } } - public boolean isVisible() { - return mIsVisible; - } - public TermuxService getTermService() { - return mTermService; - } - public TerminalView getTerminalView() { - return mTerminalView; + public int getNavBarHeight() { + return mNavBarHeight; } public ExtraKeysView getExtraKeysView() { return mExtraKeysView; } - public TermuxSharedProperties getProperties() { - return mProperties; + public void setExtraKeysView(ExtraKeysView extraKeysView) { + mExtraKeysView = extraKeysView; + } + + public DrawerLayout getDrawer() { + return (DrawerLayout) findViewById(R.id.drawer_layout); + } + + public void terminalSessionListNotifyUpdated() { + mTermuxSessionListViewController.notifyDataSetChanged(); + } + + public boolean isVisible() { + return mIsVisible; + } + + + + public TermuxService getTermuxService() { + return mTermuxService; + } + + public TerminalView getTerminalView() { + return mTerminalView; + } + + public TermuxSessionClient getTermuxSessionClient() { + return mTermuxSessionClient; + } + + @Nullable + public TerminalSession getCurrentSession() { + if(mTerminalView != null) + return mTerminalView.getCurrentSession(); + else + return null; } public TermuxSharedPreferences getPreferences() { return mPreferences; } + public TermuxSharedProperties getProperties() { + return mProperties; + } + + + + + class TermuxActivityBroadcastReceiever extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (mIsVisible) { + String whatToReload = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); + Logger.logDebug(LOG_TAG, "Reloading termux style for: " + whatToReload); + if ("storage".equals(whatToReload)) { + if (ensureStoragePermissionGranted()) + TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + return; + } + + if(mTermuxSessionClient!= null) { + mTermuxSessionClient.checkForFontAndColors(); + } + + if(mProperties!= null) { + mProperties.loadTermuxPropertiesFromDisk(); + + if (mExtraKeysView != null) { + mExtraKeysView.reload(mProperties.getExtraKeysInfo()); + } + } + + // To change the activity and drawer theme, activity needs to be recreated. + // But this will destroy the activity, and will call the onCreate() again. + // We need to investigate if enabling this is wise, since all stored variables and + // views will be destroyed and bindService() will be called again. Extra keys input + // text will we restored since that has already been implemented. Terminal sessions + // and transcripts are also already preserved. Theme does change properly too. + // TermuxActivity.this.recreate(); + } + } + }; + } diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index cc624d60..46aebbcf 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -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. *

- * 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 mTerminalSessions = new ArrayList<>(); final List mBackgroundTasks = new ArrayList<>(); - /** Note that the service may often outlive the activity, so need to clear this reference. */ - SessionChangedCallback mSessionChangeCallback; + /** The 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) { diff --git a/app/src/main/java/com/termux/app/settings/preferences/TermuxPreferenceConstants.java b/app/src/main/java/com/termux/app/settings/preferences/TermuxPreferenceConstants.java index a12aa954..1e9df39d 100644 --- a/app/src/main/java/com/termux/app/settings/preferences/TermuxPreferenceConstants.java +++ b/app/src/main/java/com/termux/app/settings/preferences/TermuxPreferenceConstants.java @@ -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; diff --git a/app/src/main/java/com/termux/app/settings/preferences/TermuxSharedPreferences.java b/app/src/main/java/com/termux/app/settings/preferences/TermuxSharedPreferences.java index 1278dd3e..dc54d696 100644 --- a/app/src/main/java/com/termux/app/settings/preferences/TermuxSharedPreferences.java +++ b/app/src/main/java/com/termux/app/settings/preferences/TermuxSharedPreferences.java @@ -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); + } + } diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java new file mode 100644 index 00000000..6222b0aa --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java @@ -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 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 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]); + } + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java new file mode 100644 index 00000000..7404ed00 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java @@ -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); + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java new file mode 100644 index 00000000..1216193a --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -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 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 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; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java index 8d49b6df..2025b3d3 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java @@ -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 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); diff --git a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java new file mode 100644 index 00000000..e53fbbcd --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -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(); + } + } + + } + +} diff --git a/app/src/main/java/com/termux/app/utils/TermuxUtils.java b/app/src/main/java/com/termux/app/utils/TermuxUtils.java index 712b5b3a..db66b01a 100644 --- a/app/src/main/java/com/termux/app/utils/TermuxUtils.java +++ b/app/src/main/java/com/termux/app/utils/TermuxUtils.java @@ -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 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); + } + } + } diff --git a/app/src/main/java/com/termux/app/utils/TextDataUtils.java b/app/src/main/java/com/termux/app/utils/TextDataUtils.java new file mode 100644 index 00000000..e61e373c --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/TextDataUtils.java @@ -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 extractUrls(String text) { + + StringBuilder regex_sb = new StringBuilder(); + + regex_sb.append("("); // Begin first matching group. + regex_sb.append("(?:"); // Begin scheme group. + regex_sb.append("dav|"); // The DAV proto. + regex_sb.append("dict|"); // The DICT proto. + regex_sb.append("dns|"); // The DNS proto. + regex_sb.append("file|"); // File path. + regex_sb.append("finger|"); // The Finger proto. + regex_sb.append("ftp(?:s?)|"); // The FTP proto. + regex_sb.append("git|"); // The Git proto. + regex_sb.append("gopher|"); // The Gopher proto. + regex_sb.append("http(?:s?)|"); // The HTTP proto. + regex_sb.append("imap(?:s?)|"); // The IMAP proto. + regex_sb.append("irc(?:[6s]?)|"); // The IRC proto. + regex_sb.append("ip[fn]s|"); // The IPFS proto. + regex_sb.append("ldap(?:s?)|"); // The LDAP proto. + regex_sb.append("pop3(?:s?)|"); // The POP3 proto. + regex_sb.append("redis(?:s?)|"); // The Redis proto. + regex_sb.append("rsync|"); // The Rsync proto. + regex_sb.append("rtsp(?:[su]?)|"); // The RTSP proto. + regex_sb.append("sftp|"); // The SFTP proto. + regex_sb.append("smb(?:s?)|"); // The SAMBA proto. + regex_sb.append("smtp(?:s?)|"); // The SMTP proto. + regex_sb.append("svn(?:(?:\\+ssh)?)|"); // The Subversion proto. + regex_sb.append("tcp|"); // The TCP proto. + regex_sb.append("telnet|"); // The Telnet proto. + regex_sb.append("tftp|"); // The TFTP proto. + regex_sb.append("udp|"); // The UDP proto. + regex_sb.append("vnc|"); // The VNC proto. + regex_sb.append("ws(?:s?)"); // The Websocket proto. + regex_sb.append(")://"); // End scheme group. + regex_sb.append(")"); // End first matching group. + + + // Begin second matching group. + regex_sb.append("("); + + // User name and/or password in format 'user:pass@'. + regex_sb.append("(?:\\S+(?::\\S*)?@)?"); + + // Begin host group. + regex_sb.append("(?:"); + + // IP address (from http://www.regular-expressions.info/examples.html). + regex_sb.append("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|"); + + // Host name or domain. + regex_sb.append("(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))?|"); + + // Just path. Used in case of 'file://' scheme. + regex_sb.append("/(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)"); + + // End host group. + regex_sb.append(")"); + + // Port number. + regex_sb.append("(?::\\d{1,5})?"); + + // Resource path with optional query string. + regex_sb.append("(?:/[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); + + // Fragment. + regex_sb.append("(?:#[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); + + // End second matching group. + regex_sb.append(")"); + + final Pattern urlPattern = Pattern.compile( + regex_sb.toString(), + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); + + LinkedHashSet urlSet = new LinkedHashSet<>(); + Matcher matcher = urlPattern.matcher(text); + + while (matcher.find()) { + int matchStart = matcher.start(1); + int matchEnd = matcher.end(); + String url = text.substring(matchStart, matchEnd); + urlSet.add(url); + } + + return urlSet; + } + +} diff --git a/app/src/main/res/layout/terminal_sessions_list_item.xml b/app/src/main/res/layout/terminal_sessions_list_item.xml new file mode 100644 index 00000000..61842a37 --- /dev/null +++ b/app/src/main/res/layout/terminal_sessions_list_item.xml @@ -0,0 +1,9 @@ + diff --git a/app/src/main/res/layout/terminal_toolbar_extra_keys_view.xml b/app/src/main/res/layout/terminal_toolbar_extra_keys_view.xml new file mode 100644 index 00000000..fbefe1ec --- /dev/null +++ b/app/src/main/res/layout/terminal_toolbar_extra_keys_view.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/terminal_toolbar_text_input_view.xml b/app/src/main/res/layout/terminal_toolbar_text_input_view.xml new file mode 100644 index 00000000..86b3ce92 --- /dev/null +++ b/app/src/main/res/layout/terminal_toolbar_text_input_view.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/layout/termux_activity.xml b/app/src/main/res/layout/termux_activity.xml new file mode 100644 index 00000000..2faa7d31 --- /dev/null +++ b/app/src/main/res/layout/termux_activity.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + +