diff --git a/.idea/compiler.xml b/.idea/compiler.xml index c0791d981..3a397d7f7 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -102,6 +102,7 @@ + diff --git a/actions/build.gradle.kts b/actions/build.gradle.kts index bbe93047d..03627edc0 100644 --- a/actions/build.gradle.kts +++ b/actions/build.gradle.kts @@ -22,6 +22,6 @@ dependencies { implementation(libs.common.utilcode) implementation(libs.google.auto.service.annotations) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.google.material) } \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 52d9a8ae5..824fd66b8 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,10 +27,6 @@ android { generateLocaleConfig = true } - compileOptions { - isCoreLibraryDesugaringEnabled = true - } - buildTypes { release { isShrinkResources = true } } packaging { @@ -104,7 +100,7 @@ dependencies { implementation(libs.google.oss.licenses) // Kotlin - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) // Local projects here @@ -112,7 +108,10 @@ dependencies { implementation(projects.buildInfo) implementation(projects.common) implementation(projects.editor) - implementation(projects.emulatorview) + implementation(projects.termux.termuxApp) + implementation(projects.termux.termuxView) + implementation(projects.termux.termuxEmulator) + implementation(projects.termux.termuxShared) implementation(projects.eventbus) implementation(projects.eventbusAndroid) implementation(projects.eventbusEvents) @@ -138,8 +137,6 @@ dependencies { implementation(projects.uidesigner) implementation(projects.xmlInflater) - coreLibraryDesugaring(libs.androidx.lib.desugaring) - // This is to build the tooling-api-impl project before the app is built // So we always copy the latest JAR file to assets compileOnly(projects.subprojects.toolingApiImpl) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b13ab7ac2..c894c8ba9 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,16 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - @@ -19,114 +128,6 @@ tools:ignore="ScopedStorage" /> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.java b/app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.java deleted file mode 100755 index 73fe8ca53..000000000 --- a/app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.java +++ /dev/null @@ -1,626 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.activities; - -import static androidx.core.view.WindowCompat.getInsetsController; -import static com.itsaky.androidide.preferences.internal.GeneralPreferencesKt.getUseSystemShell; -import static com.itsaky.androidide.utils.Environment.BIN_DIR; -import static com.itsaky.androidide.utils.Environment.HOME; -import static com.itsaky.androidide.utils.Environment.LOGIN_SHELL; -import static com.itsaky.androidide.utils.Environment.PREFIX; -import static com.itsaky.androidide.utils.Environment.getEnvironment; - -import android.os.Bundle; -import android.util.TypedValue; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.widget.Button; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - -import com.blankj.utilcode.util.ClipboardUtils; -import com.blankj.utilcode.util.KeyboardUtils; -import com.blankj.utilcode.util.ThrowableUtils; -import com.itsaky.androidide.R; -import com.itsaky.androidide.app.IDEActivity; -import com.itsaky.androidide.databinding.ActivityTerminalBinding; -import com.itsaky.androidide.fragments.CrashReportFragment; -import com.itsaky.androidide.fragments.sheets.ProgressSheet; -import com.itsaky.androidide.models.Constants; -import com.itsaky.androidide.preferences.internal.GeneralPreferencesKt; -import com.itsaky.androidide.ui.virtualkeys.SpecialButton; -import com.itsaky.androidide.ui.virtualkeys.VirtualKeyButton; -import com.itsaky.androidide.ui.virtualkeys.VirtualKeysConstants; -import com.itsaky.androidide.ui.virtualkeys.VirtualKeysInfo; -import com.itsaky.androidide.ui.virtualkeys.VirtualKeysView; -import com.itsaky.androidide.utils.BootstrapInstaller; -import com.itsaky.androidide.utils.ILogger; -import com.itsaky.androidide.utils.TypefaceUtilsKt; -import com.itsaky.terminal.TerminalEmulator; -import com.itsaky.terminal.TerminalSession; -import com.itsaky.terminal.TerminalSessionClient; -import com.itsaky.terminal.TextStyle; -import com.itsaky.terminal.view.TerminalView; -import com.itsaky.terminal.view.TerminalViewClient; - -import org.json.JSONException; - -import java.io.File; -import java.io.FileOutputStream; -import java.util.Map; - -public class TerminalActivity extends IDEActivity - implements TerminalViewClient, TerminalSessionClient { - - public static final String KEY_WORKING_DIRECTORY = "terminal_workingDirectory"; - private static final ILogger LOG = ILogger.newInstance("TerminalActivity"); - private static final byte[] SOURCES_LIST_CONTENT = - "deb https://packages.androidide.com/apt/termux-main/ stable main".getBytes(); - private static final String KEY_FONT_SIZE = "terminal_fontSize"; - private ActivityTerminalBinding binding; - private TerminalView terminal; - private TerminalSession session; - private boolean isVisible = false; - private KeyListener listener; - private int MIN_FONT_SIZE; - private int MAX_FONT_SIZE; - private int DEFAULT_FONT_SIZE; - - public boolean isVisible() { - return isVisible; - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent e) { - return false; - } - - @Override - public void onTextChanged(TerminalSession changedSession) { - terminal.onScreenUpdated(); - } - - @Override - public void onTitleChanged(TerminalSession changedSession) {} - - @Override - public void onSessionFinished(TerminalSession finishedSession) { - finish(); - } - - @Override - public void onCopyTextToClipboard(TerminalSession session, String text) { - ClipboardUtils.copyText("AndroidIDE Terminal", text); - } - - @Override - public void onPasteTextFromClipboard(TerminalSession session) { - String clip = ClipboardUtils.getText().toString(); - if (clip.trim().length() > 0 && terminal != null && terminal.mEmulator != null) { - terminal.mEmulator.paste(clip); - } - } - - @Override - public void onBell(TerminalSession session) {} - - @Override - public void onColorsChanged(TerminalSession session) {} - - @Override - public void onTerminalCursorStateChange(boolean state) {} - - @Override - public Integer getTerminalCursorStyle() { - return TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE; - } - - @Override - public float onScale(float scale) { - if (scale < 0.9f || scale > 1.1f) { - boolean increase = scale > 1.f; - changeFontSize(increase); - return 1.0f; - } - return scale; - } - - private void changeFontSize(final boolean increase) { - int fontSize = getFontSize(); - fontSize += (increase ? 1 : -1) * 2; - fontSize = Math.max(MIN_FONT_SIZE, Math.min(fontSize, MAX_FONT_SIZE)); - setFontSize(fontSize, true); - } - - public void setFontSize(int value, boolean apply) { - getApp().getPrefManager().putString(KEY_FONT_SIZE, String.valueOf(value)); - - if (apply) { - terminal.setTextSize(getFontSize()); - } - } - - public int getFontSize() { - int fontSize; - try { - fontSize = - Integer.parseInt( - getApp() - .getPrefManager() - .getString(KEY_FONT_SIZE, String.valueOf(DEFAULT_FONT_SIZE))); - } catch (NumberFormatException | ClassCastException e) { - fontSize = DEFAULT_FONT_SIZE; - } - - return Math.min(Math.max(fontSize, MIN_FONT_SIZE), MAX_FONT_SIZE); - } - - @Override - public void onSingleTapUp(MotionEvent e) { - showSoftInput(); - } - - @Override - public boolean shouldBackButtonBeMappedToEscape() { - return false; - } - - @Override - public boolean shouldEnforceCharBasedInput() { - return true; - } - - @Override - public boolean shouldUseCtrlSpaceWorkaround() { - return false; - } - - @Override - public boolean isTerminalViewSelected() { - return true; - } - - @Override - public void copyModeChanged(boolean copyMode) {} - - @Override - public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session) { - if (keyCode == KeyEvent.KEYCODE_ENTER && !session.isRunning()) { - finish(); - return true; - } - return false; - } - - @Override - public boolean onLongPress(MotionEvent event) { - return false; - } - - @Override - public boolean readControlKey() { - Boolean state = binding.virtualKeyTable.readSpecialButton(SpecialButton.CTRL, true); - return state != null && state; - } - - @Override - public boolean readAltKey() { - Boolean state = binding.virtualKeyTable.readSpecialButton(SpecialButton.ALT, true); - return state != null && state; - } - - @Override - public boolean readShiftKey() { - return false; - } - - @Override - public boolean readFnKey() { - return false; - } - - @Override - public boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session) { - return false; - } - - @Override - public void onEmulatorSet() { - setTerminalCursorBlinkingState(true); - - if (session != null) { - binding - .getRoot() - .setBackgroundColor( - session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); - } - } - - @Override - public void logError(String tag, String message) { - LOG.error(tag + ":", message); - } - - @Override - public void logWarn(String tag, String message) { - LOG.warn(tag + ":", message); - } - - @Override - public void logInfo(String tag, String message) { - LOG.info(tag + ":", message); - } - - @Override - public void logDebug(String tag, String message) { - LOG.debug(tag + ":", message); - } - - @Override - public void logVerbose(String tag, String message) { - LOG.verbose(tag + ":", message); - } - - @Override - public void logStackTraceWithMessage(String tag, String message, Exception e) { - LOG.error(tag + ":", message, e); - } - - @Override - public void logStackTrace(String tag, Exception e) { - LOG.error(tag + ":", e); - } - - private void setTerminalCursorBlinkingState(boolean start) { - if (terminal != null && terminal.mEmulator != null) { - terminal.setTerminalCursorBlinkerState(start, true); - } - } - - @Override - public int getNavigationBarColor() { - return ContextCompat.getColor(this, android.R.color.black); - } - - @Override - public int getStatusBarColor() { - return ContextCompat.getColor(this, android.R.color.black); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - final var controller = getInsetsController(getWindow(), getWindow().getDecorView()); - controller.setAppearanceLightNavigationBars(false); - controller.setAppearanceLightStatusBars(false); - super.onCreate(savedInstanceState); - final var bash = new File(BIN_DIR, "bash"); - final var useSystemShell = getUseSystemShell(); - if ((PREFIX.exists() - && PREFIX.isDirectory() - && bash.exists() - && bash.isFile() - && bash.canExecute()) - || useSystemShell) { - setupTerminalView(); - } else { - LOG.info("Bootstrap is not installed."); - - // Show the progress sheet - final var progress = new ProgressSheet(); - progress.setSubMessageEnabled(true); - progress.setMessage(getString(R.string.please_wait)); - progress.setSubMessage(getString(R.string.msg_reading_bootstrap)); - progress.setCancelable(false); - progress.show(getSupportFragmentManager(), "extract_bootstrap_progress"); - - LOG.debug("Starting installation..."); - // Install bootstrap asynchronously - final var future = - BootstrapInstaller.doInstall( - this, message -> runOnUiThread(() -> progress.setSubMessage(message))); - - future.whenComplete( - (voidResult, throwable) -> { - LOG.debug("Completable future has been complete.", throwable); - - runOnUiThread( - () -> { - progress.dismissAllowingStateLoss(); - - if (future.isCompletedExceptionally() || throwable != null) { - LOG.error("Future has been completed exceptionally."); - showInstallationError(throwable); - return; - } - - setupTerminalView(); - }); - }); - } - } - - @Override - protected View bindLayout() { - binding = ActivityTerminalBinding.inflate(getLayoutInflater()); - return binding.getRoot(); - } - - private void showInstallationError(Throwable throwable) { - - binding.virtualKeyTable.setVisibility(View.GONE); - - final var frag = - CrashReportFragment.newInstance( - getString(R.string.title_installation_failed), - getString(R.string.msg_bootstrap_installation_error), - ThrowableUtils.getFullStackTrace(throwable), - false); - - getSupportFragmentManager() - .beginTransaction() - .replace(binding.root.getId(), frag, "bootstrap_install_error_fragment") - .addToBackStack(null) - .commit(); - } - - private void setupTerminalView() { - setFontVariables(); - terminal = new TerminalView(this, null); - terminal.setTerminalViewClient(this); - terminal.attachSession(createSession(getWorkingDirectory())); - terminal.setKeepScreenOn(true); - terminal.setTextSize(getFontSize()); - terminal.setTypeface(TypefaceUtilsKt.customOrJBMono(true)); - - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-1, 0); - params.weight = 1f; - - binding.getRoot().addView(terminal, 0, params); - try { - binding.virtualKeyTable.setVirtualKeysViewClient(getKeyListener()); - binding.virtualKeyTable.reload( - new VirtualKeysInfo( - Constants.VIRTUAL_KEYS, "", VirtualKeysConstants.CONTROL_CHARS_ALIASES)); - } catch (JSONException e) { - LOG.error("Unable to parse terminal virtual keys json data", e); - } - } - - @NonNull - private String getWorkingDirectory() { - final Bundle extras = getIntent().getExtras(); - if (extras != null && extras.containsKey(KEY_WORKING_DIRECTORY)) { - String directory = extras.getString(KEY_WORKING_DIRECTORY, null); - if (directory == null || directory.trim().length() <= 0) { - directory = HOME.getAbsolutePath(); - } - return directory; - } - return HOME.getAbsolutePath(); - } - - private KeyListener getKeyListener() { - return listener == null ? listener = new KeyListener(terminal) : listener; - } - - private TerminalSession createSession(final String workingDirectory) { - final Map environment = getEnvironment(); - final String[] env = new String[environment.size()]; - int i = 0; - for (Map.Entry entry : environment.entrySet()) { - env[i] = entry.getKey() + "=" + entry.getValue(); - i++; - } - - session = - new TerminalSession( - getShellPath(), // Shell command - workingDirectory, // Working directory - new String[] {}, // Arguments - env, // Environment variables - TerminalEmulator.DEFAULT_TERMINAL_TRANSCRIPT_ROWS, // Transcript rows - this // TerminalSessionClient - ); - - try { - final var file = new File(PREFIX, "etc/apt/sources.list"); - final var out = new FileOutputStream(file); - out.write(SOURCES_LIST_CONTENT); - out.flush(); - out.close(); - } catch (Throwable th) { - LOG.error("Unable to update sources.list", th); - } - - return session; - } - - @NonNull - private String getShellPath() { - final var useSystemShell = GeneralPreferencesKt.getUseSystemShell(); - if (!useSystemShell && LOGIN_SHELL.exists() && LOGIN_SHELL.isFile()) { - return LOGIN_SHELL.getAbsolutePath(); - } - - if (!useSystemShell) { - LOG.error( - "Default shell does not exist. Falling back to '/system/bin/sh'.", - "This should not happen in normal circumstances."); - } - - return "/system/bin/sh"; - } - - public void setFontVariables() { - int[] sizes = getDefaultFontSizes(); - - DEFAULT_FONT_SIZE = sizes[0]; - MIN_FONT_SIZE = sizes[1]; - MAX_FONT_SIZE = sizes[2]; - } - - // https://github.com/termux/termux-app/blob/82b15803126138eef8899e0c7b582713f872cd09/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAppSharedPreferences.java - private int[] getDefaultFontSizes() { - float dipInPixels = - TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 1, getResources().getDisplayMetrics()); - - int[] sizes = new int[3]; - - // This is a bit arbitrary and sub-optimal. We want to give a sensible default for minimum font - // size - // to prevent invisible text due to zoom be mistake: - sizes[1] = (int) (4f * dipInPixels); // min - - // http://www.google.com/design/spec/style/typography.html#typography-line-height - int defaultFontSize = Math.round(9 * dipInPixels); - // Make it divisible by 2 since that is the minimal adjustment step: - if (defaultFontSize % 2 == 1) defaultFontSize--; - - sizes[0] = defaultFontSize; // default - - sizes[2] = 256; // max - - if (getApp() - .getPrefManager() - .getString(KEY_FONT_SIZE, "") - .equals("")) { - setFontSize(defaultFontSize, false); - } - - return sizes; - } - - @Override - protected void onResume() { - super.onResume(); - showSoftInput(); - setTerminalCursorBlinkingState(true); - } - - @Override - protected void onStart() { - super.onStart(); - isVisible = true; - } - - @Override - protected void onStop() { - super.onStop(); - isVisible = false; - setTerminalCursorBlinkingState(false); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - binding = null; - } - - private void showSoftInput() { - if (terminal != null) { - terminal.requestFocus(); - KeyboardUtils.showSoftInput(terminal); - } - } - - private static final class KeyListener implements VirtualKeysView.IVirtualKeysView { - - private final TerminalView terminal; - - public KeyListener(TerminalView terminal) { - this.terminal = terminal; - } - - @Override - public void onVirtualKeyButtonClick(View view, VirtualKeyButton buttonInfo, Button button) { - if (terminal == null) { - return; - } - if (buttonInfo.isMacro()) { - String[] keys = buttonInfo.getKey().split(" "); - boolean ctrlDown = false; - boolean altDown = false; - boolean shiftDown = false; - boolean fnDown = false; - for (String key : keys) { - if (SpecialButton.CTRL.getKey().equals(key)) { - ctrlDown = true; - } else if (SpecialButton.ALT.getKey().equals(key)) { - altDown = true; - } else if (SpecialButton.SHIFT.getKey().equals(key)) { - shiftDown = true; - } else if (SpecialButton.FN.getKey().equals(key)) { - fnDown = true; - } else { - onTerminalExtraKeyButtonClick(key, ctrlDown, altDown, shiftDown, fnDown); - ctrlDown = false; - altDown = false; - shiftDown = false; - fnDown = false; - } - } - } else { - onTerminalExtraKeyButtonClick(buttonInfo.getKey(), false, false, false, false); - } - } - - private void onTerminalExtraKeyButtonClick( - String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) { - if (VirtualKeysConstants.PRIMARY_KEY_CODES_FOR_STRINGS.containsKey(key)) { - Integer keyCode = VirtualKeysConstants.PRIMARY_KEY_CODES_FOR_STRINGS.get(key); - if (keyCode == null) { - return; - } - int metaState = 0; - if (ctrlDown) { - metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON; - } - if (altDown) { - metaState |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; - } - if (shiftDown) { - metaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON; - } - if (fnDown) { - metaState |= KeyEvent.META_FUNCTION_ON; - } - - KeyEvent keyEvent = new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState); - terminal.onKeyDown(keyCode, keyEvent); - } else { - // not a control char - for (int off = 0; off < key.length(); ) { - int codePoint = key.codePointAt(off); - terminal.inputCodePoint(codePoint, ctrlDown, altDown); - off += Character.charCount(codePoint); - } - } - } - - @Override - public boolean performVirtualKeyButtonHapticFeedback( - View view, VirtualKeyButton buttonInfo, Button button) { - // No need to handle this - // VirtualKeysView will take care of performing haptic feedback - return false; - } - } -} diff --git a/app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt new file mode 100755 index 000000000..476ff804a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt @@ -0,0 +1,69 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.activities + +import android.os.Bundle +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.ILogger +import com.termux.app.TermuxActivity +import java.io.File + +class TerminalActivity : TermuxActivity() { + + companion object { + + const val KEY_WORKING_DIRECTORY = "terminal_workingDirectory" + private val LOG = ILogger.newInstance("TerminalActivity") + private val SOURCES_LIST_CONTENT = "deb https://packages.androidide.com/apt/termux-main/ stable main".toByteArray() + } + + override val navigationBarColor: Int + get() = ContextCompat.getColor(this, android.R.color.black) + override val statusBarColor: Int + get() = ContextCompat.getColor(this, android.R.color.black) + + override fun onCreate(savedInstanceState: Bundle?) { + val controller = WindowCompat.getInsetsController( + window, window.decorView) + controller.isAppearanceLightNavigationBars = false + controller.isAppearanceLightStatusBars = false + super.onCreate(savedInstanceState) + } + + private val workingDirectory: String + get() { + val extras = intent.extras + if (extras != null && extras.containsKey(KEY_WORKING_DIRECTORY)) { + var directory = extras.getString(KEY_WORKING_DIRECTORY, null) + if (directory.isNullOrBlank()) { + directory = Environment.HOME.absolutePath + } + return directory + } + return Environment.HOME.absolutePath + } + + private fun writeSourcesList() { + try { + File(Environment.PREFIX, "etc/apt/sources.list").writeBytes(SOURCES_LIST_CONTENT) + } catch (th: Throwable) { + LOG.error("Unable to update sources.list", th) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEActivity.kt b/app/src/main/java/com/itsaky/androidide/app/IDEActivity.kt index 346bb87bf..89fe5707f 100644 --- a/app/src/main/java/com/itsaky/androidide/app/IDEActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEActivity.kt @@ -17,54 +17,8 @@ package com.itsaky.androidide.app -import android.os.Bundle -import androidx.appcompat.app.AppCompatDelegate -import androidx.appcompat.app.AppCompatDelegate.getDefaultNightMode -import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode -import androidx.core.os.LocaleListCompat -import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent -import com.itsaky.androidide.preferences.internal.SELECTED_LOCALE -import com.itsaky.androidide.preferences.internal.UI_MODE -import com.itsaky.androidide.preferences.internal.uiMode -import com.itsaky.androidide.ui.themes.ThemeManager -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode.MAIN -import java.util.Locale - abstract class IDEActivity : BaseIDEActivity() { val app: IDEApplication get() = application as IDEApplication - - override fun onStart() { - super.onStart() - EventBus.getDefault().register(this) - } - - override fun onStop() { - super.onStop() - EventBus.getDefault().unregister(this) - } - - override fun onCreate(savedInstanceState: Bundle?) { - ThemeManager.applyTheme(this) - super.onCreate(savedInstanceState) - } - - @Subscribe(threadMode = MAIN) - fun onPrefChanged(event: PreferenceChangeEvent) { - if (event.key == UI_MODE && uiMode != getDefaultNightMode()) { - setDefaultNightMode(uiMode) - } else if (event.key == SELECTED_LOCALE) { - - // Use empty locale list if the locale has been reset to 'System Default' - val selectedLocale = com.itsaky.androidide.preferences.internal.selectedLocale - val localeListCompat = selectedLocale?.let { - LocaleListCompat.create(Locale.forLanguageTag(selectedLocale)) - } ?: LocaleListCompat.getEmptyLocaleList() - - AppCompatDelegate.setApplicationLocales(localeListCompat) - } - } } diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEActivityDelegate.kt b/app/src/main/java/com/itsaky/androidide/app/IDEActivityDelegate.kt new file mode 100644 index 000000000..d108fddaf --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/app/IDEActivityDelegate.kt @@ -0,0 +1,28 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.app + +import androidx.lifecycle.DefaultLifecycleObserver + +/** + * Delegate for [IDEActivity]. + * + * @author Akash Yadav + */ +interface IDEActivityDelegate : DefaultLifecycleObserver { +} \ No newline at end of file diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index dea6b4a84..ce7c76757 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.net.Uri import android.os.StrictMode import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat import androidx.lifecycle.Observer import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy @@ -44,7 +45,9 @@ import com.itsaky.androidide.events.LspJavaEventsIndex import com.itsaky.androidide.events.ProjectsApiEventsIndex import com.itsaky.androidide.preferences.KEY_DEVOPTS_DEBUGGING_DUMPLOGS import com.itsaky.androidide.preferences.dumpLogs +import com.itsaky.androidide.preferences.internal.SELECTED_LOCALE import com.itsaky.androidide.preferences.internal.STAT_OPT_IN +import com.itsaky.androidide.preferences.internal.UI_MODE import com.itsaky.androidide.preferences.internal.statOptIn import com.itsaky.androidide.preferences.internal.uiMode import com.itsaky.androidide.stats.AndroidIDEStats @@ -53,20 +56,23 @@ import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.tasks.executeAsync import com.itsaky.androidide.treesitter.TreeSitter import com.itsaky.androidide.ui.themes.IDETheme +import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.ui.themes.ThemeManager import com.itsaky.androidide.utils.ILogger import com.itsaky.androidide.utils.RecyclableObjectPool import com.itsaky.androidide.utils.VMUtils import com.itsaky.androidide.utils.flashError +import com.termux.app.TermuxApplication import io.github.rosemoe.sora.widget.schemes.EditorColorScheme import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.lang.Thread.UncaughtExceptionHandler import java.time.Duration +import java.util.Locale import kotlin.system.exitProcess -class IDEApplication : BaseApplication() { +class IDEApplication : TermuxApplication() { private var uncaughtExceptionHandler: UncaughtExceptionHandler? = null private var ideLogcatReader: IDELogcatReader? = null @@ -109,7 +115,7 @@ class IDEApplication : BaseApplication() { AppCompatDelegate.setDefaultNightMode(uiMode) - if (ThemeManager.getCurrentTheme() == IDETheme.MATERIAL_YOU) { + if (IThemeManager.getInstance().getCurrentTheme() == IDETheme.MATERIAL_YOU) { DynamicColors.applyToActivitiesIfAvailable(this) } @@ -214,6 +220,17 @@ class IDEApplication : BaseApplication() { } else { stopLogcatReader() } + } else if (event.key == UI_MODE && uiMode != AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.setDefaultNightMode(uiMode) + } else if (event.key == SELECTED_LOCALE) { + + // Use empty locale list if the locale has been reset to 'System Default' + val selectedLocale = com.itsaky.androidide.preferences.internal.selectedLocale + val localeListCompat = selectedLocale?.let { + LocaleListCompat.create(Locale.forLanguageTag(selectedLocale)) + } ?: LocaleListCompat.getEmptyLocaleList() + + AppCompatDelegate.setApplicationLocales(localeListCompat) } } diff --git a/app/src/main/java/com/itsaky/androidide/preferences/general.kt b/app/src/main/java/com/itsaky/androidide/preferences/general.kt index 89ffa7dd9..e0ae8860e 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/general.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/general.kt @@ -37,6 +37,7 @@ import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.resources.localization.LocaleProvider import com.itsaky.androidide.ui.themes.IDETheme +import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.ui.themes.ThemeManager import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -148,7 +149,7 @@ class ThemeSelector( } override fun getInitiallySelectionItemPosition(context: Context): Int { - return themes.indexOf(ThemeManager.getCurrentTheme()) + return themes.indexOf(IThemeManager.getInstance().getCurrentTheme()) } override fun onChoiceConfirmed(position: Int) { diff --git a/app/src/main/java/com/itsaky/androidide/ui/themes/ThemeManager.kt b/app/src/main/java/com/itsaky/androidide/ui/themes/ThemeManager.kt index 3309db9bd..5bb9847e8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/themes/ThemeManager.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/themes/ThemeManager.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.ui.themes import android.app.Activity +import com.google.auto.service.AutoService import com.itsaky.androidide.preferences.internal.selectedTheme import com.itsaky.androidide.utils.isSystemInDarkMode @@ -26,12 +27,14 @@ import com.itsaky.androidide.utils.isSystemInDarkMode * * @author Akash Yadav */ -object ThemeManager { +@Suppress("unused") +@AutoService(IThemeManager::class) +class ThemeManager : IThemeManager { /** * Apply the current theme to the given activity. Does nothing if theme is set to [Material You][IDETheme.MATERIAL_YOU]. */ - fun applyTheme(activity: Activity) { + override fun applyTheme(activity: Activity) { val theme = getCurrentTheme() if (theme == IDETheme.MATERIAL_YOU) { @@ -51,7 +54,7 @@ object ThemeManager { /** * Get the currently selected theme. */ - fun getCurrentTheme(): IDETheme { + override fun getCurrentTheme(): IDETheme { return selectedTheme?.let { IDETheme.valueOf(it) } ?: IDETheme.DEFAULT } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_terminal.xml b/app/src/main/res/layout/activity_terminal.xml deleted file mode 100755 index d8953c5d1..000000000 --- a/app/src/main/res/layout/activity_terminal.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/app/src/x86_64/assets/data/x86_64/aapt2 b/app/src/x86_64/assets/data/x86_64/aapt2 new file mode 100755 index 000000000..780b8ff5c Binary files /dev/null and b/app/src/x86_64/assets/data/x86_64/aapt2 differ diff --git a/app/src/x86_64/assets/data/x86_64/bootstrap.zip b/app/src/x86_64/assets/data/x86_64/bootstrap.zip new file mode 100644 index 000000000..35d453411 Binary files /dev/null and b/app/src/x86_64/assets/data/x86_64/bootstrap.zip differ diff --git a/build-logic/ide/build.gradle.kts b/build-logic/ide/build.gradle.kts index c0703db0b..84029bba5 100644 --- a/build-logic/ide/build.gradle.kts +++ b/build-logic/ide/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(gradleApi()) implementation("com.android.tools.build:gradle:${libs.versions.agp.asProvider().get()}") + implementation(libs.maven.publish) implementation(libs.common.jkotlin) implementation(libs.common.antlr4) diff --git a/build-logic/ide/src/main/java/BuildConfig.kt b/build-logic/ide/src/main/java/BuildConfig.kt index 48966afde..16b7775e4 100644 --- a/build-logic/ide/src/main/java/BuildConfig.kt +++ b/build-logic/ide/src/main/java/BuildConfig.kt @@ -36,6 +36,8 @@ object BuildConfig { /** The target SDK version. */ const val targetSdk = 28 + const val ndkVersion = "26.1.10909125" + /** The source and target Java compatibility. */ val javaVersion = JavaVersion.VERSION_11 } diff --git a/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt b/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt new file mode 100644 index 000000000..d0dbf6e30 --- /dev/null +++ b/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/AndroidModuleConf.kt @@ -0,0 +1,109 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.plugins.conf + +import BuildConfig +import com.android.build.gradle.BaseExtension +import org.gradle.api.Project +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.VersionCatalogsExtension +import projectVersionCode + +private val flavorsAbis = arrayOf("arm64-v8a", "armeabi-v7a") + +fun Project.configureAndroidModule( + coreLibDesugDep: Dependency +) { + assert( + plugins.hasPlugin("com.android.application") || plugins.hasPlugin("com.android.library")) { + "${javaClass.simpleName} can only be applied to Android projects" + } + + extensions.getByType(BaseExtension::class.java).run { + compileSdkVersion(BuildConfig.compileSdk) + + defaultConfig { + minSdk = BuildConfig.minSdk + targetSdk = BuildConfig.targetSdk + versionCode = projectVersionCode + versionName = rootProject.version.toString() + + // required + multiDexEnabled = true + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = BuildConfig.javaVersion + targetCompatibility = BuildConfig.javaVersion + + isCoreLibraryDesugaringEnabled = true + } + + if (":app" == project.path) { + flavorDimensions("default") + + productFlavors { + flavorsAbis.forEach(this::create) + + forEach { + val name = it.name + defaultConfig.buildConfigField("String", + "FLAVOR_${name.replace('-', '_').uppercase()}", + "\"${name}\"") + } + } + } + + // configure split APKs for ':app' module only + if (this@configureAndroidModule == rootProject.findProject(":app")) { + splits { + abi { + reset() + + isEnable = true + isUniversalApk = false + + // TODO: Find a way to enable split APKs in product flavors. If this is possible, we can configure + // each flavor to include only a single ABI. For example, for the 'arm64-v8a' flavor, + // we can configure it to generate APK only for 'arm64-v8a'. + // + // See the contribution guidelines for more information. + @Suppress("ChromeOsAbiSupport") + include(*flavorsAbis) + } + } + } + + buildTypes.getByName("debug") { isMinifyEnabled = false } + buildTypes.getByName("release") { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + + testOptions { unitTests.isIncludeAndroidResources = true } + + buildFeatures.viewBinding = true + buildFeatures.buildConfig = true + + configurations.getByName("coreLibraryDesugaring").apply { + dependencies.add(coreLibDesugDep) + } + } +} \ No newline at end of file diff --git a/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/JavaModuleConf.kt b/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/JavaModuleConf.kt new file mode 100644 index 000000000..94ee12b27 --- /dev/null +++ b/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/JavaModuleConf.kt @@ -0,0 +1,34 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.plugins.conf + +import BuildConfig +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.configure + +fun Project.configureJavaModule() { + assert(plugins.hasPlugin("java-library")) { + "${javaClass.simpleName} can only be applied to Java library projects." + } + + configure { + sourceCompatibility = BuildConfig.javaVersion + targetCompatibility = BuildConfig.javaVersion + } +} \ No newline at end of file diff --git a/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/MavenPublishConf.kt b/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/MavenPublishConf.kt new file mode 100644 index 000000000..f855d47a8 --- /dev/null +++ b/build-logic/ide/src/main/java/com/itsaky/androidide/plugins/conf/MavenPublishConf.kt @@ -0,0 +1,144 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.plugins.conf + +import ProjectConfig +import com.vanniktech.maven.publish.AndroidMultiVariantLibrary +import com.vanniktech.maven.publish.GradlePlugin +import com.vanniktech.maven.publish.JavaLibrary +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SonatypeHost.Companion.S01 +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.tasks.Delete +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.withType +import publishingVersion +import java.io.File + +private val projectsRequiringMavenLocalForTests = arrayOf(":gradle-plugin") +private val mavenLocalRepos = hashMapOf() + +@Suppress("UnstableApiUsage") +fun Project.configureMavenPublish() { + assert(plugins.hasPlugin("com.vanniktech.maven.publish.base")) { + "${javaClass.simpleName} can only be applied to maven publish projects." + } + + gradle.projectsEvaluated { + rootProject.subprojects { + if (project.path in projectsRequiringMavenLocalForTests) { + tasks.withType { + for ((project, _) in mavenLocalRepos) { + dependsOn( + project(project).tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository")) + } + } + } + } + } + + afterEvaluate { + if (project.description.isNullOrBlank()) { + throw GradleException("Project ${project.path} must have a description") + } + } + + configure { + + project.configureMavenLocal() + + pom { + name.set(project.name) + description.set(project.description) + inceptionYear.set("2021") + url.set(ProjectConfig.REPO_URL) + licenses { + license { + name.set("The GNU General Public License, v3.0") + url.set("https://www.gnu.org/licenses/gpl-3.0.en.html") + distribution.set("https://www.gnu.org/licenses/gpl-3.0.en.html") + } + } + + developers { + developer { + id.set("androidide") + name.set("AndroidIDE") + url.set(ProjectConfig.PROJECT_SITE) + } + } + + scm { + url.set(ProjectConfig.REPO_URL) + connection.set(ProjectConfig.SCM_GIT) + developerConnection.set(ProjectConfig.SCM_SSH) + } + } + + coordinates(project.group.toString(), project.name, project.publishingVersion) + publishToMavenCentral(host = S01) + signAllPublications() + + if (plugins.hasPlugin("com.android.library")) { + configure(AndroidMultiVariantLibrary()) + } else if (plugins.hasPlugin("java-gradle-plugin")) { + configure(GradlePlugin(javadocJar = JavadocJar.Javadoc())) + } else if (plugins.hasPlugin("java-library")) { + configure(JavaLibrary(javadocJar = JavadocJar.Javadoc())) + } + } +} + +private fun Project.configureMavenLocal() { + val mavenLocalPath = layout.buildDirectory.dir("maven-local") + mavenLocalRepos[project.path] = mavenLocalPath.get().asFile.absolutePath + + extensions.findByType(PublishingExtension::class.java)?.run { + repositories { + maven { + name = "buildMavenLocal" + url = uri(mavenLocalPath) + } + } + } + + tasks.create("deleteBuildMavenLocal") { + delete(mavenLocalPath) + } + + if (project.path in projectsRequiringMavenLocalForTests) { + tasks.withType { + dependsOn(tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository")) + doFirst { + val file = mavenLocalPath.get().file("repos.txt").asFile + file.writeText(mavenLocalRepos.values.joinToString(separator = File.pathSeparator)) + } + } + } + + afterEvaluate { + tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository") { + dependsOn(tasks.getByName("deleteBuildMavenLocal")) + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index aae480ab4..2d0493142 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,15 +17,11 @@ @file:Suppress("UnstableApiUsage") -import com.android.build.gradle.BaseExtension import com.itsaky.androidide.plugins.AndroidIDEPlugin -import com.vanniktech.maven.publish.AndroidMultiVariantLibrary -import com.vanniktech.maven.publish.GradlePlugin -import com.vanniktech.maven.publish.JavaLibrary -import com.vanniktech.maven.publish.JavadocJar -import com.vanniktech.maven.publish.MavenPublishBaseExtension -import com.vanniktech.maven.publish.SonatypeHost.Companion.S01 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import com.itsaky.androidide.plugins.conf.configureAndroidModule +import com.itsaky.androidide.plugins.conf.configureJavaModule +import com.itsaky.androidide.plugins.conf.configureMavenPublish @Suppress("DSL_SCOPE_VIOLATION") plugins { @@ -45,74 +41,6 @@ buildscript { } } -val flavorsAbis = arrayOf("arm64-v8a", "armeabi-v7a") - -fun Project.configureBaseExtension() { - extensions.findByType(BaseExtension::class)?.run { - compileSdkVersion(BuildConfig.compileSdk) - - defaultConfig { - minSdk = BuildConfig.minSdk - targetSdk = BuildConfig.targetSdk - versionCode = projectVersionCode - versionName = rootProject.version.toString() - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = BuildConfig.javaVersion - targetCompatibility = BuildConfig.javaVersion - } - - if (":app" == project.path) { - flavorDimensions("default") - - productFlavors { - flavorsAbis.forEach(this::create) - - forEach { - val name = it.name - defaultConfig.buildConfigField("String", - "FLAVOR_${name.replace('-', '_').uppercase()}", - "\"${name}\"") - } - } - } - - // configure split APKs for ':app' module only - if (this@configureBaseExtension == rootProject.findProject(":app")) { - splits { - abi { - reset() - - isEnable = true - isUniversalApk = false - - // TODO: Find a way to enable split APKs in product flavors. If this is possible, we can configure - // each flavor to include only a single ABI. For example, for the 'arm64-v8a' flavor, - // we can configure it to generate APK only for 'arm64-v8a'. - // - // See the contribution guidelines for more information. - @Suppress("ChromeOsAbiSupport") - include(*flavorsAbis) - } - } - } - - buildTypes.getByName("debug") { isMinifyEnabled = false } - buildTypes.getByName("release") { - isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - - testOptions { unitTests.isIncludeAndroidResources = true } - - buildFeatures.viewBinding = true - buildFeatures.buildConfig = true - } -} - subprojects { afterEvaluate { apply { plugin(AndroidIDEPlugin::class.java) } @@ -120,70 +48,11 @@ subprojects { project.group = BuildConfig.packageName project.version = rootProject.version - plugins.withId("com.android.application") { configureBaseExtension() } - plugins.withId("com.android.library") { configureBaseExtension() } - plugins.withId("java-library") { - configure { - sourceCompatibility = BuildConfig.javaVersion - targetCompatibility = BuildConfig.javaVersion - } - } - - project.afterEvaluate { - if (project.plugins.hasPlugin( - "com.vanniktech.maven.publish.base") && project.description.isNullOrBlank() - ) { - throw GradleException("Project ${project.path} must have a description") - } - } - - plugins.withId("com.vanniktech.maven.publish.base") { - configure { - - project.configureMavenLocal() - - pom { - name.set(project.name) - description.set(project.description) - inceptionYear.set("2021") - url.set(ProjectConfig.REPO_URL) - licenses { - license { - name.set("The GNU General Public License, v3.0") - url.set("https://www.gnu.org/licenses/gpl-3.0.en.html") - distribution.set("https://www.gnu.org/licenses/gpl-3.0.en.html") - } - } - - developers { - developer { - id.set("androidide") - name.set("AndroidIDE") - url.set(ProjectConfig.PROJECT_SITE) - } - } - - scm { - url.set(ProjectConfig.REPO_URL) - connection.set(ProjectConfig.SCM_GIT) - developerConnection.set(ProjectConfig.SCM_SSH) - } - } - - coordinates(project.group.toString(), project.name, project.publishingVersion) - publishToMavenCentral(host = S01) - signAllPublications() - - if (plugins.hasPlugin("com.android.library")) { - configure(AndroidMultiVariantLibrary()) - } else if (plugins.hasPlugin("java-gradle-plugin")) { - configure(GradlePlugin(javadocJar = JavadocJar.Javadoc())) - } else if (plugins.hasPlugin("java-library")) { - configure(JavaLibrary(javadocJar = JavadocJar.Javadoc())) - } - } - } + plugins.withId("com.android.application") { configureAndroidModule(libs.androidx.lib.desugaring.get()) } + plugins.withId("com.android.library") { configureAndroidModule(libs.androidx.lib.desugaring.get()) } + plugins.withId("java-library") { configureJavaModule() } + plugins.withId("com.vanniktech.maven.publish.base") { configureMavenPublish() } plugins.withId("com.gradle.plugin-publish") { configure { @@ -194,55 +63,6 @@ subprojects { tasks.withType().configureEach { kotlinOptions.jvmTarget = BuildConfig.javaVersion.toString() } - - gradle.projectsEvaluated { - rootProject.subprojects { - if (project.path in projectsRequiringMavenLocalForTests) { - tasks.withType { - for ((project, _) in mavenLocalRepos) { - dependsOn(project(project).tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository")) - } - } - } - } - } -} - -val projectsRequiringMavenLocalForTests = arrayOf(":gradle-plugin") -val mavenLocalRepos = hashMapOf() - -fun Project.configureMavenLocal() { - val mavenLocalPath = layout.buildDirectory.dir("maven-local") - mavenLocalRepos[project.path] = mavenLocalPath.get().asFile.absolutePath - - extensions.findByType(PublishingExtension::class.java)?.run { - repositories { - maven { - name = "buildMavenLocal" - url = uri(mavenLocalPath) - } - } - } - - tasks.create("deleteBuildMavenLocal") { - delete(mavenLocalPath) - } - - if (project.path in projectsRequiringMavenLocalForTests) { - tasks.withType { - dependsOn(tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository")) - doFirst { - val file = mavenLocalPath.get().file("repos.txt").asFile - file.writeText(mavenLocalRepos.values.joinToString(separator = File.pathSeparator)) - } - } - } - - afterEvaluate { - tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository") { - dependsOn(tasks.getByName("deleteBuildMavenLocal")) - } - } } tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 5de4dee93..72b97b462 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -20,18 +20,20 @@ dependencies { api(libs.androidx.vectors) api(libs.androidx.animated.vectors) - api(libs.androidx.ktx) + api(libs.androidx.core.ktx) api(libs.common.kotlin) api(projects.buildInfo) + api(projects.eventbusAndroid) api(projects.eventbusEvents) api(projects.lexers) api(projects.resources) api(projects.shared) api(projects.logger) + api(projects.resources) api(projects.subprojects.flashbar) - + testImplementation(libs.tests.junit) testImplementation(libs.tests.google.truth) testImplementation(libs.tests.robolectric) diff --git a/common/src/main/java/com/itsaky/androidide/app/BaseIDEActivity.kt b/common/src/main/java/com/itsaky/androidide/app/BaseIDEActivity.kt index 736eec3f7..53d682d20 100755 --- a/common/src/main/java/com/itsaky/androidide/app/BaseIDEActivity.kt +++ b/common/src/main/java/com/itsaky/androidide/app/BaseIDEActivity.kt @@ -26,10 +26,12 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import com.itsaky.androidide.common.R import com.itsaky.androidide.tasks.cancelIfActive +import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.ILogger import com.itsaky.androidide.utils.resolveAttr import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import org.greenrobot.eventbus.EventBus abstract class BaseIDEActivity : AppCompatActivity() { @@ -56,6 +58,7 @@ abstract class BaseIDEActivity : AppCompatActivity() { navigationBarColor = this@BaseIDEActivity.navigationBarColor statusBarColor = this@BaseIDEActivity.statusBarColor } + IThemeManager.getInstance().applyTheme(this) super.onCreate(savedInstanceState) preSetContentLayout() setContentView(bindLayout()) @@ -75,6 +78,24 @@ abstract class BaseIDEActivity : AppCompatActivity() { activityScope.cancelIfActive("Activity is being destroyed") } + override fun onStart() { + super.onStart() + EventBus.getDefault().apply { + if (!isRegistered(this@BaseIDEActivity)) { + register(this) + } + } + } + + override fun onStop() { + super.onStop() + EventBus.getDefault().apply { + if (isRegistered(this@BaseIDEActivity)) { + unregister(this) + } + } + } + fun loadFragment(fragment: Fragment, id: Int) { val transaction = supportFragmentManager.beginTransaction() transaction.replace(id, fragment) diff --git a/app/src/main/java/com/itsaky/androidide/ui/themes/IDETheme.kt b/common/src/main/java/com/itsaky/androidide/ui/themes/IDETheme.kt similarity index 97% rename from app/src/main/java/com/itsaky/androidide/ui/themes/IDETheme.kt rename to common/src/main/java/com/itsaky/androidide/ui/themes/IDETheme.kt index 8360c27a8..7fdd7e14b 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/themes/IDETheme.kt +++ b/common/src/main/java/com/itsaky/androidide/ui/themes/IDETheme.kt @@ -19,7 +19,7 @@ package com.itsaky.androidide.ui.themes import androidx.annotation.StringRes import androidx.annotation.StyleRes -import com.itsaky.androidide.R +import com.itsaky.androidide.resources.R /** * Themes in AndroidIDE. diff --git a/common/src/main/java/com/itsaky/androidide/ui/themes/IThemeManager.kt b/common/src/main/java/com/itsaky/androidide/ui/themes/IThemeManager.kt new file mode 100644 index 000000000..936eae178 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/ui/themes/IThemeManager.kt @@ -0,0 +1,50 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui.themes + +import android.app.Activity +import com.itsaky.androidide.utils.ServiceLoader + +/** + * Theme manager for AndroidIDE. + * + * @author Akash Yadav + */ +interface IThemeManager { + + companion object { + private object Provider { + val instance by lazy { ServiceLoader.load(IThemeManager::class.java).findFirstOrThrow() } + } + + /** + * Get the [IThemeManager] instance. + */ + fun getInstance() : IThemeManager = Provider.instance + } + + /** + * Apply current theme to [activity]. + */ + fun applyTheme(activity: Activity) + + /** + * Get the current IDE theme. + */ + fun getCurrentTheme(): IDETheme +} \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/window/ActivityCompatHelper.kt b/common/src/main/java/com/itsaky/androidide/window/ActivityCompatHelper.kt new file mode 100644 index 000000000..0a581608e --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/ActivityCompatHelper.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.itsaky.androidide.window + +import android.annotation.SuppressLint +import android.app.Activity +import android.graphics.Rect +import android.os.Build +import androidx.annotation.RequiresApi + +@SuppressLint("ObsoleteSdkInt") +@RequiresApi(Build.VERSION_CODES.N) +internal object ActivityCompatHelperApi24 { + + fun isInMultiWindowMode(activity: Activity): Boolean { + return activity.isInMultiWindowMode + } +} + +@RequiresApi(Build.VERSION_CODES.R) +internal object ActivityCompatHelperApi30 { + + fun currentWindowBounds(activity: Activity): Rect { + return activity.windowManager.currentWindowMetrics.bounds + } + + fun maximumWindowBounds(activity: Activity): Rect { + return activity.windowManager.maximumWindowMetrics.bounds + } +} \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/window/Bounds.kt b/common/src/main/java/com/itsaky/androidide/window/Bounds.kt new file mode 100644 index 000000000..8d423eae7 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/Bounds.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.itsaky.androidide.window + +import android.graphics.Rect + +/** + * A wrapper for [Rect] to handle compatibility issues with API 15. In API 15, equals and + * hashCode operate on the reference as opposed to the attributes. This leads to test failures + * because the data matches but the equals check fails. + * + * Also useful in unit tests since you can instantiate [Bounds] in a JVM test but when you + * instantiate [Rect] you are using the class from the mockable jar file. The mockable jar does + * not contain any behavior or calculations. + */ +internal class Bounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int +) { + + constructor(rect: Rect) : this(rect.left, rect.top, rect.right, rect.bottom) + + /** + * Return the [Rect] representation of the bounds + */ + fun toRect(): Rect = Rect(left, top, right, bottom) + + /** + * The width of the bounds, may be negative. + */ + val width: Int + get() = right - left + + /** + * The height of the bounds, may be negative. + */ + val height: Int + get() = bottom - top + + /** + * Determines if the bounds has empty area. + */ + val isEmpty: Boolean + get() = height == 0 || width == 0 + + override fun toString(): String { + return "${Bounds::class.java.simpleName} { [$left,$top,$right,$bottom] }" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Bounds + + if (left != other.left) return false + if (top != other.top) return false + if (right != other.right) return false + if (bottom != other.bottom) return false + + return true + } + + override fun hashCode(): Int { + var result = left + result = 31 * result + top + result = 31 * result + right + result = 31 * result + bottom + return result + } +} \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/window/DisplayCompatHelper.kt b/common/src/main/java/com/itsaky/androidide/window/DisplayCompatHelper.kt new file mode 100644 index 000000000..4f21a37ee --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/DisplayCompatHelper.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.itsaky.androidide.window + +import android.annotation.SuppressLint +import android.graphics.Point +import android.os.Build +import android.view.Display +import android.view.DisplayCutout +import androidx.annotation.RequiresApi + +@SuppressLint("ObsoleteSdkInt") +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +internal object DisplayCompatHelperApi17 { + + @Suppress("DEPRECATION") + fun getRealSize(display: Display, point: Point) { + display.getRealSize(point) + } +} + +@RequiresApi(Build.VERSION_CODES.P) +internal object DisplayCompatHelperApi28 { + + fun safeInsetLeft(displayCutout: DisplayCutout): Int { + return displayCutout.safeInsetLeft + } + + fun safeInsetTop(displayCutout: DisplayCutout): Int { + return displayCutout.safeInsetTop + } + + fun safeInsetRight(displayCutout: DisplayCutout): Int { + return displayCutout.safeInsetRight + } + + fun safeInsetBottom(displayCutout: DisplayCutout): Int { + return displayCutout.safeInsetBottom + } +} \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/window/WindowManager.kt b/common/src/main/java/com/itsaky/androidide/window/WindowManager.kt new file mode 100644 index 000000000..b53826d9b --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/WindowManager.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.itsaky.androidide.window + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +/** + * Main interaction point with the WindowManager library. An instance of this class allows + * polling the current state of the device and display, and registering callbacks for changes in + * the corresponding states. + * + * All methods of this class will return information that is associated with this visual + * context. + * + */ +class WindowManager( + /** + * A visual [Context], such as an [Activity] or a [ContextWrapper] + */ + context: Context +) { + + /** + * A class to calculate the bounds of a [android.view.Window] across different versions of + * Android. + */ + internal var windowMetricsCalculator: WindowMetricsCalculator = WindowMetricsCalculatorCompat + + /** + * Activity that was registered with this instance of [WindowManager] at creation. + * This is used to find the token identifier of the window when requesting layout information + * from the [com.itsaky.androidide.window.sidecar.SidecarInterface] or is passed directly to the + * [com.itsaky.androidide.window.extensions.ExtensionInterface]. + */ + private val activity: Activity = getActivityFromContext(context) + ?: throw IllegalArgumentException( + "Used non-visual Context to obtain an instance of WindowManager. Please use an " + + "Activity or a ContextWrapper around one instead." + ) + + /** + * Returns the [WindowMetrics] according to the current system state. + * + * + * The metrics describe the size of the area the window would occupy with + * [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height + * and any combination of flags that would allow the window to extend behind display cutouts. + * + * + * The value of this is based on the **current** windowing state of the system. For + * example, for activities in multi-window mode, the metrics returned are based on the + * current bounds that the user has selected for the [Activity][android.app.Activity]'s + * window. + * + * @see getMaximumWindowMetrics + * @see android.view.WindowManager.getCurrentWindowMetrics + */ + fun getCurrentWindowMetrics(): WindowMetrics { + return windowMetricsCalculator.computeCurrentWindowMetrics(activity) + } + + /** + * Returns the largest [WindowMetrics] an app may expect in the current system state. + * + * + * The metrics describe the size of the largest potential area the window might occupy with + * [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height + * and any combination of flags that would allow the window to extend behind display cutouts. + * + * + * The value of this is based on the largest **potential** windowing state of the system. + * For example, for activities in multi-window mode the metrics returned are based on what the + * bounds would be if the user expanded the window to cover the entire screen. + * + * + * Note that this might still be smaller than the size of the physical display if certain + * areas of the display are not available to windows created for the associated [Context]. + * For example, devices with foldable displays that wrap around the enclosure may split the + * physical display into different regions, one for the front and one for the back, each acting + * as different logical displays. In this case [.getMaximumWindowMetrics] would return + * the region describing the side of the device the associated [context's][Context] + * window is placed. + * + * @see getCurrentWindowMetrics + * @see android.view.WindowManager.getMaximumWindowMetrics + */ + fun getMaximumWindowMetrics(): WindowMetrics { + return windowMetricsCalculator.computeMaximumWindowMetrics(activity) + } + + internal companion object { + + /** + * Unwraps the hierarchy of [ContextWrapper]-s until [Activity] is reached. + * + * @return Base [Activity] context or `null` if not available. + */ + fun getActivityFromContext(initialContext: Context): Activity? { + var context: Context? = initialContext + while (context is ContextWrapper) { + if (context is Activity) { + return context + } + context = context.baseContext + } + return null + } + } +} diff --git a/common/src/main/java/com/itsaky/androidide/window/WindowMetrics.kt b/common/src/main/java/com/itsaky/androidide/window/WindowMetrics.kt new file mode 100644 index 000000000..01355a189 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/WindowMetrics.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.itsaky.androidide.window + +import android.graphics.Rect + +/** + * Metrics about a [android.view.Window], consisting of its bounds. + * + * + * This is usually obtained from [WindowManager#getCurrentWindowMetrics] or + * [WindowManager#getMaximumWindowMetrics]. + * + * @see WindowManager#getCurrentWindowMetrics + * @see WindowManager#getMaximumWindowMetrics + */ +class WindowMetrics internal constructor(private val _bounds: Bounds) { + + constructor(bounds: Rect) : this(Bounds(bounds)) + + /** + * Returns a new [Rect] describing the bounds of the area the window occupies. + * + * + * **Note that the size of the reported bounds can have different size than + * [Display#getSize].** This method reports the window size including all system + * decorations, while [Display#getSize] reports the area excluding navigation bars + * and display cutout areas. + * + * @return window bounds in pixels. + */ + val bounds: Rect + get() = _bounds.toRect() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as WindowMetrics + return _bounds == that._bounds + } + + override fun hashCode(): Int { + return _bounds.hashCode() + } +} \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/window/WindowMetricsCalculator.kt b/common/src/main/java/com/itsaky/androidide/window/WindowMetricsCalculator.kt new file mode 100644 index 000000000..914943b49 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/WindowMetricsCalculator.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.itsaky.androidide.window + +import android.app.Activity +import android.os.Build +import android.view.Display + +/** + * An interface to calculate the [WindowMetrics] for an [Activity]. + */ +internal interface WindowMetricsCalculator { + + /** + * Computes the size and position of the area the window would occupy with + * [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height + * and any combination of flags that would allow the window to extend behind display cutouts. + * + * For example, [android.view.WindowManager.LayoutParams.layoutInDisplayCutoutMode] set to + * [android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS] or the + * [android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS] flag set. + * + * The value returned from this method may be different from platform API(s) used to determine + * the size and position of the visible area a given context occupies. For example: + * + * * [Display.getSize] can be used to determine the size of the visible area + * a window occupies, but may be subtracted to exclude certain system decorations that + * always appear on screen, notably the navigation bar. + * * The decor view's [android.view.View#getWidth] and [android.view.View@getHeight] can be + * used to determine the size of the top level view in the view hierarchy, but this size is + * determined through a combination of [android.view.WindowManager.LayoutParams] + * flags and may not represent the true window size. For example, a window that does not + * indicate it can be displayed behind a display cutout will have the size of the decor + * view offset to exclude this region unless this region overlaps with the status bar, while + * the value returned from this method will include this region. + * + * The value returned from this method is guaranteed to be correct on platforms + * [Q][Build.VERSION_CODES.Q] and above. For older platforms the value may be invalid if + * the activity is in multi-window mode or if the navigation bar offset can not be accounted + * for, though a best effort is made to ensure the returned value is as close as possible to + * the true value. See [.computeWindowBoundsP] and + * [.computeWindowBoundsN]. + * + * Note: The value of this is based on the last windowing state reported to the client. + * + * @see android.view.WindowManager.getCurrentWindowMetrics + * @see android.view.WindowMetrics.getBounds + */ + fun computeCurrentWindowMetrics(activity: Activity): WindowMetrics + + /** + * Computes the maximum size and position of the area the window can expect with + * [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height + * and any combination of flags that would allow the window to extend behind display cutouts. + * + * The value returned from this method will always match [Display.getRealSize] on + * [Android 10][Build.VERSION_CODES.Q] and below. + * + * @see android.view.WindowManager.getMaximumWindowMetrics + */ + fun computeMaximumWindowMetrics(activity: Activity): WindowMetrics +} diff --git a/common/src/main/java/com/itsaky/androidide/window/WindowMetricsCalculatorCompat.kt b/common/src/main/java/com/itsaky/androidide/window/WindowMetricsCalculatorCompat.kt new file mode 100644 index 000000000..1de79985a --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/window/WindowMetricsCalculatorCompat.kt @@ -0,0 +1,332 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.itsaky.androidide.window + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.Configuration +import android.graphics.Point +import android.graphics.Rect +import android.os.Build +import android.os.Build.VERSION_CODES +import android.util.Log +import android.view.Display +import android.view.DisplayCutout +import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.window.ActivityCompatHelperApi24.isInMultiWindowMode +import com.itsaky.androidide.window.ActivityCompatHelperApi30.currentWindowBounds +import com.itsaky.androidide.window.ActivityCompatHelperApi30.maximumWindowBounds +import com.itsaky.androidide.window.DisplayCompatHelperApi17.getRealSize +import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetBottom +import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetLeft +import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetRight +import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetTop +import java.lang.reflect.InvocationTargetException + +/** + * Helper class used to compute window metrics across Android versions. + */ +internal object WindowMetricsCalculatorCompat : WindowMetricsCalculator { + + private val TAG: String = WindowMetricsCalculatorCompat::class.java.simpleName + + /** + * Computes the current [WindowMetrics] for a given [Activity] + * @see WindowMetricsCalculator.computeCurrentWindowMetrics + */ + override fun computeCurrentWindowMetrics(activity: Activity): WindowMetrics { + val bounds = if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { + currentWindowBounds(activity) + } else if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) { + computeWindowBoundsQ(activity) + } else if (Build.VERSION.SDK_INT >= VERSION_CODES.P) { + computeWindowBoundsP(activity) + } else + computeWindowBoundsN(activity) + return WindowMetrics(bounds) + } + + /** + * Computes the maximum [WindowMetrics] for a given [Activity] + * @see WindowMetricsCalculator.computeMaximumWindowMetrics + */ + override fun computeMaximumWindowMetrics(activity: Activity): WindowMetrics { + val bounds = if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { + maximumWindowBounds(activity) + } else { + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") + val display = activity.windowManager.defaultDisplay + val displaySize = getRealSizeForDisplay(display) + Rect(0, 0, displaySize.x, displaySize.y) + } + return WindowMetrics(bounds) + } + + /** Computes the window bounds for [Build.VERSION_CODES.Q]. */ + @SuppressLint("BanUncheckedReflection", "BlockedPrivateApi") + @RequiresApi(VERSION_CODES.Q) + internal fun computeWindowBoundsQ(activity: Activity): Rect { + var bounds: Rect + val config = activity.resources.configuration + try { + val windowConfigField = + Configuration::class.java.getDeclaredField("windowConfiguration") + windowConfigField.isAccessible = true + val windowConfig = windowConfigField[config] + val getBoundsMethod = windowConfig.javaClass.getDeclaredMethod("getBounds") + bounds = Rect(getBoundsMethod.invoke(windowConfig) as Rect) + } catch (e: NoSuchFieldException) { + Log.w(TAG, e) + // If reflection fails for some reason default to the P implementation which still + // has the ability to account for display cutouts. + bounds = computeWindowBoundsP(activity) + } catch (e: NoSuchMethodException) { + Log.w(TAG, e) + bounds = computeWindowBoundsP(activity) + } catch (e: IllegalAccessException) { + Log.w(TAG, e) + bounds = computeWindowBoundsP(activity) + } catch (e: InvocationTargetException) { + Log.w(TAG, e) + bounds = computeWindowBoundsP(activity) + } + return bounds + } + + /** + * Computes the window bounds for [Build.VERSION_CODES.P]. + * + * + * NOTE: This method may result in incorrect values if the [android.content.res.Resources] + * value stored at 'navigation_bar_height' does not match the true navigation bar inset on + * the window. + * + */ + @SuppressLint("BanUncheckedReflection", "BlockedPrivateApi") + @RequiresApi(VERSION_CODES.P) + internal fun computeWindowBoundsP(activity: Activity): Rect { + val bounds = Rect() + val config = activity.resources.configuration + try { + val windowConfigField = + Configuration::class.java.getDeclaredField("windowConfiguration") + windowConfigField.isAccessible = true + val windowConfig = windowConfigField[config] + + // In multi-window mode we'll use the WindowConfiguration#mBounds property which + // should match the window size. Otherwise we'll use the mAppBounds property and + // will adjust it below. + if (isInMultiWindowMode(activity)) { + val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getBounds") + bounds.set((getAppBounds.invoke(windowConfig) as Rect)) + } else { + val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getAppBounds") + bounds.set((getAppBounds.invoke(windowConfig) as Rect)) + } + } catch (e: NoSuchFieldException) { + Log.w(TAG, e) + getRectSizeFromDisplay(activity, bounds) + } catch (e: NoSuchMethodException) { + Log.w(TAG, e) + getRectSizeFromDisplay(activity, bounds) + } catch (e: IllegalAccessException) { + Log.w(TAG, e) + getRectSizeFromDisplay(activity, bounds) + } catch (e: InvocationTargetException) { + Log.w(TAG, e) + getRectSizeFromDisplay(activity, bounds) + } + val platformWindowManager = activity.windowManager + + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") + val currentDisplay = platformWindowManager.defaultDisplay + val realDisplaySize = Point() + // [Display#getRealSize] is deprecated but we have this for + // compatibility with older versions + getRealSize(display = currentDisplay, point = realDisplaySize) + if (!isInMultiWindowMode(activity)) { + // The activity is not in multi-window mode. Check if the addition of the + // navigation bar size to mAppBounds results in the real display size and if so + // assume the nav bar height should be added to the result. + val navigationBarHeight = getNavigationBarHeight(activity) + if (bounds.bottom + navigationBarHeight == realDisplaySize.y) { + bounds.bottom += navigationBarHeight + } else if (bounds.right + navigationBarHeight == realDisplaySize.x) { + bounds.right += navigationBarHeight + } else if (bounds.left == navigationBarHeight) { + bounds.left = 0 + } + } + if ((bounds.width() < realDisplaySize.x || bounds.height() < realDisplaySize.y) && + !isInMultiWindowMode(activity) + ) { + // If the corrected bounds are not the same as the display size and the activity is + // not in multi-window mode it is possible there are unreported cutouts inset-ing + // the window depending on the layoutInCutoutMode. Check for them here by getting + // the cutout from the display itself. + val displayCutout = getCutoutForDisplay(currentDisplay) + if (displayCutout != null) { + if (bounds.left == safeInsetLeft(displayCutout)) { + bounds.left = 0 + } + if (realDisplaySize.x - bounds.right == safeInsetRight(displayCutout)) { + bounds.right += safeInsetRight(displayCutout) + } + if (bounds.top == safeInsetTop(displayCutout)) { + bounds.top = 0 + } + if (realDisplaySize.y - bounds.bottom == safeInsetBottom(displayCutout)) { + bounds.bottom += safeInsetBottom(displayCutout) + } + } + } + return bounds + } + + private fun getRectSizeFromDisplay(activity: Activity, bounds: Rect) { + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") + val defaultDisplay = activity.windowManager.defaultDisplay + // [Display#getRectSize] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") + defaultDisplay.getRectSize(bounds) + } + + /** + * Computes the window bounds for platforms between [Build.VERSION_CODES.N] + * and [Build.VERSION_CODES.O_MR1], inclusive. + * + * + * NOTE: This method may result in incorrect values under the following conditions: + * + * * If the activity is in multi-window mode the origin of the returned bounds will + * always be anchored at (0, 0). + * * If the [android.content.res.Resources] value stored at 'navigation_bar_height' does + * not match the true navigation bar size the returned bounds will not take into account + * the navigation + * bar. + * + */ + private fun computeWindowBoundsN(activity: Activity): Rect { + val bounds = Rect() + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") + val defaultDisplay = activity.windowManager.defaultDisplay + // [Display#getRectSize] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") + defaultDisplay.getRectSize(bounds) + if (!isInMultiWindowMode(activity)) { + // The activity is not in multi-window mode. Check if the addition of the + // navigation bar size to Display#getSize() results in the real display size and + // if so return this value. If not, return the result of Display#getSize(). + val realDisplaySize = getRealSizeForDisplay(defaultDisplay) + val navigationBarHeight = getNavigationBarHeight(activity) + if (bounds.bottom + navigationBarHeight == realDisplaySize.y) { + bounds.bottom += navigationBarHeight + } else if (bounds.right + navigationBarHeight == realDisplaySize.x) { + bounds.right += navigationBarHeight + } + } + return bounds + } + + /** + * Returns the full (real) size of the display, in pixels, without subtracting any window + * decor or applying any compatibility scale factors. + * + * + * The size is adjusted based on the current rotation of the display. + * + * @return a point representing the real display size in pixels. + * + * @see Display.getRealSize + */ + @VisibleForTesting + internal fun getRealSizeForDisplay(display: Display): Point { + val size = Point() + getRealSize(display, size) + return size + } + + /** + * Returns the [android.content.res.Resources] value stored as 'navigation_bar_height'. + * + * + * Note: This is error-prone and is **not** the recommended way to determine the size + * of the overlapping region between the navigation bar and a given window. The best + * approach is to acquire the [android.view.WindowInsets]. + */ + @SuppressLint("DiscouragedApi", "InternalInsetResource") + private fun getNavigationBarHeight(context: Context): Int { + val resources = context.resources + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId > 0) { + resources.getDimensionPixelSize(resourceId) + } else 0 + } + + /** + * Returns the [DisplayCutout] for the given display. Note that display cutout returned + * here is for the display and the insets provided are in the display coordinate system. + * + * @return the display cutout for the given display. + */ + @SuppressLint("BanUncheckedReflection", "PrivateApi") + @RequiresApi(VERSION_CODES.P) + private fun getCutoutForDisplay(display: Display): DisplayCutout? { + var displayCutout: DisplayCutout? = null + try { + val displayInfoClass = Class.forName("android.view.DisplayInfo") + val displayInfoConstructor = displayInfoClass.getConstructor() + displayInfoConstructor.isAccessible = true + val displayInfo = displayInfoConstructor.newInstance() + val getDisplayInfoMethod = display.javaClass.getDeclaredMethod( + "getDisplayInfo", displayInfo.javaClass + ) + getDisplayInfoMethod.isAccessible = true + getDisplayInfoMethod.invoke(display, displayInfo) + val displayCutoutField = displayInfo.javaClass.getDeclaredField("displayCutout") + displayCutoutField.isAccessible = true + val cutout = displayCutoutField[displayInfo] + if (cutout is DisplayCutout) { + displayCutout = cutout + } + } catch (e: ClassNotFoundException) { + Log.w(TAG, e) + } catch (e: NoSuchMethodException) { + Log.w(TAG, e) + } catch (e: NoSuchFieldException) { + Log.w(TAG, e) + } catch (e: IllegalAccessException) { + Log.w(TAG, e) + } catch (e: InvocationTargetException) { + Log.w(TAG, e) + } catch (e: InstantiationException) { + Log.w(TAG, e) + } + return displayCutout + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 031f0affd..ce8620a23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,8 @@ antlr4 = "4.13.1" androidx-work = "2.9.0" androidx-espresso = "3.5.1" retrofit = "2.9.0" +markwon = "4.6.2" +maven-publish-plugin = "0.25.3" [libraries] @@ -48,6 +50,12 @@ common-jsonrpc = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc", versi common-leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.12" } common-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } common-retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +common-markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } +common-markwon-extStrikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon" } +common-markwon-linkify = { module = "io.noties.markwon:linkify", version.ref = "markwon" } +common-markwon-recycler = { module = "io.noties.markwon:recycler", version.ref = "markwon" } +common-hiddenApiBypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "2.0" } +common-termuxAmLib = { module = "com.termux:termux-am-library", version = "v2.0.0" } # AndroidX androidx-annotation = { module = "androidx.annotation:annotation", version = "1.7.0" } @@ -62,7 +70,8 @@ androidx-preference = { module = "androidx.preference:preference", version = "1. androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.3.2" } androidx-vectors = { module = "androidx.vectordrawable:vectordrawable", version.ref = "androidx-vectordrawable" } androidx-animated_vectors = { module = "androidx.vectordrawable:vectordrawable-animated", version.ref = "androidx-vectordrawable" } -androidx-ktx = { module = "androidx.core:core-ktx", version = "1.12.0" } +androidx-core = { module = "androidx.core:core", version = "1.12.0" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.12.0" } androidx-fragment_ktx = { module = "androidx.fragment:fragment-ktx", version = "1.5.7" } androidx-lib_desugaring = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.4" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.0.1" } @@ -72,6 +81,9 @@ androidx-nav-ui = { module = "androidx.navigation:navigation-ui-ktx", version.re androidx-nav-dynamic_features = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "androidx-navigation" } androidx-tracing = { module = "androidx.tracing:tracing", version = "1.2.0" } androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version = "1.2.0" } +androidx-viewpager = { module = "androidx.viewpager:viewpager", version = "1.0.0" } +#noinspection GradleDependency +androidx-window-v1alpha9 = { module = "androidx.window:window", version="1.0.0-alpha09" } androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" } androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } @@ -130,10 +142,11 @@ tooling-slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.9" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version = "0.10.6" } nav-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "androidx-navigation" } +maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish-plugin" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -maven-publish = { id = "com.vanniktech.maven.publish.base", version = "0.25.3" } +maven-publish = { id = "com.vanniktech.maven.publish.base", version.ref = "maven-publish-plugin" } gradle-publish = { id = "com.gradle.plugin-publish", version = "1.2.1" } diff --git a/lsp/api/build.gradle.kts b/lsp/api/build.gradle.kts index f840d2931..1eb932872 100644 --- a/lsp/api/build.gradle.kts +++ b/lsp/api/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation(projects.eventbusEvents) implementation(libs.androidx.appcompat) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) implementation(libs.common.utilcode) implementation(libs.google.material) diff --git a/lsp/java/build.gradle.kts b/lsp/java/build.gradle.kts index 02156b5bc..d386d766f 100644 --- a/lsp/java/build.gradle.kts +++ b/lsp/java/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { implementation(projects.subprojects.javapoet) implementation(projects.subprojects.googleJavaFormat) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) testImplementation(projects.testing.lsp) diff --git a/lsp/models/build.gradle.kts b/lsp/models/build.gradle.kts index c9b2e0adc..e4e442b3e 100644 --- a/lsp/models/build.gradle.kts +++ b/lsp/models/build.gradle.kts @@ -13,7 +13,7 @@ dependencies { implementation(projects.subprojects.fuzzysearch) implementation(libs.androidx.appcompat) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) implementation(libs.common.utilcode) } \ No newline at end of file diff --git a/lsp/xml/build.gradle.kts b/lsp/xml/build.gradle.kts index 40ef4ef5f..990974d6c 100644 --- a/lsp/xml/build.gradle.kts +++ b/lsp/xml/build.gradle.kts @@ -46,7 +46,7 @@ dependencies { implementation(projects.subprojects.xmlDom) implementation(projects.subprojects.xmlUtils) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) implementation(libs.google.material) diff --git a/resources/src/main/res/values-night/styles.xml b/resources/src/main/res/values-night/styles.xml index e6cb624b5..1b88d9f6a 100755 --- a/resources/src/main/res/values-night/styles.xml +++ b/resources/src/main/res/values-night/styles.xml @@ -22,6 +22,17 @@ diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index fa5db3400..475b5fa89 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ Copy + More… Text to search AndroidIDE Open source licenses diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml index 2e196441f..9b902e135 100755 --- a/resources/src/main/res/values/styles.xml +++ b/resources/src/main/res/values/styles.xml @@ -39,6 +39,17 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 33e702c40..c82c0febd 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,7 +50,6 @@ include( ":editor", ":editor-api", ":editor-treesitter", - ":emulatorview", ":eventbus", ":eventbus-android", ":eventbus-events", @@ -96,6 +95,10 @@ include( ":subprojects:tooling-api-model", ":subprojects:xml-dom", ":subprojects:xml-utils", + ":termux:termux-app", + ":termux:termux-emulator", + ":termux:termux-shared", + ":termux:termux-view", ":testing:android", ":testing:lsp", ":testing:tooling", diff --git a/subprojects/flashbar/build.gradle.kts b/subprojects/flashbar/build.gradle.kts index 92a92c651..14ccc9c64 100644 --- a/subprojects/flashbar/build.gradle.kts +++ b/subprojects/flashbar/build.gradle.kts @@ -27,7 +27,7 @@ android { dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.annotation) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.constraintlayout) implementation(libs.google.material) } \ No newline at end of file diff --git a/templates-impl/build.gradle.kts b/templates-impl/build.gradle.kts index da5ea3b38..8bf63d292 100644 --- a/templates-impl/build.gradle.kts +++ b/templates-impl/build.gradle.kts @@ -35,7 +35,7 @@ dependencies { implementation(projects.preferences) implementation(projects.subprojects.projects) implementation(libs.androidx.annotation) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.google.auto.service.annotations) testImplementation(projects.templatesApi) diff --git a/termux/termux-app/.gitignore b/termux/termux-app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/termux/termux-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/termux/termux-app/build.gradle.kts b/termux/termux-app/build.gradle.kts new file mode 100644 index 000000000..127330aa3 --- /dev/null +++ b/termux/termux-app/build.gradle.kts @@ -0,0 +1,71 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + id("com.android.library") + id("kotlin-android") +} + +val packageVariant = System.getenv("TERMUX_PACKAGE_VARIANT") ?: "apt-android-7" // Default: "apt-android-7" + +android { + namespace = "com.termux" + ndkVersion = BuildConfig.ndkVersion + + defaultConfig { + + buildConfigField("String", "TERMUX_PACKAGE_VARIANT", "\"" + packageVariant + "\"") // Used by TermuxApplication class + + manifestPlaceholders["TERMUX_PACKAGE_NAME"] = BuildConfig.packageName + manifestPlaceholders["TERMUX_APP_NAME"] = "AndroidIDE" + + externalNativeBuild { + ndkBuild { + cFlags("-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections") + } + } + } + + externalNativeBuild { + ndkBuild { + path = file("src/main/cpp/Android.mk") + } + } + + lint.disable += "ProtectedPermissions" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + packaging.jniLibs.useLegacyPackaging = true +} + +dependencies { + implementation(libs.androidx.annotation) + implementation(libs.androidx.core) + implementation(libs.androidx.drawer) + implementation(libs.androidx.preference) + implementation(libs.androidx.viewpager) + implementation(libs.google.material) + implementation(libs.google.guava) + implementation(libs.common.markwon.core) + implementation(libs.common.markwon.extStrikethrough) + implementation(libs.common.markwon.linkify) + implementation(libs.common.markwon.recycler) + + implementation(projects.common) + implementation(projects.preferences) + implementation(projects.resources) + implementation(projects.termux.termuxView) + implementation(projects.termux.termuxShared) + + testImplementation(projects.testing.unit) +} + +tasks.register("versionName") { + doLast { + print(project.rootProject.version) + } +} \ No newline at end of file diff --git a/termux/termux-app/proguard-rules.pro b/termux/termux-app/proguard-rules.pro new file mode 100644 index 000000000..a01c03896 --- /dev/null +++ b/termux/termux-app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +-dontobfuscate +#-renamesourcefileattribute SourceFile +#-keepattributes SourceFile,LineNumberTable + +# Temp fix for androidx.window:window:1.0.0-alpha09 imported by termux-shared +# https://issuetracker.google.com/issues/189001730 +# https://android-review.googlesource.com/c/platform/frameworks/support/+/1757630 +-keep class androidx.window.** { *; } diff --git a/termux/termux-app/src/main/AndroidManifest.xml b/termux/termux-app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..086a2a6ce --- /dev/null +++ b/termux/termux-app/src/main/AndroidManifest.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/termux/termux-app/src/main/cpp/Android.mk b/termux/termux-app/src/main/cpp/Android.mk new file mode 100644 index 000000000..d013e2ed9 --- /dev/null +++ b/termux/termux-app/src/main/cpp/Android.mk @@ -0,0 +1,5 @@ +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) +LOCAL_MODULE := libtermux-bootstrap +LOCAL_SRC_FILES := termux-bootstrap-zip.S termux-bootstrap.c +include $(BUILD_SHARED_LIBRARY) diff --git a/app/src/arm64-v8a/assets/data/arm64-v8a/bootstrap.zip b/termux/termux-app/src/main/cpp/bootstrap-aarch64.zip similarity index 100% rename from app/src/arm64-v8a/assets/data/arm64-v8a/bootstrap.zip rename to termux/termux-app/src/main/cpp/bootstrap-aarch64.zip diff --git a/app/src/armeabi-v7a/assets/data/armeabi-v7a/bootstrap.zip b/termux/termux-app/src/main/cpp/bootstrap-arm.zip similarity index 100% rename from app/src/armeabi-v7a/assets/data/armeabi-v7a/bootstrap.zip rename to termux/termux-app/src/main/cpp/bootstrap-arm.zip diff --git a/termux/termux-app/src/main/cpp/termux-bootstrap-zip.S b/termux/termux-app/src/main/cpp/termux-bootstrap-zip.S new file mode 100644 index 000000000..f7c62b73c --- /dev/null +++ b/termux/termux-app/src/main/cpp/termux-bootstrap-zip.S @@ -0,0 +1,14 @@ + .global blob + .global blob_size + .section .rodata + blob: +#if defined __aarch64__ + .incbin "bootstrap-aarch64.zip" + #elif defined __arm__ + .incbin "bootstrap-arm.zip" + #else + # error Unsupported arch + #endif + 1: + blob_size: + .int 1b - blob diff --git a/termux/termux-app/src/main/cpp/termux-bootstrap.c b/termux/termux-app/src/main/cpp/termux-bootstrap.c new file mode 100644 index 000000000..8ba745ffc --- /dev/null +++ b/termux/termux-app/src/main/cpp/termux-bootstrap.c @@ -0,0 +1,11 @@ +#include + +extern jbyte blob[]; +extern int blob_size; + +JNIEXPORT jbyteArray JNICALL Java_com_termux_app_TermuxInstaller_getZip(JNIEnv *env, __attribute__((__unused__)) jobject This) +{ + jbyteArray ret = (*env)->NewByteArray(env, blob_size); + (*env)->SetByteArrayRegion(env, ret, 0, blob_size, blob); + return ret; +} diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java new file mode 100644 index 000000000..7db23e2ad --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java @@ -0,0 +1,968 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.termux.app; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.autofill.AutofillManager; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.viewpager.widget.ViewPager; +import com.itsaky.androidide.app.BaseIDEActivity; +import com.termux.R; +import com.termux.app.api.file.FileReceiverActivity; +import com.termux.app.terminal.TermuxActivityRootView; +import com.termux.app.terminal.TermuxSessionsListViewController; +import com.termux.app.terminal.TermuxTerminalSessionActivityClient; +import com.termux.app.terminal.TermuxTerminalViewClient; +import com.termux.app.terminal.io.TerminalToolbarViewPager; +import com.termux.app.terminal.io.TermuxTerminalExtraKeys; +import com.termux.databinding.ActivityTermuxBinding; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.activity.ActivityUtils; +import com.termux.shared.android.PermissionUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; +import com.termux.shared.termux.crash.TermuxCrashUtils; +import com.termux.shared.termux.extrakeys.ExtraKeysView; +import com.termux.shared.termux.interact.TextInputDialogUtils; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.termux.theme.TermuxThemeUtils; +import com.termux.shared.view.KeyboardUtils; +import com.termux.shared.view.ViewUtils; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; +import com.termux.view.TerminalView; +import com.termux.view.TerminalViewClient; +import java.util.Arrays; + +/** + * A terminal emulator activity. + *

+ * See + *

    + *
  • http://www.mongrel-phones.com.au/default/how_to_make_a_local_service_and_bind_to_it_in_android
  • + *
  • https://code.google.com/p/android/issues/detail?id=6426
  • + *
+ * about memory leaks. + */ +public class TermuxActivity extends BaseIDEActivity implements ServiceConnection { + + protected ActivityTermuxBinding binding; + + /** + * The connection to the {@link TermuxService}. Requested in {@link #onCreate(Bundle)} with a call to + * {@link #bindService(Intent, ServiceConnection, int)}, and obtained and stored in + * {@link #onServiceConnected(ComponentName, IBinder)}. + */ + TermuxService mTermuxService; + + /** + * The {@link TerminalViewClient} interface implementation to allow for communication between + * {@link TerminalView} and {@link TermuxActivity}. + */ + TermuxTerminalViewClient mTermuxTerminalViewClient; + + /** + * The {@link TerminalSessionClient} interface implementation to allow for communication between + * {@link TerminalSession} and {@link TermuxActivity}. + */ + TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; + + /** + * Termux app shared preferences manager. + */ + private TermuxAppSharedPreferences mPreferences; + + /** + * Termux app SharedProperties loaded from termux.properties + */ + private TermuxAppSharedProperties mProperties; + + /** + * The terminal extra keys view. + */ + ExtraKeysView mExtraKeysView; + + /** + * The client for the {@link #mExtraKeysView}. + */ + TermuxTerminalExtraKeys mTermuxTerminalExtraKeys; + + /** + * The termux sessions list controller. + */ + TermuxSessionsListViewController mTermuxSessionListViewController; + + /** + * The {@link TermuxActivity} broadcast receiver for various things like terminal style configuration changes. + */ + private final BroadcastReceiver mTermuxActivityBroadcastReceiver = new TermuxActivityBroadcastReceiver(); + + /** + * The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}. + */ + Toast mLastToast; + + /** + * If between onResume() and onStop(). Note that only one session is in the foreground of the terminal view at the + * time, so if the session causing a change is not in the foreground it should probably be treated as background. + */ + private boolean mIsVisible; + + /** + * If onResume() was called after onCreate(). + */ + private boolean mIsOnResumeAfterOnCreate = false; + + /** + * If activity was restarted like due to call to {@link #recreate()} after receiving + * {@link TERMUX_ACTIVITY#ACTION_RELOAD_STYLE}, system dark night mode was changed or activity + * was killed by android. + */ + private boolean mIsActivityRecreated = false; + + /** + * The {@link TermuxActivity} is in an invalid state and must not be run. + */ + private boolean mIsInvalidState; + + private int mNavBarHeight; + + private float mTerminalToolbarDefaultHeight; + + + private static final int CONTEXT_MENU_SELECT_URL_ID = 0; + private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1; + private static final int CONTEXT_MENU_SHARE_SELECTED_TEXT = 10; + private static final int CONTEXT_MENU_AUTOFILL_ID = 2; + private static final int CONTEXT_MENU_RESET_TERMINAL_ID = 3; + private static final int CONTEXT_MENU_KILL_PROCESS_ID = 4; + private static final int CONTEXT_MENU_STYLING_ID = 5; + private static final int CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON = 6; + private static final int CONTEXT_MENU_HELP_ID = 7; + private static final int CONTEXT_MENU_SETTINGS_ID = 8; + private static final int CONTEXT_MENU_REPORT_ID = 9; + + private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input"; + private static final String ARG_ACTIVITY_RECREATED = "activity_recreated"; + + private static final String LOG_TAG = "TermuxActivity"; + + @NonNull + @Override + protected View bindLayout() { + this.binding = ActivityTermuxBinding.inflate(getLayoutInflater()); + return binding.getRoot(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + mIsOnResumeAfterOnCreate = true; + + if (savedInstanceState != null) + mIsActivityRecreated = savedInstanceState.getBoolean(ARG_ACTIVITY_RECREATED, false); + + // Delete ReportInfo serialized object files from cache older than 14 days + ReportActivity.deleteReportInfoFilesOlderThanXDays(this, 14, false); + + // Load Termux app SharedProperties from disk + mProperties = TermuxAppSharedProperties.getProperties(); + reloadProperties(); + + setActivityTheme(); + + super.onCreate(savedInstanceState); + + // Load termux shared preferences + // This will also fail if TermuxConstants.TERMUX_PACKAGE_NAME does not equal applicationId + mPreferences = TermuxAppSharedPreferences.build(this, true); + if (mPreferences == null) { + // An AlertDialog should have shown to kill the app, so we don't continue running activity code + mIsInvalidState = true; + return; + } + + setMargins(); + + binding.activityTermuxRootView.setActivity(this); + binding.activityTermuxRootView.setOnApplyWindowInsetsListener(new TermuxActivityRootView.WindowInsetsListener()); + + View content = findViewById(android.R.id.content); + content.setOnApplyWindowInsetsListener((v, insets) -> { + mNavBarHeight = insets.getSystemWindowInsetBottom(); + return insets; + }); + + if (mProperties.isUsingFullScreen()) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + setTermuxTerminalViewAndClients(); + + setTerminalToolbarView(savedInstanceState); + + setNewSessionButtonView(); + + setToggleKeyboardView(); + + registerForContextMenu(binding.terminalView); + + FileReceiverActivity.updateFileReceiverActivityComponentsState(this); + + try { + // Start the {@link TermuxService} and make it run regardless of who is bound to it + Intent serviceIntent = new Intent(this, TermuxService.class); + startService(serviceIntent); + + // Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)} + // callback if it succeeds. + if (!bindService(serviceIntent, this, 0)) + throw new RuntimeException("bindService() failed"); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG,"TermuxActivity failed to start TermuxService", e); + Logger.showToast(this, + getString(e.getMessage() != null && e.getMessage().contains("app is in background") ? + R.string.error_termux_service_start_failed_bg : R.string.error_termux_service_start_failed_general), + true); + mIsInvalidState = true; + return; + } + } + + @Override + public void onStart() { + super.onStart(); + + Logger.logDebug(LOG_TAG, "onStart"); + + if (mIsInvalidState) return; + + mIsVisible = true; + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onStart(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStart(); + + if (mPreferences.isTerminalMarginAdjustmentEnabled()) + addTermuxActivityRootViewGlobalLayoutListener(); + + registerTermuxActivityBroadcastReceiver(); + } + + @Override + public void onResume() { + super.onResume(); + + Logger.logVerbose(LOG_TAG, "onResume"); + + if (mIsInvalidState) return; + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onResume(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onResume(); + + // Check if a crash happened on last run of the app or if a plugin crashed and show a + // notification with the crash details if it did + TermuxCrashUtils.notifyAppCrashFromCrashLogFile(this, LOG_TAG); + + mIsOnResumeAfterOnCreate = false; + } + + @Override + protected void onStop() { + super.onStop(); + + Logger.logDebug(LOG_TAG, "onStop"); + + if (mIsInvalidState) return; + + mIsVisible = false; + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onStop(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStop(); + + removeTermuxActivityRootViewGlobalLayoutListener(); + + unregisterTermuxActivityBroadcastReceiver(); + getDrawer().closeDrawers(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + Logger.logDebug(LOG_TAG, "onDestroy"); + + if (mIsInvalidState) return; + + this.binding = null; + this.mLastToast = null; + + if (mTermuxService != null) { + // Do not leave service and session clients with references to activity. + mTermuxService.unsetTermuxTerminalSessionClient(); + mTermuxService = null; + } + + try { + unbindService(this); + } catch (Exception e) { + // ignore. + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { + Logger.logVerbose(LOG_TAG, "onSaveInstanceState"); + + super.onSaveInstanceState(savedInstanceState); + saveTerminalToolbarTextInput(savedInstanceState); + savedInstanceState.putBoolean(ARG_ACTIVITY_RECREATED, true); + } + + + + + + /** + * Part of the {@link ServiceConnection} interface. The service is bound with + * {@link #bindService(Intent, ServiceConnection, int)} in {@link #onCreate(Bundle)} which will cause a call to this + * callback method. + */ + @Override + public void onServiceConnected(ComponentName componentName, IBinder service) { + Logger.logDebug(LOG_TAG, "onServiceConnected"); + + mTermuxService = ((TermuxService.LocalBinder) service).service; + + setTermuxSessionsListView(); + + final Intent intent = getIntent(); + setIntent(null); + + if (mTermuxService.isTermuxSessionsEmpty()) { + if (mIsVisible) { + TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> { + if (mTermuxService == null) return; // Activity might have been destroyed. + try { + boolean launchFailsafe = false; + if (intent != null && intent.getExtras() != null) { + launchFailsafe = intent.getExtras().getBoolean(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + } + mTermuxTerminalSessionActivityClient.addNewSession(launchFailsafe, null); + } catch (WindowManager.BadTokenException e) { + // Activity finished - ignore. + } + }); + } else { + // The service connected while not in foreground - just bail out. + finishActivityIfNotFinishing(); + } + } else { + // If termux was started from launcher "New session" shortcut and activity is recreated, + // then the original intent will be re-delivered, resulting in a new session being re-added + // each time. + if (!mIsActivityRecreated && intent != null && Intent.ACTION_RUN.equals(intent.getAction())) { + // Android 7.1 app shortcut from res/xml/shortcuts.xml. + boolean isFailSafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + mTermuxTerminalSessionActivityClient.addNewSession(isFailSafe, null); + } else { + mTermuxTerminalSessionActivityClient.setCurrentSession(mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast()); + } + } + + // Update the {@link TerminalSession} and {@link TerminalEmulator} clients. + mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionActivityClient); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + Logger.logDebug(LOG_TAG, "onServiceDisconnected"); + + // Respect being stopped from the {@link TermuxService} notification action. + finishActivityIfNotFinishing(); + } + + + + + + + private void reloadProperties() { + mProperties.loadTermuxPropertiesFromDisk(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onReloadProperties(); + } + + + + private void setActivityTheme() { + // Update NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(mProperties.getNightMode()); + } + + private void setMargins() { + RelativeLayout relativeLayout = binding.activityTermuxRootRelativeLayout; + int marginHorizontal = mProperties.getTerminalMarginHorizontal(); + int marginVertical = mProperties.getTerminalMarginVertical(); + ViewUtils.setLayoutMarginsInDp(relativeLayout, marginHorizontal, marginVertical, marginHorizontal, marginVertical); + } + + + + public void addTermuxActivityRootViewGlobalLayoutListener() { + getTermuxActivityRootView().getViewTreeObserver().addOnGlobalLayoutListener(getTermuxActivityRootView()); + } + + public void removeTermuxActivityRootViewGlobalLayoutListener() { + if (getTermuxActivityRootView() != null) + getTermuxActivityRootView().getViewTreeObserver().removeOnGlobalLayoutListener(getTermuxActivityRootView()); + } + + + + private void setTermuxTerminalViewAndClients() { + // Set termux terminal view and session clients + mTermuxTerminalSessionActivityClient = new TermuxTerminalSessionActivityClient(this); + mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionActivityClient); + + // Set termux terminal view + binding.terminalView.setTerminalViewClient(mTermuxTerminalViewClient); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onCreate(); + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onCreate(); + } + + private void setTermuxSessionsListView() { + ListView termuxSessionsListView = binding.terminalSessionsList; + mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions()); + termuxSessionsListView.setAdapter(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); + } + + + + private void setTerminalToolbarView(Bundle savedInstanceState) { + mTermuxTerminalExtraKeys = new TermuxTerminalExtraKeys(this, binding.terminalView, + mTermuxTerminalViewClient, mTermuxTerminalSessionActivityClient); + + final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager(); + if (mPreferences.shouldShowTerminalToolbar()) terminalToolbarViewPager.setVisibility(View.VISIBLE); + + ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams(); + mTerminalToolbarDefaultHeight = layoutParams.height; + + setTerminalToolbarHeight(); + + String savedTextInput = null; + if (savedInstanceState != null) + savedTextInput = savedInstanceState.getString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT); + + terminalToolbarViewPager.setAdapter(new TerminalToolbarViewPager.PageAdapter(this, savedTextInput)); + terminalToolbarViewPager.addOnPageChangeListener(new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager)); + } + + private void setTerminalToolbarHeight() { + final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager(); + if (terminalToolbarViewPager == null) return; + + ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams(); + layoutParams.height = Math.round(mTerminalToolbarDefaultHeight * + (mTermuxTerminalExtraKeys.getExtraKeysInfo() == null ? 0 : mTermuxTerminalExtraKeys.getExtraKeysInfo().getMatrix().length) * + mProperties.getTerminalToolbarHeightScaleFactor()); + terminalToolbarViewPager.setLayoutParams(layoutParams); + } + + public void toggleTerminalToolbar() { + final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager(); + if (terminalToolbarViewPager == null) return; + + final boolean showNow = mPreferences.toogleShowTerminalToolbar(); + Logger.showToast(this, (showNow ? getString(R.string.msg_enabling_terminal_toolbar) : getString(R.string.msg_disabling_terminal_toolbar)), true); + terminalToolbarViewPager.setVisibility(showNow ? View.VISIBLE : View.GONE); + if (showNow && isTerminalToolbarTextInputViewSelected()) { + // Focus the text input view if just revealed. + findViewById(R.id.terminal_toolbar_text_input).requestFocus(); + } + } + + private void saveTerminalToolbarTextInput(Bundle savedInstanceState) { + if (savedInstanceState == null) return; + + final EditText textInputView = findViewById(R.id.terminal_toolbar_text_input); + if (textInputView != null) { + String textInput = textInputView.getText().toString(); + if (!textInput.isEmpty()) savedInstanceState.putString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT, textInput); + } + } + + private void setNewSessionButtonView() { + binding.newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionActivityClient.addNewSession(false, null)); + binding.newSessionButton.setOnLongClickListener(v -> { + TextInputDialogUtils.textInput(TermuxActivity.this, R.string.title_create_named_session, null, + R.string.action_create_named_session_confirm, text -> mTermuxTerminalSessionActivityClient.addNewSession(false, text), + R.string.action_new_session_failsafe, text -> mTermuxTerminalSessionActivityClient.addNewSession(true, text), + -1, null, null); + return true; + }); + } + + private void setToggleKeyboardView() { + binding.toggleKeyboardButton.setOnClickListener(v -> { + mTermuxTerminalViewClient.onToggleSoftKeyboardRequest(); + getDrawer().closeDrawers(); + }); + + binding.toggleKeyboardButton.setOnLongClickListener(v -> { + toggleTerminalToolbar(); + return true; + }); + } + + + @SuppressLint("RtlHardcoded") + @Override + public void onBackPressed() { + if (KeyboardUtils.isSoftKeyboardVisible(this)) { + KeyboardUtils.showSoftKeyboard(this, getTerminalView()); + } else if (getDrawer().isDrawerOpen(Gravity.LEFT)) { + getDrawer().closeDrawers(); + } else { + finishActivityIfNotFinishing(); + } + } + + public void finishActivityIfNotFinishing() { + // prevent duplicate calls to finish() if called from multiple places + if (!TermuxActivity.this.isFinishing()) { + finish(); + } + } + + /** Show a toast and dismiss the last one if still visible. */ + public void showToast(String text, boolean longDuration) { + if (text == null || text.isEmpty()) return; + if (mLastToast != null) mLastToast.cancel(); + mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT); + mLastToast.setGravity(Gravity.TOP, 0, 0); + mLastToast.show(); + } + + + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + TerminalSession currentSession = getCurrentSession(); + if (currentSession == null) return; + + boolean addAutoFillMenu = false; + AutofillManager autofillManager = getSystemService(AutofillManager.class); + if (autofillManager != null && autofillManager.isEnabled()) { + addAutoFillMenu = true; + } + + menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.action_select_url); + menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.action_share_transcript); + if (!DataUtils.isNullOrEmpty(binding.terminalView.getStoredSelectedText())) + menu.add(Menu.NONE, CONTEXT_MENU_SHARE_SELECTED_TEXT, Menu.NONE, R.string.action_share_selected_text); + if (addAutoFillMenu) + menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.action_autofill_password); + menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.action_reset_terminal); + menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.action_kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning()); + menu.add(Menu.NONE, CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.action_toggle_keep_screen_on).setCheckable(true).setChecked(mPreferences.shouldKeepScreenOn()); + menu.add(Menu.NONE, CONTEXT_MENU_SETTINGS_ID, Menu.NONE, R.string.action_open_settings); + menu.add(Menu.NONE, CONTEXT_MENU_REPORT_ID, Menu.NONE, R.string.action_report_issue); + } + + /** Hook system menu to show context menu instead. */ + @Override + public boolean onCreateOptionsMenu(Menu menu) { + binding.terminalView.showContextMenu(); + return false; + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + TerminalSession session = getCurrentSession(); + + switch (item.getItemId()) { + case CONTEXT_MENU_SELECT_URL_ID: + mTermuxTerminalViewClient.showUrlSelection(); + return true; + case CONTEXT_MENU_SHARE_TRANSCRIPT_ID: + mTermuxTerminalViewClient.shareSessionTranscript(); + return true; + case CONTEXT_MENU_SHARE_SELECTED_TEXT: + mTermuxTerminalViewClient.shareSelectedText(); + return true; + case CONTEXT_MENU_AUTOFILL_ID: + requestAutoFill(); + return true; + case CONTEXT_MENU_RESET_TERMINAL_ID: + onResetTerminalSession(session); + return true; + case CONTEXT_MENU_KILL_PROCESS_ID: + showKillSessionDialog(session); + return true; + case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON: + toggleKeepScreenOn(); + return true; + case CONTEXT_MENU_REPORT_ID: + mTermuxTerminalViewClient.reportIssueFromTranscript(); + return true; + default: + return super.onContextItemSelected(item); + } + } + + @Override + public void onContextMenuClosed(Menu menu) { + super.onContextMenuClosed(menu); + // onContextMenuClosed() is triggered twice if back button is pressed to dismiss instead of tap for some reason + binding.terminalView.onContextMenuClosed(menu); + } + + private void showKillSessionDialog(TerminalSession session) { + if (session == null) return; + + final AlertDialog.Builder b = new AlertDialog.Builder(this); + b.setIcon(android.R.drawable.ic_dialog_alert); + b.setMessage(R.string.title_confirm_kill_process); + b.setPositiveButton(android.R.string.yes, (dialog, id) -> { + dialog.dismiss(); + session.finishIfRunning(); + }); + b.setNegativeButton(android.R.string.no, null); + b.show(); + } + + private void onResetTerminalSession(TerminalSession session) { + if (session != null) { + session.reset(); + showToast(getResources().getString(R.string.msg_terminal_reset), true); + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onResetTerminalSession(); + } + } + + private void toggleKeepScreenOn() { + if (binding.terminalView.getKeepScreenOn()) { + binding.terminalView.setKeepScreenOn(false); + mPreferences.setKeepScreenOn(false); + } else { + binding.terminalView.setKeepScreenOn(true); + mPreferences.setKeepScreenOn(true); + } + } + + private void requestAutoFill() { + AutofillManager autofillManager = getSystemService(AutofillManager.class); + if (autofillManager != null && autofillManager.isEnabled()) { + autofillManager.requestAutofill(binding.terminalView); + } + } + + + + /** + * For processes to access primary external storage (/sdcard, /storage/emulated/0, ~/storage/shared), + * termux needs to be granted legacy WRITE_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE permissions + * if targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) and higher. + */ + public void requestStoragePermission(boolean isPermissionCallback) { + new Thread(() -> { + // Do not ask for permission again + int requestCode = isPermissionCallback ? -1 : PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION; + + // If permission is granted, then also setup storage symlinks. + if(PermissionUtils.checkAndRequestLegacyOrManageExternalStoragePermission( + TermuxActivity.this, requestCode, !isPermissionCallback)) { + if (isPermissionCallback) + Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG, + getString(com.termux.shared.R.string.msg_storage_permission_granted_on_request)); + + TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + } else { + if (isPermissionCallback) + Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG, + getString(com.termux.shared.R.string.msg_storage_permission_not_granted_on_request)); + } + }).start(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data)); + if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) { + requestStoragePermission(true); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + Logger.logVerbose(LOG_TAG, "onRequestPermissionsResult: requestCode: " + requestCode + ", permissions: " + Arrays.toString(permissions) + ", grantResults: " + Arrays.toString(grantResults)); + if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) { + requestStoragePermission(true); + } + } + + + + public int getNavBarHeight() { + return mNavBarHeight; + } + + public TermuxActivityRootView getTermuxActivityRootView() { + return binding.activityTermuxRootView; + } + + public View getTermuxActivityBottomSpaceView() { + return binding.activityTermuxBottomSpaceView; + } + + public ExtraKeysView getExtraKeysView() { + return mExtraKeysView; + } + + public TermuxTerminalExtraKeys getTermuxTerminalExtraKeys() { + return mTermuxTerminalExtraKeys; + } + + public void setExtraKeysView(ExtraKeysView extraKeysView) { + mExtraKeysView = extraKeysView; + } + + public DrawerLayout getDrawer() { + return (DrawerLayout) findViewById(R.id.drawer_layout); + } + + + public ViewPager getTerminalToolbarViewPager() { + return binding.terminalToolbarViewPager; + } + + public float getTerminalToolbarDefaultHeight() { + return mTerminalToolbarDefaultHeight; + } + + public boolean isTerminalViewSelected() { + return getTerminalToolbarViewPager().getCurrentItem() == 0; + } + + public boolean isTerminalToolbarTextInputViewSelected() { + return getTerminalToolbarViewPager().getCurrentItem() == 1; + } + + + public void termuxSessionListNotifyUpdated() { + mTermuxSessionListViewController.notifyDataSetChanged(); + } + + public boolean isVisible() { + return mIsVisible; + } + + public boolean isOnResumeAfterOnCreate() { + return mIsOnResumeAfterOnCreate; + } + + public boolean isActivityRecreated() { + return mIsActivityRecreated; + } + + + + public TermuxService getTermuxService() { + return mTermuxService; + } + + public TerminalView getTerminalView() { + return binding.terminalView; + } + + public TermuxTerminalViewClient getTermuxTerminalViewClient() { + return mTermuxTerminalViewClient; + } + + public TermuxTerminalSessionActivityClient getTermuxTerminalSessionClient() { + return mTermuxTerminalSessionActivityClient; + } + + @Nullable + public TerminalSession getCurrentSession() { + if (binding != null) + return binding.terminalView.getCurrentSession(); + else + return null; + } + + public TermuxAppSharedPreferences getPreferences() { + return mPreferences; + } + + public TermuxAppSharedProperties getProperties() { + return mProperties; + } + + + + + public static void updateTermuxActivityStyling(Context context, boolean recreateActivity) { + // Make sure that terminal styling is always applied. + Intent stylingIntent = new Intent(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE); + stylingIntent.putExtra(TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY, recreateActivity); + context.sendBroadcast(stylingIntent); + } + + private void registerTermuxActivityBroadcastReceiver() { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS); + + registerReceiver(mTermuxActivityBroadcastReceiver, intentFilter); + } + + private void unregisterTermuxActivityBroadcastReceiver() { + unregisterReceiver(mTermuxActivityBroadcastReceiver); + } + + private void fixTermuxActivityBroadcastReceiverIntent(Intent intent) { + if (intent == null) return; + + String extraReloadStyle = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); + if ("storage".equals(extraReloadStyle)) { + intent.removeExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); + intent.setAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS); + } + } + + class TermuxActivityBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + + if (mIsVisible) { + fixTermuxActivityBroadcastReceiverIntent(intent); + + switch (intent.getAction()) { + case TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH: + Logger.logDebug(LOG_TAG, "Received intent to notify app crash"); + TermuxCrashUtils.notifyAppCrashFromCrashLogFile(context, LOG_TAG); + return; + case TERMUX_ACTIVITY.ACTION_RELOAD_STYLE: + Logger.logDebug(LOG_TAG, "Received intent to reload styling"); + reloadActivityStyling(intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY, true)); + return; + case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS: + Logger.logDebug(LOG_TAG, "Received intent to request storage permissions"); + requestStoragePermission(false); + return; + default: + } + } + } + } + + private void reloadActivityStyling(boolean recreateActivity) { + if (mProperties != null) { + reloadProperties(); + + if (mExtraKeysView != null) { + mExtraKeysView.setButtonTextAllCaps(mProperties.shouldExtraKeysTextBeAllCaps()); + mExtraKeysView.reload(mTermuxTerminalExtraKeys.getExtraKeysInfo(), mTerminalToolbarDefaultHeight); + } + + // Update NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(mProperties.getNightMode()); + } + + setMargins(); + setTerminalToolbarHeight(); + + FileReceiverActivity.updateFileReceiverActivityComponentsState(this); + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onReloadActivityStyling(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onReloadActivityStyling(); + + // To change the activity and drawer theme, activity needs to be recreated. + // It will destroy the activity, including all stored variables and views, and onCreate() + // will be called again. Extra keys input text, terminal sessions and transcripts will be preserved. + if (recreateActivity) { + Logger.logDebug(LOG_TAG, "Recreating activity"); + TermuxActivity.this.recreate(); + } + } + + + + public static void startTermuxActivity(@NonNull final Context context) { + ActivityUtils.startActivity(context, newInstance(context)); + } + + public static Intent newInstance(@NonNull final Context context) { + Intent intent = new Intent(context, TermuxActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxApplication.java b/termux/termux-app/src/main/java/com/termux/app/TermuxApplication.java new file mode 100644 index 000000000..4483b0f98 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxApplication.java @@ -0,0 +1,79 @@ +package com.termux.app; + +import android.content.Context; +import com.itsaky.androidide.app.BaseApplication; +import com.termux.shared.errors.Error; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.crash.TermuxCrashUtils; +import com.termux.shared.termux.file.TermuxFileUtils; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.termux.shell.TermuxShellManager; +import com.termux.shared.termux.shell.am.TermuxAmSocketServer; +import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; +import com.termux.shared.termux.theme.TermuxThemeUtils; + +public class TermuxApplication extends BaseApplication { + + private static final String LOG_TAG = "TermuxApplication"; + + public void onCreate() { + super.onCreate(); + + Context context = getApplicationContext(); + + // Set crash handler for the app + TermuxCrashUtils.setDefaultCrashHandler(this); + + // Set log config for the app + setLogConfig(context); + + Logger.logDebug("Starting Application"); + + // Init app wide SharedProperties loaded from termux.properties + TermuxAppSharedProperties properties = TermuxAppSharedProperties.init(context); + + // Init app wide shell manager + TermuxShellManager shellManager = TermuxShellManager.init(context); + + // Set NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(properties.getNightMode()); + + // Check and create termux files directory. If failed to access it like in case of secondary + // user or external sd card installation, then don't run files directory related code + Error error = TermuxFileUtils.isTermuxFilesDirectoryAccessible(this, true, true); + boolean isTermuxFilesDirectoryAccessible = error == null; + if (isTermuxFilesDirectoryAccessible) { + Logger.logInfo(LOG_TAG, "Termux files directory is accessible"); + + error = TermuxFileUtils.isAppsTermuxAppDirectoryAccessible(true, true); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, "Create apps/termux-app directory failed\n" + error); + return; + } + + // Setup termux-am-socket server + TermuxAmSocketServer.setupTermuxAmSocketServer(context); + } else { + Logger.logErrorExtended(LOG_TAG, "Termux files directory is not accessible\n" + error); + } + + // Init TermuxShellEnvironment constants and caches after everything has been setup including termux-am-socket server + TermuxShellEnvironment.init(this); + + if (isTermuxFilesDirectoryAccessible) { + TermuxShellEnvironment.writeEnvironmentToFile(this); + } + } + + public static void setLogConfig(Context context) { + Logger.setDefaultLogTag(TermuxConstants.TERMUX_APP_NAME); + + // Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL} + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; + preferences.setLogLevel(null, preferences.getLogLevel()); + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxInstaller.java b/termux/termux-app/src/main/java/com/termux/app/TermuxInstaller.java new file mode 100644 index 000000000..63a6d8ee3 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxInstaller.java @@ -0,0 +1,386 @@ +package com.termux.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.os.Build; +import android.os.Environment; +import android.system.Os; +import android.util.Pair; +import android.view.WindowManager; + +import com.termux.R; +import com.termux.shared.file.FileUtils; +import com.termux.shared.termux.crash.TermuxCrashUtils; +import com.termux.shared.termux.file.TermuxFileUtils; +import com.termux.shared.interact.MessageDialogUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.android.PackageUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR; +import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR_PATH; +import static com.termux.shared.termux.TermuxConstants.TERMUX_STAGING_PREFIX_DIR; +import static com.termux.shared.termux.TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH; + +/** + * Install the Termux bootstrap packages if necessary by following the below steps: + *

+ * (1) If $PREFIX already exist, assume that it is correct and be done. Note that this relies on that we do not create a + * broken $PREFIX directory below. + *

+ * (2) A progress dialog is shown with "Installing..." message and a spinner. + *

+ * (3) A staging directory, $STAGING_PREFIX, is cleared if left over from broken installation below. + *

+ * (4) The zip file is loaded from a shared library. + *

+ * (5) The zip, containing entries relative to the $PREFIX, is is downloaded and extracted by a zip input stream + * continuously encountering zip file entries: + *

+ * (5.1) If the zip entry encountered is SYMLINKS.txt, go through it and remember all symlinks to setup. + *

+ * (5.2) For every other zip entry, extract it into $STAGING_PREFIX and set execute permissions if necessary. + */ +public final class TermuxInstaller { + + private static final String LOG_TAG = "TermuxInstaller"; + + /** Performs bootstrap setup if necessary. */ + static void setupBootstrapIfNeeded(final Activity activity, final Runnable whenDone) { + String bootstrapErrorMessage; + Error filesDirectoryAccessibleError; + + // This will also call Context.getFilesDir(), which should ensure that termux files directory + // is created if it does not already exist + filesDirectoryAccessibleError = TermuxFileUtils.isTermuxFilesDirectoryAccessible(activity, true, true); + boolean isFilesDirectoryAccessible = filesDirectoryAccessibleError == null; + + // Termux can only be run as the primary user (device owner) since only that + // account has the expected file system paths. Verify that: + if (!PackageUtils.isCurrentUserThePrimaryUser(activity)) { + bootstrapErrorMessage = activity.getString(R.string.bootstrap_error_not_primary_user_message, + MarkdownUtils.getMarkdownCodeForString(TERMUX_PREFIX_DIR_PATH, false)); + Logger.logError(LOG_TAG, "isFilesDirectoryAccessible: " + isFilesDirectoryAccessible); + Logger.logError(LOG_TAG, bootstrapErrorMessage); + sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage); + MessageDialogUtils.exitAppWithErrorMessage(activity, + activity.getString(R.string.bootstrap_error_title), + bootstrapErrorMessage); + return; + } + + if (!isFilesDirectoryAccessible) { + bootstrapErrorMessage = Error.getMinimalErrorString(filesDirectoryAccessibleError); + //noinspection SdCardPath + if (PackageUtils.isAppInstalledOnExternalStorage(activity) && + !TermuxConstants.TERMUX_FILES_DIR_PATH.equals(activity.getFilesDir().getAbsolutePath().replaceAll("^/data/user/0/", "/data/data/"))) { + bootstrapErrorMessage += "\n\n" + activity.getString(R.string.bootstrap_error_installed_on_portable_sd, + MarkdownUtils.getMarkdownCodeForString(TERMUX_PREFIX_DIR_PATH, false)); + } + + Logger.logError(LOG_TAG, bootstrapErrorMessage); + sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage); + MessageDialogUtils.showMessage(activity, + activity.getString(R.string.bootstrap_error_title), + bootstrapErrorMessage, null); + return; + } + + // If prefix directory exists, even if its a symlink to a valid directory and symlink is not broken/dangling + if (FileUtils.directoryFileExists(TERMUX_PREFIX_DIR_PATH, true)) { + if (TermuxFileUtils.isTermuxPrefixDirectoryEmpty()) { + Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" exists but is empty or only contains specific unimportant files."); + } else { + whenDone.run(); + return; + } + } else if (FileUtils.fileExists(TERMUX_PREFIX_DIR_PATH, false)) { + Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" does not exist but another file exists at its destination."); + } + + final ProgressDialog progress = ProgressDialog.show(activity, null, activity.getString(R.string.bootstrap_installer_body), true, false); + new Thread() { + @Override + public void run() { + try { + Logger.logInfo(LOG_TAG, "Installing " + TermuxConstants.TERMUX_APP_NAME + " bootstrap packages."); + + Error error; + + // Delete prefix staging directory or any file at its destination + error = FileUtils.deleteFile("termux prefix staging directory", TERMUX_STAGING_PREFIX_DIR_PATH, true); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + + // Delete prefix directory or any file at its destination + error = FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + + // Create prefix staging directory if it does not already exist and set required permissions + error = TermuxFileUtils.isTermuxPrefixStagingDirectoryAccessible(true, true); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + + // Create prefix directory if it does not already exist and set required permissions + error = TermuxFileUtils.isTermuxPrefixDirectoryAccessible(true, true); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + + Logger.logInfo(LOG_TAG, "Extracting bootstrap zip to prefix staging directory \"" + TERMUX_STAGING_PREFIX_DIR_PATH + "\"."); + + final byte[] buffer = new byte[8096]; + final List> symlinks = new ArrayList<>(50); + + final byte[] zipBytes = loadZipBytes(); + try (ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry zipEntry; + while ((zipEntry = zipInput.getNextEntry()) != null) { + if (zipEntry.getName().equals("SYMLINKS.txt")) { + BufferedReader symlinksReader = new BufferedReader(new InputStreamReader(zipInput)); + String line; + while ((line = symlinksReader.readLine()) != null) { + String[] parts = line.split("←"); + if (parts.length != 2) + throw new RuntimeException("Malformed symlink line: " + line); + String oldPath = parts[0]; + String newPath = TERMUX_STAGING_PREFIX_DIR_PATH + "/" + parts[1]; + symlinks.add(Pair.create(oldPath, newPath)); + + error = ensureDirectoryExists(new File(newPath).getParentFile()); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + } + } else { + String zipEntryName = zipEntry.getName(); + File targetFile = new File(TERMUX_STAGING_PREFIX_DIR_PATH, zipEntryName); + boolean isDirectory = zipEntry.isDirectory(); + + error = ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile()); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + + if (!isDirectory) { + try (FileOutputStream outStream = new FileOutputStream(targetFile)) { + int readBytes; + while ((readBytes = zipInput.read(buffer)) != -1) + outStream.write(buffer, 0, readBytes); + } + if (zipEntryName.startsWith("bin/") || zipEntryName.startsWith("libexec") || + zipEntryName.startsWith("lib/apt/apt-helper") || zipEntryName.startsWith("lib/apt/methods")) { + //noinspection OctalInteger + Os.chmod(targetFile.getAbsolutePath(), 0700); + } + } + } + } + } + + if (symlinks.isEmpty()) + throw new RuntimeException("No SYMLINKS.txt encountered"); + for (Pair symlink : symlinks) { + Os.symlink(symlink.first, symlink.second); + } + + Logger.logInfo(LOG_TAG, "Moving termux prefix staging to prefix directory."); + + if (!TERMUX_STAGING_PREFIX_DIR.renameTo(TERMUX_PREFIX_DIR)) { + throw new RuntimeException("Moving termux prefix staging to prefix directory failed"); + } + + Logger.logInfo(LOG_TAG, "Bootstrap packages installed successfully."); + + // Recreate env file since termux prefix was wiped earlier + TermuxShellEnvironment.writeEnvironmentToFile(activity); + + activity.runOnUiThread(whenDone); + + } catch (final Exception e) { + showBootstrapErrorDialog(activity, whenDone, Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e))); + + } finally { + activity.runOnUiThread(() -> { + try { + progress.dismiss(); + } catch (RuntimeException e) { + // Activity already dismissed - ignore. + } + }); + } + } + }.start(); + } + + public static void showBootstrapErrorDialog(Activity activity, Runnable whenDone, String message) { + Logger.logErrorExtended(LOG_TAG, "Bootstrap Error:\n" + message); + + // Send a notification with the exception so that the user knows why bootstrap setup failed + sendBootstrapCrashReportNotification(activity, message); + + activity.runOnUiThread(() -> { + try { + new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_body) + .setNegativeButton(R.string.bootstrap_error_abort, (dialog, which) -> { + dialog.dismiss(); + activity.finish(); + }) + .setPositiveButton(R.string.bootstrap_error_try_again, (dialog, which) -> { + dialog.dismiss(); + FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true); + TermuxInstaller.setupBootstrapIfNeeded(activity, whenDone); + }).show(); + } catch (WindowManager.BadTokenException e1) { + // Activity already dismissed - ignore. + } + }); + } + + private static void sendBootstrapCrashReportNotification(Activity activity, String message) { + final String title = TermuxConstants.TERMUX_APP_NAME + " Bootstrap Error"; + + // Add info of all install Termux plugin apps as well since their target sdk or installation + // on external/portable sd card can affect Termux app files directory access or exec. + TermuxCrashUtils.sendCrashReportNotification(activity, LOG_TAG, + title, null, "## " + title + "\n\n" + message + "\n\n" + + TermuxUtils.getTermuxDebugMarkdownString(activity), + true, false, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES, true); + } + + static void setupStorageSymlinks(final Context context) { + final String LOG_TAG = "termux-storage"; + final String title = TermuxConstants.TERMUX_APP_NAME + " Setup Storage Error"; + + Logger.logInfo(LOG_TAG, "Setting up storage symlinks."); + + new Thread() { + public void run() { + try { + Error error; + File storageDir = TermuxConstants.TERMUX_STORAGE_HOME_DIR; + + error = FileUtils.clearDirectory("~/storage", storageDir.getAbsolutePath()); + if (error != null) { + Logger.logErrorAndShowToast(context, LOG_TAG, error.getMessage()); + Logger.logErrorExtended(LOG_TAG, "Setup Storage Error\n" + error.toString()); + TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null, + "## " + title + "\n\n" + Error.getErrorMarkdownString(error), + true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true); + return; + } + + Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/shared, ~/storage/downloads, ~/storage/dcim, ~/storage/pictures, ~/storage/music and ~/storage/movies for directories in \"" + Environment.getExternalStorageDirectory().getAbsolutePath() + "\"."); + + // Get primary storage root "/storage/emulated/0" symlink + File sharedDir = Environment.getExternalStorageDirectory(); + Os.symlink(sharedDir.getAbsolutePath(), new File(storageDir, "shared").getAbsolutePath()); + + File documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); + Os.symlink(documentsDir.getAbsolutePath(), new File(storageDir, "documents").getAbsolutePath()); + + File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + Os.symlink(downloadsDir.getAbsolutePath(), new File(storageDir, "downloads").getAbsolutePath()); + + File dcimDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); + Os.symlink(dcimDir.getAbsolutePath(), new File(storageDir, "dcim").getAbsolutePath()); + + File picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + Os.symlink(picturesDir.getAbsolutePath(), new File(storageDir, "pictures").getAbsolutePath()); + + File musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); + Os.symlink(musicDir.getAbsolutePath(), new File(storageDir, "music").getAbsolutePath()); + + File moviesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + Os.symlink(moviesDir.getAbsolutePath(), new File(storageDir, "movies").getAbsolutePath()); + + File podcastsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PODCASTS); + Os.symlink(podcastsDir.getAbsolutePath(), new File(storageDir, "podcasts").getAbsolutePath()); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + File audiobooksDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_AUDIOBOOKS); + Os.symlink(audiobooksDir.getAbsolutePath(), new File(storageDir, "audiobooks").getAbsolutePath()); + } + + // Dir 0 should ideally be for primary storage + // https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ContextImpl.java;l=818 + // https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java;l=219 + // https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java;l=181 + // https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/StorageManagerService.java;l=3796 + // https://cs.android.com/android/platform/superproject/+/android-7.0.0_r36:frameworks/base/services/core/java/com/android/server/MountService.java;l=3053 + + // Create "Android/data/com.termux" symlinks + File[] dirs = context.getExternalFilesDirs(null); + if (dirs != null && dirs.length > 0) { + for (int i = 0; i < dirs.length; i++) { + File dir = dirs[i]; + if (dir == null) continue; + String symlinkName = "external-" + i; + Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\"."); + Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath()); + } + } + + // Create "Android/media/com.termux" symlinks + dirs = context.getExternalMediaDirs(); + if (dirs != null && dirs.length > 0) { + for (int i = 0; i < dirs.length; i++) { + File dir = dirs[i]; + if (dir == null) continue; + String symlinkName = "media-" + i; + Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\"."); + Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath()); + } + } + + Logger.logInfo(LOG_TAG, "Storage symlinks created successfully."); + } catch (Exception e) { + Logger.logErrorAndShowToast(context, LOG_TAG, e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, "Setup Storage Error: Error setting up link", e); + TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null, + "## " + title + "\n\n" + Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)), + true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true); + } + } + }.start(); + } + + private static Error ensureDirectoryExists(File directory) { + return FileUtils.createDirectoryFile(directory.getAbsolutePath()); + } + + public static byte[] loadZipBytes() { + // Only load the shared library when necessary to save memory usage. + System.loadLibrary("termux-bootstrap"); + return getZip(); + } + + public static native byte[] getZip(); + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxService.java b/termux/termux-app/src/main/java/com/termux/app/TermuxService.java new file mode 100644 index 000000000..25caceed4 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxService.java @@ -0,0 +1,705 @@ +package com.termux.app; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.net.wifi.WifiManager; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import androidx.annotation.Nullable; +import com.termux.R; +import com.termux.app.terminal.TermuxTerminalSessionActivityClient; +import com.termux.app.terminal.TermuxTerminalSessionServiceClient; +import com.termux.shared.android.PermissionUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.ExecutionCommand.Runner; +import com.termux.shared.shell.command.runner.app.AppShell; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.shared.termux.plugins.TermuxPluginUtils; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.termux.shell.TermuxShellManager; +import com.termux.shared.termux.shell.TermuxShellUtils; +import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; +import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; +import java.util.ArrayList; +import java.util.List; + +/** + * A service holding a list of {@link TermuxSession} in {@link TermuxShellManager#mTermuxSessions} and background {@link AppShell} + * in {@link TermuxShellManager#mTermuxTasks}, showing a foreground notification while running so that it is not terminated. + * The user interacts with the session through {@link TermuxActivity}, but this service may outlive + * the activity when the user or the system disposes of the activity. In that case the user may + * restart {@link TermuxActivity} later to yet again access the sessions. + *

+ * In order to keep both terminal sessions and spawned processes (who may outlive the terminal sessions) alive as long + * as wanted by the user this service is a foreground service, {@link Service#startForeground(int, Notification)}. + *

+ * Optionally may hold a wake and a wifi lock, in which case that is shown in the notification - see + * {@link #buildNotification()}. + */ +public final class TermuxService extends Service implements AppShell.AppShellClient, TermuxSession.TermuxSessionClient { + + /** This service is only bound from inside the same process and never uses IPC. */ + class LocalBinder extends Binder { + public final TermuxService service = TermuxService.this; + } + + private final IBinder mBinder = new LocalBinder(); + + private final Handler mHandler = new Handler(); + + + /** The full implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} + * that holds activity references for activity related functions. + * Note that the service may often outlive the activity, so need to clear this reference. + */ + private TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; + + /** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} + * that does not hold activity references and only a service reference. + */ + private final TermuxTerminalSessionServiceClient mTermuxTerminalSessionServiceClient = new TermuxTerminalSessionServiceClient(this); + + /** + * Termux app shared properties manager, loaded from termux.properties + */ + private TermuxAppSharedProperties mProperties; + + /** + * Termux app shell manager + */ + private TermuxShellManager mShellManager; + + /** The wake lock and wifi lock are always acquired and released together. */ + private PowerManager.WakeLock mWakeLock; + private WifiManager.WifiLock mWifiLock; + + /** If the user has executed the {@link TERMUX_SERVICE#ACTION_STOP_SERVICE} intent. */ + boolean mWantsToStop = false; + + private static final String LOG_TAG = "TermuxService"; + + @Override + public void onCreate() { + Logger.logVerbose(LOG_TAG, "onCreate"); + + // Get Termux app SharedProperties without loading from disk since TermuxApplication handles + // load and TermuxActivity handles reloads + mProperties = TermuxAppSharedProperties.getProperties(); + + mShellManager = TermuxShellManager.getShellManager(); + + runStartForeground(); + } + + @SuppressLint("Wakelock") + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + + // Run again in case service is already started and onCreate() is not called + runStartForeground(); + + String action = null; + if (intent != null) { + Logger.logVerboseExtended(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + action = intent.getAction(); + } + + if (action != null) { + switch (action) { + case TERMUX_SERVICE.ACTION_STOP_SERVICE: + Logger.logDebug(LOG_TAG, "ACTION_STOP_SERVICE intent received"); + actionStopService(); + break; + case TERMUX_SERVICE.ACTION_WAKE_LOCK: + Logger.logDebug(LOG_TAG, "ACTION_WAKE_LOCK intent received"); + actionAcquireWakeLock(); + break; + case TERMUX_SERVICE.ACTION_WAKE_UNLOCK: + Logger.logDebug(LOG_TAG, "ACTION_WAKE_UNLOCK intent received"); + actionReleaseWakeLock(true); + break; + default: + Logger.logError(LOG_TAG, "Invalid action: \"" + action + "\""); + break; + } + } + + // If this service really do get killed, there is no point restarting it automatically - let the user do on next + // start of {@link Term): + return Service.START_NOT_STICKY; + } + + @Override + public void onDestroy() { + Logger.logVerbose(LOG_TAG, "onDestroy"); + + TermuxShellUtils.clearTermuxTMPDIR(true); + + actionReleaseWakeLock(false); + if (!mWantsToStop) + killAllTermuxExecutionCommands(); + + TermuxShellManager.onAppExit(this); + + runStopForeground(); + } + + @Override + public IBinder onBind(Intent intent) { + Logger.logVerbose(LOG_TAG, "onBind"); + return mBinder; + } + + @Override + public boolean onUnbind(Intent intent) { + Logger.logVerbose(LOG_TAG, "onUnbind"); + + // Since we cannot rely on {@link TermuxActivity.onDestroy()} to always complete, + // we unset clients here as well if it failed, so that we do not leave service and session + // clients with references to the activity. + if (mTermuxTerminalSessionActivityClient != null) + unsetTermuxTerminalSessionClient(); + return false; + } + + /** Make service run in foreground mode. */ + private void runStartForeground() { + setupNotificationChannel(); + startForeground(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification()); + } + + /** Make service leave foreground mode. */ + private void runStopForeground() { + stopForeground(true); + } + + /** Request to stop service. */ + private void requestStopService() { + Logger.logDebug(LOG_TAG, "Requesting to stop service"); + runStopForeground(); + stopSelf(); + } + + /** Process action to stop service. */ + private void actionStopService() { + mWantsToStop = true; + killAllTermuxExecutionCommands(); + requestStopService(); + } + + /** Kill all TermuxSessions and TermuxTasks by sending SIGKILL to their processes. + * + * For TermuxSessions, all sessions will be killed, whether user manually exited Termux or if + * onDestroy() was directly called because of unintended shutdown. The processing of results + * will only be done if user manually exited termux or if the session was started by a plugin + * which **expects** the result back via a pending intent. + * + * For TermuxTasks, only tasks that were started by a plugin which **expects** the result + * back via a pending intent will be killed, whether user manually exited Termux or if + * onDestroy() was directly called because of unintended shutdown. The processing of results + * will always be done for the tasks that are killed. The remaining processes will keep on + * running until the termux app process is killed by android, like by OOM, so we let them run + * as long as they can. + * + * Some plugin execution commands may not have been processed and added to mTermuxSessions and + * mTermuxTasks lists before the service is killed, so we maintain a separate + * mPendingPluginExecutionCommands list for those, so that we can notify the pending intent + * creators that execution was cancelled. + * + * Note that if user didn't manually exit Termux and if onDestroy() was directly called because + * of unintended shutdown, like android deciding to kill the service, then there will be no + * guarantee that onDestroy() will be allowed to finish and termux app process may be killed before + * it has finished. This means that in those cases some results may not be sent back to their + * creators for plugin commands but we still try to process whatever results can be processed + * despite the unreliable behaviour of onDestroy(). + * + * Note that if don't kill the processes started by plugins which **expect** the result back + * and notify their creators that they have been killed, then they may get stuck waiting for + * the results forever like in case of commands started by Termux:Tasker or RUN_COMMAND intent, + * since once TermuxService has been killed, no result will be sent back. They may still get + * stuck if termux app process gets killed, so for this case reasonable timeout values should + * be used, like in Tasker for the Termux:Tasker actions. + * + * We make copies of each list since items are removed inside the loop. + */ + private synchronized void killAllTermuxExecutionCommands() { + boolean processResult; + + Logger.logDebug(LOG_TAG, "Killing TermuxSessions=" + mShellManager.mTermuxSessions.size() + + ", TermuxTasks=" + mShellManager.mTermuxTasks.size()); + + List termuxSessions = new ArrayList<>(mShellManager.mTermuxSessions); + List termuxTasks = new ArrayList<>(mShellManager.mTermuxTasks); + + for (int i = 0; i < termuxSessions.size(); i++) { + ExecutionCommand executionCommand = termuxSessions.get(i).getExecutionCommand(); + processResult = mWantsToStop || executionCommand.isPluginExecutionCommandWithPendingResult(); + termuxSessions.get(i).killIfExecuting(this, processResult); + if (!processResult) + mShellManager.mTermuxSessions.remove(termuxSessions.get(i)); + } + + + for (int i = 0; i < termuxTasks.size(); i++) { + ExecutionCommand executionCommand = termuxTasks.get(i).getExecutionCommand(); + if (executionCommand.isPluginExecutionCommandWithPendingResult()) + termuxTasks.get(i).killIfExecuting(this, true); + else + mShellManager.mTermuxTasks.remove(termuxTasks.get(i)); + } + } + + + + /** Process action to acquire Power and Wi-Fi WakeLocks. */ + @SuppressLint({"WakelockTimeout", "BatteryLife"}) + private void actionAcquireWakeLock() { + if (mWakeLock != null) { + Logger.logDebug(LOG_TAG, "Ignoring acquiring WakeLocks since they are already held"); + return; + } + + Logger.logDebug(LOG_TAG, "Acquiring WakeLocks"); + + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TermuxConstants.TERMUX_APP_NAME.toLowerCase() + ":service-wakelock"); + mWakeLock.acquire(); + + // http://tools.android.com/tech-docs/lint-in-studio-2-3#TOC-WifiManager-Leak + WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, TermuxConstants.TERMUX_APP_NAME.toLowerCase()); + mWifiLock.acquire(); + + if (!PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) { + PermissionUtils.requestDisableBatteryOptimizations(this); + } + + updateNotification(); + + Logger.logDebug(LOG_TAG, "WakeLocks acquired successfully"); + + } + + /** Process action to release Power and Wi-Fi WakeLocks. */ + private void actionReleaseWakeLock(boolean updateNotification) { + if (mWakeLock == null && mWifiLock == null) { + Logger.logDebug(LOG_TAG, "Ignoring releasing WakeLocks since none are already held"); + return; + } + + Logger.logDebug(LOG_TAG, "Releasing WakeLocks"); + + if (mWakeLock != null) { + mWakeLock.release(); + mWakeLock = null; + } + + if (mWifiLock != null) { + mWifiLock.release(); + mWifiLock = null; + } + + if (updateNotification) + updateNotification(); + + Logger.logDebug(LOG_TAG, "WakeLocks released successfully"); + } + + + /** Create a TermuxTask. */ + @Nullable + public AppShell createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) { + return createTermuxTask(new ExecutionCommand(TermuxShellManager.getNextShellId(), executablePath, + arguments, stdin, workingDirectory, Runner.APP_SHELL.getName(), false)); + } + + /** Create a TermuxTask. */ + @Nullable + public synchronized AppShell createTermuxTask(ExecutionCommand executionCommand) { + if (executionCommand == null) return null; + + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); + + if (!Runner.APP_SHELL.equalsRunner(executionCommand.runner)) { + Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxTask()"); + return null; + } + + executionCommand.setShellCommandShellEnvironment = true; + + if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) + Logger.logVerboseExtended(LOG_TAG, executionCommand.toString()); + + AppShell newTermuxTask = AppShell.execute(this, executionCommand, this, + new TermuxShellEnvironment(), null,false); + if (newTermuxTask == null) { + Logger.logError(LOG_TAG, "Failed to execute new TermuxTask command for:\n" + executionCommand.getCommandIdAndLabelLogString()); + // If the execution command was started for a plugin, then process the error + if (executionCommand.isPluginExecutionCommand) + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + else { + Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs"); + Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString()); + } + return null; + } + + mShellManager.mTermuxTasks.add(newTermuxTask); + + updateNotification(); + + return newTermuxTask; + } + + /** Callback received when a TermuxTask finishes. */ + @Override + public void onAppShellExited(final AppShell termuxTask) { + mHandler.post(() -> { + if (termuxTask != null) { + ExecutionCommand executionCommand = termuxTask.getExecutionCommand(); + + Logger.logVerbose(LOG_TAG, "The onTermuxTaskExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command"); + + // If the execution command was started for a plugin, then process the results + if (executionCommand != null && executionCommand.isPluginExecutionCommand) + TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + + mShellManager.mTermuxTasks.remove(termuxTask); + } + + updateNotification(); + }); + } + + + /** + * Create a {@link TermuxSession}. + * Currently called by {@link TermuxTerminalSessionActivityClient#addNewSession(boolean, String)} to add a new {@link TermuxSession}. + */ + @Nullable + public TermuxSession createTermuxSession(String executablePath, String[] arguments, String stdin, + String workingDirectory, boolean isFailSafe, String sessionName) { + ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId(), + executablePath, arguments, stdin, workingDirectory, Runner.TERMINAL_SESSION.getName(), isFailSafe); + executionCommand.shellName = sessionName; + return createTermuxSession(executionCommand); + } + + /** Create a {@link TermuxSession}. */ + @Nullable + public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand) { + if (executionCommand == null) return null; + + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + + if (!Runner.TERMINAL_SESSION.equalsRunner(executionCommand.runner)) { + Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxSession()"); + return null; + } + + executionCommand.setShellCommandShellEnvironment = true; + executionCommand.terminalTranscriptRows = mProperties.getTerminalTranscriptRows(); + + if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) + Logger.logVerboseExtended(LOG_TAG, executionCommand.toString()); + + // If the execution command was started for a plugin, only then will the stdout be set + // Otherwise if command was manually started by the user like by adding a new terminal session, + // then no need to set stdout + TermuxSession newTermuxSession = TermuxSession.execute(this, executionCommand, getTermuxTerminalSessionClient(), + this, new TermuxShellEnvironment(), null, executionCommand.isPluginExecutionCommand); + if (newTermuxSession == null) { + Logger.logError(LOG_TAG, "Failed to execute new TermuxSession command for:\n" + executionCommand.getCommandIdAndLabelLogString()); + // If the execution command was started for a plugin, then process the error + if (executionCommand.isPluginExecutionCommand) + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + else { + Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs"); + Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString()); + } + return null; + } + + mShellManager.mTermuxSessions.add(newTermuxSession); + + // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if + // activity in is foreground + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated(); + + updateNotification(); + + // No need to recreate the activity since it likely just started and theme should already have applied + TermuxActivity.updateTermuxActivityStyling(this, false); + + return newTermuxSession; + } + + /** Remove a TermuxSession. */ + public synchronized int removeTermuxSession(TerminalSession sessionToRemove) { + int index = getIndexOfSession(sessionToRemove); + + if (index >= 0) + mShellManager.mTermuxSessions.get(index).finish(); + + return index; + } + + /** Callback received when a {@link TermuxSession} finishes. */ + @Override + public void onTermuxSessionExited(final TermuxSession termuxSession) { + if (termuxSession != null) { + ExecutionCommand executionCommand = termuxSession.getExecutionCommand(); + + Logger.logVerbose(LOG_TAG, "The onTermuxSessionExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); + + // If the execution command was started for a plugin, then process the results + if (executionCommand != null && executionCommand.isPluginExecutionCommand) + TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + + mShellManager.mTermuxSessions.remove(termuxSession); + + // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if + // activity in is foreground + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated(); + } + + updateNotification(); + } + + + /** If {@link TermuxActivity} has not bound to the {@link TermuxService} yet or is destroyed, then + * interface functions requiring the activity should not be available to the terminal sessions, + * so we just return the {@link #mTermuxTerminalSessionServiceClient}. Once {@link TermuxActivity} bind + * callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the + * {@link TermuxService#mTermuxTerminalSessionActivityClient} so that further terminal sessions are directly + * passed the {@link TermuxTerminalSessionActivityClient} object which fully implements the + * {@link TerminalSessionClient} interface. + * + * @return Returns the {@link TermuxTerminalSessionActivityClient} if {@link TermuxActivity} has bound with + * {@link TermuxService}, otherwise {@link TermuxTerminalSessionServiceClient}. + */ + public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() { + if (mTermuxTerminalSessionActivityClient != null) + return mTermuxTerminalSessionActivityClient; + else + return mTermuxTerminalSessionServiceClient; + } + + /** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the + * {@link TermuxService#mTermuxTerminalSessionActivityClient} variable and update the {@link TerminalSession} + * and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionServiceClient} + * earlier. + * + * @param termuxTerminalSessionActivityClient The {@link TermuxTerminalSessionActivityClient} object that fully + * implements the {@link TerminalSessionClient} interface. + */ + public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) { + mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient; + + for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) + mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionActivityClient); + } + + /** This should be called when {@link TermuxActivity} has been destroyed and in {@link #onUnbind(Intent)} + * so that the {@link TermuxService} and {@link TerminalSession} and {@link TerminalEmulator} + * clients do not hold an activity references. + */ + public synchronized void unsetTermuxTerminalSessionClient() { + for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) + mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionServiceClient); + + mTermuxTerminalSessionActivityClient = null; + } + + + + + + private Notification buildNotification() { + Resources res = getResources(); + + // Set pending intent to be launched when notification is clicked + Intent notificationIntent = TermuxActivity.newInstance(this); + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); + + + // Set notification text + int sessionCount = getTermuxSessionsSize(); + int taskCount = mShellManager.mTermuxTasks.size(); + String notificationText = sessionCount + " session" + (sessionCount == 1 ? "" : "s"); + if (taskCount > 0) { + notificationText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s"); + } + + final boolean wakeLockHeld = mWakeLock != null; + if (wakeLockHeld) notificationText += " (wake lock held)"; + + + // Set notification priority + // If holding a wake or wifi lock consider the notification of high priority since it's using power, + // otherwise use a low priority + int priority = (wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_LOW; + + + // Build the notification + Notification.Builder builder = NotificationUtils.geNotificationBuilder(this, + TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, priority, + TermuxConstants.TERMUX_APP_NAME, notificationText, null, + contentIntent, null, NotificationUtils.NOTIFICATION_MODE_SILENT); + if (builder == null) return null; + + // No need to show a timestamp: + builder.setShowWhen(false); + + // Set notification icon + builder.setSmallIcon(R.drawable.ic_service_notification); + + // Set background color for small notification icon + builder.setColor(0xFF607D8B); + + // TermuxSessions are always ongoing + builder.setOngoing(true); + + + // Set Exit button action + Intent exitIntent = new Intent(this, TermuxService.class).setAction(TERMUX_SERVICE.ACTION_STOP_SERVICE); + builder.addAction(android.R.drawable.ic_delete, res.getString(R.string.notification_action_exit), PendingIntent.getService(this, 0, exitIntent, 0)); + + + // Set Wakelock button actions + String newWakeAction = wakeLockHeld ? TERMUX_SERVICE.ACTION_WAKE_UNLOCK : TERMUX_SERVICE.ACTION_WAKE_LOCK; + Intent toggleWakeLockIntent = new Intent(this, TermuxService.class).setAction(newWakeAction); + String actionTitle = res.getString(wakeLockHeld ? R.string.notification_action_wake_unlock : R.string.notification_action_wake_lock); + int actionIcon = wakeLockHeld ? android.R.drawable.ic_lock_idle_lock : android.R.drawable.ic_lock_lock; + builder.addAction(actionIcon, actionTitle, PendingIntent.getService(this, 0, toggleWakeLockIntent, 0)); + + + return builder.build(); + } + + private void setupNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + + NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); + } + + /** Update the shown foreground service notification after making any changes that affect it. */ + private synchronized void updateNotification() { + if (mWakeLock == null && mShellManager.mTermuxSessions.isEmpty() && mShellManager.mTermuxTasks.isEmpty()) { + // Exit if we are updating after the user disabled all locks with no sessions or tasks running. + requestStopService(); + } else { + ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification()); + } + } + + + public synchronized boolean isTermuxSessionsEmpty() { + return mShellManager.mTermuxSessions.isEmpty(); + } + + public synchronized int getTermuxSessionsSize() { + return mShellManager.mTermuxSessions.size(); + } + + public synchronized List getTermuxSessions() { + return mShellManager.mTermuxSessions; + } + + @Nullable + public synchronized TermuxSession getTermuxSession(int index) { + if (index >= 0 && index < mShellManager.mTermuxSessions.size()) + return mShellManager.mTermuxSessions.get(index); + else + return null; + } + + @Nullable + public synchronized TermuxSession getTermuxSessionForTerminalSession(TerminalSession terminalSession) { + if (terminalSession == null) return null; + + for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) { + if (mShellManager.mTermuxSessions.get(i).getTerminalSession().equals(terminalSession)) + return mShellManager.mTermuxSessions.get(i); + } + + return null; + } + + public synchronized TermuxSession getLastTermuxSession() { + return mShellManager.mTermuxSessions.isEmpty() ? null : mShellManager.mTermuxSessions.get(mShellManager.mTermuxSessions.size() - 1); + } + + public synchronized int getIndexOfSession(TerminalSession terminalSession) { + if (terminalSession == null) return -1; + + for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) { + if (mShellManager.mTermuxSessions.get(i).getTerminalSession().equals(terminalSession)) + return i; + } + return -1; + } + + public synchronized TerminalSession getTerminalSessionForHandle(String sessionHandle) { + TerminalSession terminalSession; + for (int i = 0, len = mShellManager.mTermuxSessions.size(); i < len; i++) { + terminalSession = mShellManager.mTermuxSessions.get(i).getTerminalSession(); + if (terminalSession.mHandle.equals(sessionHandle)) + return terminalSession; + } + return null; + } + + public synchronized AppShell getTermuxTaskForShellName(String name) { + if (DataUtils.isNullOrEmpty(name)) return null; + AppShell appShell; + for (int i = 0, len = mShellManager.mTermuxTasks.size(); i < len; i++) { + appShell = mShellManager.mTermuxTasks.get(i); + String shellName = appShell.getExecutionCommand().shellName; + if (shellName != null && shellName.equals(name)) + return appShell; + } + return null; + } + + public synchronized TermuxSession getTermuxSessionForShellName(String name) { + if (DataUtils.isNullOrEmpty(name)) return null; + TermuxSession termuxSession; + for (int i = 0, len = mShellManager.mTermuxSessions.size(); i < len; i++) { + termuxSession = mShellManager.mTermuxSessions.get(i); + String shellName = termuxSession.getExecutionCommand().shellName; + if (shellName != null && shellName.equals(name)) + return termuxSession; + } + return null; + } + + + + public boolean wantsToStop() { + return mWantsToStop; + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java b/termux/termux-app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java new file mode 100644 index 000000000..3ce524f6b --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java @@ -0,0 +1,285 @@ +package com.termux.app.api.file; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Patterns; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +import com.termux.R; +import com.termux.shared.android.PackageUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.net.uri.UriUtils; +import com.termux.shared.interact.MessageDialogUtils; +import com.termux.shared.net.uri.UriScheme; +import com.termux.shared.termux.interact.TextInputDialogUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.app.TermuxService; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +public class FileReceiverActivity extends AppCompatActivity { + + static final String TERMUX_RECEIVEDIR = TermuxConstants.TERMUX_FILES_DIR_PATH + "/home/downloads"; + static final String EDITOR_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-file-editor"; + static final String URL_OPENER_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-url-opener"; + + /** + * If the activity should be finished when the name input dialog is dismissed. This is disabled + * before showing an error dialog, since the act of showing the error dialog will cause the + * name input dialog to be implicitly dismissed, and we do not want to finish the activity directly + * when showing the error dialog. + */ + boolean mFinishOnDismissNameDialog = true; + + private static final String API_TAG = TermuxConstants.TERMUX_APP_NAME + "FileReceiver"; + + private static final String LOG_TAG = "FileReceiverActivity"; + + static boolean isSharedTextAnUrl(String sharedText) { + return Patterns.WEB_URL.matcher(sharedText).matches() + || Pattern.matches("magnet:\\?xt=urn:btih:.*?", sharedText); + } + + @Override + protected void onResume() { + super.onResume(); + + final Intent intent = getIntent(); + final String action = intent.getAction(); + final String type = intent.getType(); + final String scheme = intent.getScheme(); + + Logger.logVerbose(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + + final String sharedTitle = IntentUtils.getStringExtraIfSet(intent, Intent.EXTRA_TITLE, null); + + if (Intent.ACTION_SEND.equals(action) && type != null) { + final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + final Uri sharedUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + + if (sharedUri != null) { + handleContentUri(sharedUri, sharedTitle); + } else if (sharedText != null) { + if (isSharedTextAnUrl(sharedText)) { + handleUrlAndFinish(sharedText); + } else { + String subject = IntentUtils.getStringExtraIfSet(intent, Intent.EXTRA_SUBJECT, null); + if (subject == null) subject = sharedTitle; + if (subject != null) subject += ".txt"; + promptNameAndSave(new ByteArrayInputStream(sharedText.getBytes(StandardCharsets.UTF_8)), subject); + } + } else { + showErrorDialogAndQuit("Send action without content - nothing to save."); + } + } else { + Uri dataUri = intent.getData(); + + if (dataUri == null) { + showErrorDialogAndQuit("Data uri not passed."); + return; + } + + if (UriScheme.SCHEME_CONTENT.equals(scheme)) { + handleContentUri(dataUri, sharedTitle); + } else if (UriScheme.SCHEME_FILE.equals(scheme)) { + Logger.logVerbose(LOG_TAG, "uri: \"" + dataUri + "\", path: \"" + dataUri.getPath() + "\", fragment: \"" + dataUri.getFragment() + "\""); + + // Get full path including fragment (anything after last "#") + String path = UriUtils.getUriFilePathWithFragment(dataUri); + if (DataUtils.isNullOrEmpty(path)) { + showErrorDialogAndQuit("File path from data uri is null, empty or invalid."); + return; + } + + File file = new File(path); + try { + FileInputStream in = new FileInputStream(file); + promptNameAndSave(in, file.getName()); + } catch (FileNotFoundException e) { + showErrorDialogAndQuit("Cannot open file: " + e.getMessage() + "."); + } + } else { + showErrorDialogAndQuit("Unable to receive any file or URL."); + } + } + } + + void showErrorDialogAndQuit(String message) { + mFinishOnDismissNameDialog = false; + MessageDialogUtils.showMessage(this, + API_TAG, message, + null, (dialog, which) -> finish(), + null, null, + dialog -> finish()); + } + + void handleContentUri(@NonNull final Uri uri, String subjectFromIntent) { + try { + Logger.logVerbose(LOG_TAG, "uri: \"" + uri + "\", path: \"" + uri.getPath() + "\", fragment: \"" + uri.getFragment() + "\""); + + String attachmentFileName = null; + + String[] projection = new String[]{OpenableColumns.DISPLAY_NAME}; + try (Cursor c = getContentResolver().query(uri, projection, null, null, null)) { + if (c != null && c.moveToFirst()) { + final int fileNameColumnId = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (fileNameColumnId >= 0) attachmentFileName = c.getString(fileNameColumnId); + } + } + + if (attachmentFileName == null) attachmentFileName = subjectFromIntent; + if (attachmentFileName == null) attachmentFileName = UriUtils.getUriFileBasename(uri, true); + + InputStream in = getContentResolver().openInputStream(uri); + promptNameAndSave(in, attachmentFileName); + } catch (Exception e) { + showErrorDialogAndQuit("Unable to handle shared content:\n\n" + e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, "handleContentUri(uri=" + uri + ") failed", e); + } + } + + void promptNameAndSave(final InputStream in, final String attachmentFileName) { + TextInputDialogUtils.textInput(this, R.string.title_file_received, attachmentFileName, + R.string.action_file_received_edit, text -> { + File outFile = saveStreamWithName(in, text); + if (outFile == null) return; + + final File editorProgramFile = new File(EDITOR_PROGRAM); + if (!editorProgramFile.isFile()) { + showErrorDialogAndQuit("The following file does not exist:\n$HOME/bin/termux-file-editor\n\n" + + "Create this file as a script or a symlink - it will be called with the received file as only argument."); + return; + } + + // Do this for the user if necessary: + //noinspection ResultOfMethodCallIgnored + editorProgramFile.setExecutable(true); + + final Uri scriptUri = UriUtils.getFileUri(EDITOR_PROGRAM); + + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, scriptUri); + executeIntent.setClass(FileReceiverActivity.this, TermuxService.class); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()}); + startService(executeIntent); + finish(); + }, + R.string.action_file_received_open_directory, text -> { + if (saveStreamWithName(in, text) == null) return; + + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, TERMUX_RECEIVEDIR); + executeIntent.setClass(FileReceiverActivity.this, TermuxService.class); + startService(executeIntent); + finish(); + }, + android.R.string.cancel, text -> finish(), dialog -> { + if (mFinishOnDismissNameDialog) finish(); + }); + } + + public File saveStreamWithName(InputStream in, String attachmentFileName) { + File receiveDir = new File(TERMUX_RECEIVEDIR); + + if (DataUtils.isNullOrEmpty(attachmentFileName)) { + showErrorDialogAndQuit("File name cannot be null or empty"); + return null; + } + + if (!receiveDir.isDirectory() && !receiveDir.mkdirs()) { + showErrorDialogAndQuit("Cannot create directory: " + receiveDir.getAbsolutePath()); + return null; + } + + try { + final File outFile = new File(receiveDir, attachmentFileName); + try (FileOutputStream f = new FileOutputStream(outFile)) { + byte[] buffer = new byte[4096]; + int readBytes; + while ((readBytes = in.read(buffer)) > 0) { + f.write(buffer, 0, readBytes); + } + } + return outFile; + } catch (IOException e) { + showErrorDialogAndQuit("Error saving file:\n\n" + e); + Logger.logStackTraceWithMessage(LOG_TAG, "Error saving file", e); + return null; + } + } + + void handleUrlAndFinish(final String url) { + final File urlOpenerProgramFile = new File(URL_OPENER_PROGRAM); + if (!urlOpenerProgramFile.isFile()) { + showErrorDialogAndQuit("The following file does not exist:\n$HOME/bin/termux-url-opener\n\n" + + "Create this file as a script or a symlink - it will be called with the shared URL as the first argument."); + return; + } + + // Do this for the user if necessary: + //noinspection ResultOfMethodCallIgnored + urlOpenerProgramFile.setExecutable(true); + + final Uri urlOpenerProgramUri = UriUtils.getFileUri(URL_OPENER_PROGRAM); + + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, urlOpenerProgramUri); + executeIntent.setClass(FileReceiverActivity.this, TermuxService.class); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{url}); + startService(executeIntent); + finish(); + } + + /** + * Update {@link TERMUX_APP#FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME} component state depending on + * {@link TermuxPropertyConstants#KEY_DISABLE_FILE_SHARE_RECEIVER} value and + * {@link TERMUX_APP#FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME} component state depending on + * {@link TermuxPropertyConstants#KEY_DISABLE_FILE_VIEW_RECEIVER} value. + */ + public static void updateFileReceiverActivityComponentsState(@NonNull Context context) { + new Thread() { + @Override + public void run() { + TermuxAppSharedProperties properties = TermuxAppSharedProperties.getProperties(); + + String errmsg; + boolean state; + + state = !properties.isFileShareReceiverDisabled(); + Logger.logVerbose(LOG_TAG, "Setting " + TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME + " component state to " + state); + errmsg = PackageUtils.setComponentState(context,TermuxConstants.TERMUX_PACKAGE_NAME, + TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME, + state, null, false, false); + if (errmsg != null) + Logger.logError(LOG_TAG, errmsg); + + state = !properties.isFileViewReceiverDisabled(); + Logger.logVerbose(LOG_TAG, "Setting " + TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME + " component state to " + state); + errmsg = PackageUtils.setComponentState(context,TermuxConstants.TERMUX_PACKAGE_NAME, + TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME, + state, null, false, false); + if (errmsg != null) + Logger.logError(LOG_TAG, errmsg); + + } + }.start(); + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/models/UserAction.java b/termux/termux-app/src/main/java/com/termux/app/models/UserAction.java new file mode 100644 index 000000000..f380c07cd --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/models/UserAction.java @@ -0,0 +1,16 @@ +package com.termux.app.models; + +public enum UserAction { + REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript"); + + private final String name; + + UserAction(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java new file mode 100644 index 000000000..7e8b0e9d3 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java @@ -0,0 +1,284 @@ +package com.termux.app.terminal; + +import android.content.Context; +import android.graphics.Rect; +import android.inputmethodservice.InputMethodService; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.view.inputmethod.EditorInfo; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.core.view.WindowInsetsCompat; + +import com.termux.app.TermuxActivity; +import com.termux.shared.logger.Logger; +import com.termux.shared.view.ViewUtils; + + +/** + * The {@link TermuxActivity} relies on {@link android.view.WindowManager.LayoutParams#SOFT_INPUT_ADJUST_RESIZE)} + * set by {@link TermuxTerminalViewClient#setSoftKeyboardState(boolean, boolean)} to automatically + * resize the view and push the terminal up when soft keyboard is opened. However, this does not + * always work properly. When `enforce-char-based-input=true` is set in `termux.properties` + * and {@link com.termux.view.TerminalView#onCreateInputConnection(EditorInfo)} sets the inputType + * to `InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS` + * instead of the default `InputType.TYPE_NULL` for termux, some keyboards may still show suggestions. + * Gboard does too, but only when text is copied and clipboard suggestions **and** number keys row + * toggles are enabled in its settings. When number keys row toggle is not enabled, Gboard will still + * show the row but will switch it with suggestions if needed. If its enabled, then number keys row + * is always shown and suggestions are shown in an additional row on top of it. This additional row is likely + * part of the candidates view returned by the keyboard app in {@link InputMethodService#onCreateCandidatesView()}. + * + * With the above configuration, the additional clipboard suggestions row partially covers the + * extra keys/terminal. Reopening the keyboard/activity does not fix the issue. This is either a bug + * in the Android OS where it does not consider the candidate's view height in its calculation to push + * up the view or because Gboard does not include the candidate's view height in the height reported + * to android that should be used, hence causing an overlap. + * + * Gboard logs the following entry to `logcat` when its opened with or without the suggestions bar showing: + * I/KeyboardViewUtil: KeyboardViewUtil.calculateMaxKeyboardBodyHeight():62 leave 500 height for app when screen height:2392, header height:176 and isFullscreenMode:false, so the max keyboard body height is:1716 + * where `keyboard_height = screen_height - height_for_app - header_height` (62 is a hardcoded value in Gboard source code and may be a version number) + * So this may in fact be due to Gboard but https://stackoverflow.com/questions/57567272 suggests + * otherwise. Another similar report https://stackoverflow.com/questions/66761661. + * Also check https://github.com/termux/termux-app/issues/1539. + * + * This overlap may happen even without `enforce-char-based-input=true` for keyboards with extended layouts + * like number row, etc. + * + * To fix these issues, `activity_termux.xml` has the constant 1sp transparent + * `activity_termux_bottom_space_view` View at the bottom. This will appear as a line matching the + * activity theme. When {@link TermuxActivity} {@link ViewTreeObserver.OnGlobalLayoutListener} is + * called when any of the sub view layouts change, like keyboard opening/closing keyboard, + * extra keys/input view switched, etc, we check if the bottom space view is visible or not. + * If its not, then we add a margin to the bottom of the root view, so that the keyboard does not + * overlap the extra keys/terminal, since the margin will push up the view. By default the margin + * added is equal to the height of the hidden part of extra keys/terminal. For Gboard's case, the + * hidden part equals the `header_height`. The updates to margins may cause a jitter in some cases + * when the view is redrawn if the margin is incorrect, but logic has been implemented to avoid that. + */ +public class TermuxActivityRootView extends LinearLayout implements ViewTreeObserver.OnGlobalLayoutListener { + + public TermuxActivity mActivity; + public Integer marginBottom; + public Integer lastMarginBottom; + public long lastMarginBottomTime; + public long lastMarginBottomExtraTime; + + /** Log root view events. */ + private boolean ROOT_VIEW_LOGGING_ENABLED = false; + + private static final String LOG_TAG = "TermuxActivityRootView"; + + private static int mStatusBarHeight; + + public TermuxActivityRootView(Context context) { + super(context); + } + + public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setActivity(TermuxActivity activity) { + mActivity = activity; + } + + /** + * Sets whether root view logging is enabled or not. + * + * @param value The boolean value that defines the state. + */ + public void setIsRootViewLoggingEnabled(boolean value) { + ROOT_VIEW_LOGGING_ENABLED = value; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (marginBottom != null) { + if (ROOT_VIEW_LOGGING_ENABLED) + Logger.logVerbose(LOG_TAG, "onMeasure: Setting bottom margin to " + marginBottom); + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams(); + params.setMargins(0, 0, 0, marginBottom); + setLayoutParams(params); + marginBottom = null; + requestLayout(); + } + } + + @Override + public void onGlobalLayout() { + if (mActivity == null || !mActivity.isVisible()) return; + + View bottomSpaceView = mActivity.getTermuxActivityBottomSpaceView(); + if (bottomSpaceView == null) return; + + boolean root_view_logging_enabled = ROOT_VIEW_LOGGING_ENABLED; + + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, ":\nonGlobalLayout:"); + + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) getLayoutParams(); + + // Get the position Rects of the bottom space view and the main window holding it + Rect[] windowAndViewRects = ViewUtils.getWindowAndViewRects(bottomSpaceView, mStatusBarHeight); + if (windowAndViewRects == null) + return; + + Rect windowAvailableRect = windowAndViewRects[0]; + Rect bottomSpaceViewRect = windowAndViewRects[1]; + + // If the bottomSpaceViewRect is inside the windowAvailableRect, then it must be completely visible + //boolean isVisible = windowAvailableRect.contains(bottomSpaceViewRect); // rect.right comparison often fails in landscape + boolean isVisible = ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect); + boolean isVisibleBecauseMargin = (windowAvailableRect.bottom == bottomSpaceViewRect.bottom) && params.bottomMargin > 0; + boolean isVisibleBecauseExtraMargin = ((bottomSpaceViewRect.bottom - windowAvailableRect.bottom) < 0); + + if (root_view_logging_enabled) { + Logger.logVerbose(LOG_TAG, "windowAvailableRect " + ViewUtils.toRectString(windowAvailableRect) + ", bottomSpaceViewRect " + ViewUtils.toRectString(bottomSpaceViewRect)); + Logger.logVerbose(LOG_TAG, "windowAvailableRect.bottom " + windowAvailableRect.bottom + + ", bottomSpaceViewRect.bottom " +bottomSpaceViewRect.bottom + + ", diff " + (bottomSpaceViewRect.bottom - windowAvailableRect.bottom) + ", bottom " + params.bottomMargin + + ", isVisible " + windowAvailableRect.contains(bottomSpaceViewRect) + ", isRectAbove " + ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect) + + ", isVisibleBecauseMargin " + isVisibleBecauseMargin + ", isVisibleBecauseExtraMargin " + isVisibleBecauseExtraMargin); + } + + // If the bottomSpaceViewRect is visible, then remove the margin if needed + if (isVisible) { + // If visible because of margin, i.e the bottom of bottomSpaceViewRect equals that of windowAvailableRect + // and a margin has been added + // Necessary so that we don't get stuck in an infinite loop since setting margin + // will call OnGlobalLayoutListener again and next time bottom space view + // will be visible and margin will be set to 0, which again will call + // OnGlobalLayoutListener... + // Calling addTermuxActivityRootViewGlobalLayoutListener with a delay fails to + // set appropriate margins when views are changed quickly since some changes + // may be missed. + if (isVisibleBecauseMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Visible due to margin"); + + // Once the view has been redrawn with new margin, we set margin back to 0 so that + // when next time onMeasure() is called, margin 0 is used. This is necessary for + // cases when view has been redrawn with new margin because bottom space view was + // hidden by keyboard and then view was redrawn again due to layout change (like + // keyboard symbol view is switched to), android will add margin below its new position + // if its greater than 0, which was already above the keyboard creating x2x margin. + // Adding time check since moving split screen divider in landscape causes jitter + // and prevents some infinite loops + if ((System.currentTimeMillis() - lastMarginBottomTime) > 40) { + lastMarginBottomTime = System.currentTimeMillis(); + marginBottom = 0; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Ignoring restoring marginBottom to 0 since called to quickly"); + } + + return; + } + + boolean setMargin = params.bottomMargin != 0; + + // If visible because of extra margin, i.e the bottom of bottomSpaceViewRect is above that of windowAvailableRect + // onGlobalLayout: windowAvailableRect 1408, bottomSpaceViewRect 1232, diff -176, bottom 0, isVisible true, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false + // onGlobalLayout: Bottom margin already equals 0 + if (isVisibleBecauseExtraMargin) { + // Adding time check since prevents infinite loops, like in landscape mode in freeform mode in Taskbar + if ((System.currentTimeMillis() - lastMarginBottomExtraTime) > 40) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Resetting margin since visible due to extra margin"); + lastMarginBottomExtraTime = System.currentTimeMillis(); + // lastMarginBottom must be invalid. May also happen when keyboards are changed. + lastMarginBottom = null; + setMargin = true; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Ignoring resetting margin since visible due to extra margin since called to quickly"); + } + } + + if (setMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Setting bottom margin to 0"); + params.setMargins(0, 0, 0, 0); + setLayoutParams(params); + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Bottom margin already equals 0"); + // This is done so that when next time onMeasure() is called, lastMarginBottom is used. + // This is done since we **expect** the keyboard to have same dimensions next time layout + // changes, so best set margin while view is drawn the first time, otherwise it will + // cause a jitter when OnGlobalLayoutListener is called with margin 0 and it sets the + // likely same lastMarginBottom again and requesting a redraw. Hopefully, this logic + // works fine for all cases. + marginBottom = lastMarginBottom; + } + } + // ELse find the part of the extra keys/terminal that is hidden and add a margin accordingly + else { + int pxHidden = bottomSpaceViewRect.bottom - windowAvailableRect.bottom; + + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "pxHidden " + pxHidden + ", bottom " + params.bottomMargin); + + boolean setMargin = params.bottomMargin != pxHidden; + + // If invisible despite margin, i.e a margin was added, but the bottom of bottomSpaceViewRect + // is still below that of windowAvailableRect, this will trigger OnGlobalLayoutListener + // again, so that margins are set properly. May happen when toolbar/extra keys is disabled + // and enabled from left drawer, just like case for isVisibleBecauseExtraMargin. + // onMeasure: Setting bottom margin to 176 + // onGlobalLayout: windowAvailableRect 1232, bottomSpaceViewRect 1408, diff 176, bottom 176, isVisible false, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false + // onGlobalLayout: Bottom margin already equals 176 + if (pxHidden > 0 && params.bottomMargin > 0) { + if (pxHidden != params.bottomMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since not visible due to wrong margin"); + pxHidden = 0; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Force setting margin since not visible despite required margin"); + } + setMargin = true; + } + + if (pxHidden < 0) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since new margin is negative"); + pxHidden = 0; + } + + + if (setMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Setting bottom margin to " + pxHidden); + params.setMargins(0, 0, 0, pxHidden); + setLayoutParams(params); + lastMarginBottom = pxHidden; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Bottom margin already equals " + pxHidden); + } + } + } + + public static class WindowInsetsListener implements View.OnApplyWindowInsetsListener { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mStatusBarHeight = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.statusBars()).top; + // Let view window handle insets however it wants + return v.onApplyWindowInsets(insets); + } + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java new file mode 100644 index 000000000..bf914b977 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -0,0 +1,109 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; +import com.termux.shared.theme.NightMode; +import com.termux.shared.theme.ThemeUtils; +import com.termux.terminal.TerminalSession; + +import java.util.List; + +public class TermuxSessionsListViewController extends ArrayAdapter 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.item_terminal_sessions_list, sessionList); + this.mActivity = activity; + } + + @SuppressLint("SetTextI18n") + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + View sessionRowView = convertView; + if (sessionRowView == null) { + LayoutInflater inflater = mActivity.getLayoutInflater(); + sessionRowView = inflater.inflate(R.layout.item_terminal_sessions_list, parent, false); + } + + TextView sessionTitleView = sessionRowView.findViewById(R.id.session_title); + + TerminalSession sessionAtRow = getItem(position).getTerminalSession(); + if (sessionAtRow == null) { + sessionTitleView.setText("null session"); + return sessionRowView; + } + + boolean shouldEnableDarkTheme = ThemeUtils.shouldEnableDarkTheme(mActivity, NightMode.getAppNightMode().getName()); + + if (shouldEnableDarkTheme) { + sessionTitleView.setBackground( + ContextCompat.getDrawable(mActivity, R.drawable.session_background_black_selected) + ); + } + + String name = sessionAtRow.mSessionName; + String sessionTitle = sessionAtRow.getTitle(); + + String numberPart = "[" + (position + 1) + "] "; + String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name); + String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle)); + + String fullSessionTitle = numberPart + sessionNamePart + sessionTitlePart; + SpannableString fullSessionTitleStyled = new SpannableString(fullSessionTitle); + fullSessionTitleStyled.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + fullSessionTitleStyled.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), fullSessionTitle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + sessionTitleView.setText(fullSessionTitleStyled); + + boolean sessionRunning = sessionAtRow.isRunning(); + + if (sessionRunning) { + sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); + } else { + sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + } + int defaultColor = shouldEnableDarkTheme ? Color.WHITE : Color.BLACK; + int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; + sessionTitleView.setTextColor(color); + return sessionRowView; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + TermuxSession clickedSession = getItem(position); + mActivity.getTermuxTerminalSessionClient().setCurrentSession(clickedSession.getTerminalSession()); + mActivity.getDrawer().closeDrawers(); + } + + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { + final TermuxSession selectedSession = getItem(position); + mActivity.getTermuxTerminalSessionClient().renameSession(selectedSession.getTerminalSession()); + return true; + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java new file mode 100644 index 000000000..ee1d1452a --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java @@ -0,0 +1,530 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.pm.PackageManager; +import android.graphics.Typeface; +import android.media.AudioAttributes; +import android.media.SoundPool; +import android.text.TextUtils; +import android.widget.ListView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.R; +import com.termux.shared.interact.ShareUtils; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; +import com.termux.shared.termux.interact.TextInputDialogUtils; +import com.termux.app.TermuxActivity; +import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; +import com.termux.shared.termux.TermuxConstants; +import com.termux.app.TermuxService; +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; +import com.termux.shared.termux.terminal.io.BellHandler; +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalColors; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; +import com.termux.terminal.TextStyle; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Properties; + +/** The {@link TerminalSessionClient} implementation that may require an {@link Activity} for its interface methods. */ +public class TermuxTerminalSessionActivityClient extends TermuxTerminalSessionClientBase { + + private final TermuxActivity mActivity; + + private static final int MAX_SESSIONS = 8; + + private SoundPool mBellSoundPool; + + private int mBellSoundId; + + private static final String LOG_TAG = "TermuxTerminalSessionActivityClient"; + + public TermuxTerminalSessionActivityClient(TermuxActivity activity) { + this.mActivity = activity; + } + + /** + * Should be called when mActivity.onCreate() is called + */ + public void onCreate() { + // Set terminal fonts and colors + checkForFontAndColors(); + } + + /** + * Should be called when mActivity.onStart() is called + */ + public void onStart() { + // The service has connected, but data may have changed since we were last in the foreground. + // Get the session stored in shared preferences stored by {@link #onStop} if its valid, + // otherwise get the last session currently running. + if (mActivity.getTermuxService() != null) { + setCurrentSession(getCurrentStoredSessionOrLast()); + termuxSessionListNotifyUpdated(); + } + + // The current terminal session may have changed while being away, force + // a refresh of the displayed terminal. + mActivity.getTerminalView().onScreenUpdated(); + } + + /** + * Should be called when mActivity.onResume() is called + */ + public void onResume() { + // Just initialize the mBellSoundPool and load the sound, otherwise bell might not run + // the first time bell key is pressed and play() is called, since sound may not be loaded + // quickly enough before the call to play(). https://stackoverflow.com/questions/35435625 + loadBellSoundPool(); + } + + /** + * Should be called when mActivity.onStop() is called + */ + public void onStop() { + // Store current session in shared preferences so that it can be restored later in + // {@link #onStart} if needed. + setCurrentStoredSession(); + + // Release mBellSoundPool resources, specially to prevent exceptions like the following to be thrown + // java.util.concurrent.TimeoutException: android.media.SoundPool.finalize() timed out after 10 seconds + // Bell is not played in background anyways + // Related: https://stackoverflow.com/a/28708351/14686958 + releaseBellSoundPool(); + } + + /** + * Should be called when mActivity.reloadActivityStyling() is called + */ + public void onReloadActivityStyling() { + // Set terminal fonts and colors + checkForFontAndColors(); + } + + + + @Override + public void onTextChanged(@NonNull TerminalSession changedSession) { + if (!mActivity.isVisible()) return; + + if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated(); + } + + @Override + public void onTitleChanged(@NonNull TerminalSession updatedSession) { + if (!mActivity.isVisible()) return; + + if (updatedSession != mActivity.getCurrentSession()) { + // Only show toast for other sessions than the current one, since the user + // probably consciously caused the title change to change in the current session + // and don't want an annoying toast for that. + mActivity.showToast(toToastTitle(updatedSession), true); + } + + termuxSessionListNotifyUpdated(); + } + + @Override + public void onSessionFinished(@NonNull TerminalSession finishedSession) { + TermuxService service = mActivity.getTermuxService(); + + if (service == null || service.wantsToStop()) { + // The service wants to stop as soon as possible. + mActivity.finishActivityIfNotFinishing(); + return; + } + + int index = service.getIndexOfSession(finishedSession); + + // For plugin commands that expect the result back, we should immediately close the session + // and send the result back instead of waiting fo the user to press enter. + // The plugin can handle/show errors itself. + boolean isPluginExecutionCommandWithPendingResult = false; + TermuxSession termuxSession = service.getTermuxSession(index); + if (termuxSession != null) { + isPluginExecutionCommandWithPendingResult = termuxSession.getExecutionCommand().isPluginExecutionCommandWithPendingResult(); + if (isPluginExecutionCommandWithPendingResult) + Logger.logVerbose(LOG_TAG, "The \"" + finishedSession.mSessionName + "\" session will be force finished automatically since result in pending."); + } + + if (mActivity.isVisible() && finishedSession != mActivity.getCurrentSession()) { + // Show toast for non-current sessions that exit. + // Verify that session was not removed before we got told about it finishing: + if (index >= 0) + mActivity.showToast(toToastTitle(finishedSession) + " - exited", true); + } + + if (mActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + // On Android TV devices we need to use older behaviour because we may + // not be able to have multiple launcher icons. + if (service.getTermuxSessionsSize() > 1 || isPluginExecutionCommandWithPendingResult) { + removeFinishedSession(finishedSession); + } + } else { + // Once we have a separate launcher icon for the failsafe session, it + // should be safe to auto-close session on exit code '0' or '130'. + if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130 || isPluginExecutionCommandWithPendingResult) { + removeFinishedSession(finishedSession); + } + } + } + + @Override + public void onCopyTextToClipboard(@NonNull TerminalSession session, String text) { + if (!mActivity.isVisible()) return; + + ShareUtils.copyTextToClipboard(mActivity, text); + } + + @Override + public void onPasteTextFromClipboard(@Nullable TerminalSession session) { + if (!mActivity.isVisible()) return; + + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + mActivity.getTerminalView().mEmulator.paste(text); + } + + @Override + public void onBell(@NonNull TerminalSession session) { + if (!mActivity.isVisible()) return; + + switch (mActivity.getProperties().getBellBehaviour()) { + case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_VIBRATE: + BellHandler.getInstance(mActivity).doBell(); + break; + case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_BEEP: + loadBellSoundPool(); + if (mBellSoundPool != null) + mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f); + break; + case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_IGNORE: + // Ignore the bell character. + break; + } + } + + @Override + public void onColorsChanged(@NonNull TerminalSession changedSession) { + if (mActivity.getCurrentSession() == changedSession) + updateBackgroundColor(); + } + + @Override + public void onTerminalCursorStateChange(boolean enabled) { + // Do not start cursor blinking thread if activity is not visible + if (enabled && !mActivity.isVisible()) { + Logger.logVerbose(LOG_TAG, "Ignoring call to start cursor blinking since activity is not visible"); + return; + } + + // If cursor is to enabled now, then start cursor blinking if blinking is enabled + // otherwise stop cursor blinking + mActivity.getTerminalView().setTerminalCursorBlinkerState(enabled, false); + } + + @Override + public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) { + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + TermuxSession termuxSession = service.getTermuxSessionForTerminalSession(terminalSession); + if (termuxSession != null) + termuxSession.getExecutionCommand().mPid = pid; + } + + + /** + * Should be called when mActivity.onResetTerminalSession() is called + */ + public void onResetTerminalSession() { + // Ensure blinker starts again after reset if cursor blinking was disabled before reset like + // with "tput civis" which would have called onTerminalCursorStateChange() + mActivity.getTerminalView().setTerminalCursorBlinkerState(true, true); + } + + + + @Override + public Integer getTerminalCursorStyle() { + return mActivity.getProperties().getTerminalCursorStyle(); + } + + + + /** Load mBellSoundPool */ + private synchronized void loadBellSoundPool() { + if (mBellSoundPool == null) { + mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes( + new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build(); + + try { + mBellSoundId = mBellSoundPool.load(mActivity, R.raw.bell, 1); + } catch (Exception e){ + // Catch java.lang.RuntimeException: Unable to resume activity {com.termux/com.itsaky.androidide.activities.TermuxActivity}: android.content.res.Resources$NotFoundException: File res/raw/bell.ogg from drawable resource ID + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to load bell sound pool", e); + } + } + } + + /** Release mBellSoundPool resources */ + private synchronized void releaseBellSoundPool() { + if (mBellSoundPool != null) { + mBellSoundPool.release(); + mBellSoundPool = null; + } + } + + + + /** Try switching to session. */ + public void setCurrentSession(TerminalSession session) { + if (session == null) return; + + if (mActivity.getTerminalView().attachSession(session)) { + // notify about switched session if not already displaying the session + notifyOfSessionChange(); + } + + // We call the following even when the session is already being displayed since config may + // be stale, like current session not selected or scrolled to. + checkAndScrollToSession(session); + updateBackgroundColor(); + } + + void notifyOfSessionChange() { + if (!mActivity.isVisible()) return; + + if (!mActivity.getProperties().areTerminalSessionChangeToastsDisabled()) { + TerminalSession session = mActivity.getCurrentSession(); + mActivity.showToast(toToastTitle(session), false); + } + } + + public void switchToSession(boolean forward) { + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + TerminalSession currentTerminalSession = mActivity.getCurrentSession(); + int index = service.getIndexOfSession(currentTerminalSession); + int size = service.getTermuxSessionsSize(); + if (forward) { + if (++index >= size) index = 0; + } else { + if (--index < 0) index = size - 1; + } + + TermuxSession termuxSession = service.getTermuxSession(index); + if (termuxSession != null) + setCurrentSession(termuxSession.getTerminalSession()); + } + + public void switchToSession(int index) { + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + TermuxSession termuxSession = service.getTermuxSession(index); + if (termuxSession != null) + setCurrentSession(termuxSession.getTerminalSession()); + } + + @SuppressLint("InflateParams") + public void renameSession(final TerminalSession sessionToRename) { + if (sessionToRename == null) return; + + TextInputDialogUtils.textInput(mActivity, R.string.title_rename_session, sessionToRename.mSessionName, R.string.action_rename_session_confirm, text -> { + renameSession(sessionToRename, text); + termuxSessionListNotifyUpdated(); + }, -1, null, -1, null, null); + } + + private void renameSession(TerminalSession sessionToRename, String text) { + if (sessionToRename == null) return; + sessionToRename.mSessionName = text; + TermuxService service = mActivity.getTermuxService(); + if (service != null) { + TermuxSession termuxSession = service.getTermuxSessionForTerminalSession(sessionToRename); + if (termuxSession != null) + termuxSession.getExecutionCommand().shellName = text; + } + } + + public void addNewSession(boolean isFailSafe, String sessioName) { + addNewSession(isFailSafe, sessioName, null); + } + + public void addNewSession(boolean isFailSafe, String sessionName, String workingDirectory) { + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + if (service.getTermuxSessionsSize() >= MAX_SESSIONS) { + new AlertDialog.Builder(mActivity).setTitle(R.string.title_max_terminals_reached).setMessage(R.string.msg_max_terminals_reached) + .setPositiveButton(android.R.string.ok, null).show(); + } else { + TerminalSession currentSession = mActivity.getCurrentSession(); + + if (workingDirectory == null) { + if (currentSession == null) { + workingDirectory = mActivity.getProperties().getDefaultWorkingDirectory(); + } else { + workingDirectory = currentSession.getCwd(); + } + } + + TermuxSession newTermuxSession = service.createTermuxSession(null, null, null, workingDirectory, isFailSafe, sessionName); + if (newTermuxSession == null) return; + + TerminalSession newTerminalSession = newTermuxSession.getTerminalSession(); + setCurrentSession(newTerminalSession); + + mActivity.getDrawer().closeDrawers(); + } + } + + public void setCurrentStoredSession() { + TerminalSession currentSession = mActivity.getCurrentSession(); + if (currentSession != null) + mActivity.getPreferences().setCurrentSession(currentSession.mHandle); + else + mActivity.getPreferences().setCurrentSession(null); + } + + /** The current session as stored or the last one if that does not exist. */ + public TerminalSession getCurrentStoredSessionOrLast() { + TerminalSession stored = getCurrentStoredSession(); + + if (stored != null) { + // If a stored session is in the list of currently running sessions, then return it + return stored; + } else { + // Else return the last session currently running + TermuxService service = mActivity.getTermuxService(); + if (service == null) return null; + + TermuxSession termuxSession = service.getLastTermuxSession(); + if (termuxSession != null) + return termuxSession.getTerminalSession(); + else + return null; + } + } + + private TerminalSession getCurrentStoredSession() { + String sessionHandle = mActivity.getPreferences().getCurrentSession(); + + // If no session is stored in shared preferences + if (sessionHandle == null) + return null; + + // Check if the session handle found matches one of the currently running sessions + TermuxService service = mActivity.getTermuxService(); + if (service == null) return null; + + return service.getTerminalSessionForHandle(sessionHandle); + } + + public void removeFinishedSession(TerminalSession finishedSession) { + // Return pressed with finished session - remove it. + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + int index = service.removeTermuxSession(finishedSession); + + int size = service.getTermuxSessionsSize(); + if (size == 0) { + // There are no sessions to show, so finish the activity. + mActivity.finishActivityIfNotFinishing(); + } else { + if (index >= size) { + index = size - 1; + } + TermuxSession termuxSession = service.getTermuxSession(index); + if (termuxSession != null) + setCurrentSession(termuxSession.getTerminalSession()); + } + } + + public void termuxSessionListNotifyUpdated() { + mActivity.termuxSessionListNotifyUpdated(); + } + + public void checkAndScrollToSession(TerminalSession session) { + if (!mActivity.isVisible()) return; + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + final int indexOfSession = service.getIndexOfSession(session); + if (indexOfSession < 0) return; + final ListView termuxSessionsListView = mActivity.findViewById(R.id.terminal_sessions_list); + if (termuxSessionsListView == null) return; + + termuxSessionsListView.setItemChecked(indexOfSession, true); + // Delay is necessary otherwise sometimes scroll to newly added session does not happen + termuxSessionsListView.postDelayed(() -> termuxSessionsListView.smoothScrollToPosition(indexOfSession), 1000); + } + + + String toToastTitle(TerminalSession session) { + TermuxService service = mActivity.getTermuxService(); + if (service == null) return null; + + final int indexOfSession = service.getIndexOfSession(session); + if (indexOfSession < 0) return null; + StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]"); + if (!TextUtils.isEmpty(session.mSessionName)) { + toastTitle.append(" ").append(session.mSessionName); + } + String title = session.getTitle(); + if (!TextUtils.isEmpty(title)) { + // Space to "[${NR}] or newline after session name: + toastTitle.append(session.mSessionName == null ? " " : "\n"); + toastTitle.append(title); + } + return toastTitle.toString(); + } + + + public void checkForFontAndColors() { + try { + File colorsFile = TermuxConstants.TERMUX_COLOR_PROPERTIES_FILE; + File fontFile = TermuxConstants.TERMUX_FONT_FILE; + + final Properties props = new Properties(); + if (colorsFile.isFile()) { + try (InputStream in = new FileInputStream(colorsFile)) { + props.load(in); + } + } + + TerminalColors.COLOR_SCHEME.updateWith(props); + TerminalSession session = mActivity.getCurrentSession(); + if (session != null && session.getEmulator() != null) { + session.getEmulator().mColors.reset(); + } + updateBackgroundColor(); + + final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE; + mActivity.getTerminalView().setTypeface(newTypeface); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error in checkForFontAndColors()", e); + } + } + + public void updateBackgroundColor() { + if (!mActivity.isVisible()) return; + TerminalSession session = mActivity.getCurrentSession(); + if (session != null && session.getEmulator() != null) { + mActivity.getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); + } + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java new file mode 100644 index 000000000..e943bb3ad --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java @@ -0,0 +1,31 @@ +package com.termux.app.terminal; + +import android.app.Service; + +import androidx.annotation.NonNull; + +import com.termux.app.TermuxService; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; +import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; + +/** The {@link TerminalSessionClient} implementation that may require a {@link Service} for its interface methods. */ +public class TermuxTerminalSessionServiceClient extends TermuxTerminalSessionClientBase { + + private static final String LOG_TAG = "TermuxTerminalSessionServiceClient"; + + private final TermuxService mService; + + public TermuxTerminalSessionServiceClient(TermuxService service) { + this.mService = service; + } + + @Override + public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) { + TermuxSession termuxSession = mService.getTermuxSessionForTerminalSession(terminalSession); + if (termuxSession != null) + termuxSession.getExecutionCommand().mPid = pid; + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java new file mode 100644 index 000000000..91fe31aa8 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -0,0 +1,798 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.media.AudioManager; +import android.os.Environment; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.widget.EditText; +import android.widget.ListView; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.shared.file.FileUtils; +import com.termux.shared.interact.MessageDialogUtils; +import com.termux.shared.interact.ShareUtils; +import com.termux.shared.shell.ShellUtils; +import com.termux.shared.termux.TermuxBootstrap; +import com.termux.shared.termux.terminal.TermuxTerminalViewClientBase; +import com.termux.shared.termux.extrakeys.SpecialButton; +import com.termux.shared.android.AndroidUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.models.ReportInfo; +import com.termux.app.models.UserAction; +import com.termux.app.terminal.io.KeyboardShortcut; +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.data.TermuxUrlUtils; +import com.termux.shared.view.KeyboardUtils; +import com.termux.shared.view.ViewUtils; +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +import androidx.drawerlayout.widget.DrawerLayout; + +public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase { + + final TermuxActivity mActivity; + + final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; + + /** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */ + boolean mVirtualControlKeyDown, mVirtualFnKeyDown; + + private Runnable mShowSoftKeyboardRunnable; + + private boolean mShowSoftKeyboardIgnoreOnce; + private boolean mShowSoftKeyboardWithDelayOnce; + + private boolean mTerminalCursorBlinkerStateAlreadySet; + + private List mSessionShortcuts; + + private static final String LOG_TAG = "TermuxTerminalViewClient"; + + public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) { + this.mActivity = activity; + this.mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient; + } + + public TermuxActivity getActivity() { + return mActivity; + } + + /** + * Should be called when mActivity.onCreate() is called + */ + public void onCreate() { + onReloadProperties(); + + mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize()); + mActivity.getTerminalView().setKeepScreenOn(mActivity.getPreferences().shouldKeepScreenOn()); + } + + /** + * Should be called when mActivity.onStart() is called + */ + public void onStart() { + // Set {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value + // Also required if user changed the preference from {@link TermuxSettings} activity and returns + boolean isTerminalViewKeyLoggingEnabled = mActivity.getPreferences().isTerminalViewKeyLoggingEnabled(); + mActivity.getTerminalView().setIsTerminalViewKeyLoggingEnabled(isTerminalViewKeyLoggingEnabled); + + // Piggyback on the terminal view key logging toggle for now, should add a separate toggle in future + mActivity.getTermuxActivityRootView().setIsRootViewLoggingEnabled(isTerminalViewKeyLoggingEnabled); + ViewUtils.setIsViewUtilsLoggingEnabled(isTerminalViewKeyLoggingEnabled); + } + + /** + * Should be called when mActivity.onResume() is called + */ + public void onResume() { + // Show the soft keyboard if required + setSoftKeyboardState(true, mActivity.isActivityRecreated()); + + mTerminalCursorBlinkerStateAlreadySet = false; + + if (mActivity.getTerminalView().mEmulator != null) { + // Start terminal cursor blinking if enabled + // If emulator is already set, then start blinker now, otherwise wait for onEmulatorSet() + // event to start it. This is needed since onEmulatorSet() may not be called after + // TermuxActivity is started after device display timeout with double tap and not power button. + setTerminalCursorBlinkerState(true); + mTerminalCursorBlinkerStateAlreadySet = true; + } + } + + /** + * Should be called when mActivity.onStop() is called + */ + public void onStop() { + // Stop terminal cursor blinking if enabled + setTerminalCursorBlinkerState(false); + } + + /** + * Should be called when mActivity.reloadProperties() is called + */ + public void onReloadProperties() { + setSessionShortcuts(); + } + + /** + * Should be called when mActivity.reloadActivityStyling() is called + */ + public void onReloadActivityStyling() { + // Show the soft keyboard if required + setSoftKeyboardState(false, true); + + // Start terminal cursor blinking if enabled + setTerminalCursorBlinkerState(true); + } + + /** + * Should be called when {@link com.termux.view.TerminalView#mEmulator} is set + */ + @Override + public void onEmulatorSet() { + if (!mTerminalCursorBlinkerStateAlreadySet) { + // Start terminal cursor blinking if enabled + // We need to wait for the first session to be attached that's set in + // TermuxActivity.onServiceConnected() and then the multiple calls to TerminalView.updateSize() + // where the final one eventually sets the mEmulator when width/height is not 0. Otherwise + // blinker will not start again if TermuxActivity is started again after exiting it with + // double back press. Check TerminalView.setTerminalCursorBlinkerState(). + setTerminalCursorBlinkerState(true); + mTerminalCursorBlinkerStateAlreadySet = true; + } + } + + + + @Override + public float onScale(float scale) { + if (scale < 0.9f || scale > 1.1f) { + boolean increase = scale > 1.f; + changeFontSize(increase); + return 1.0f; + } + return scale; + } + + + + @Override + public void onSingleTapUp(MotionEvent e) { + TerminalEmulator term = mActivity.getCurrentSession().getEmulator(); + + if (mActivity.getProperties().shouldOpenTerminalTranscriptURLOnClick()) { + int[] columnAndRow = mActivity.getTerminalView().getColumnAndRow(e, true); + String wordAtTap = term.getScreen().getWordAtLocation(columnAndRow[0], columnAndRow[1]); + LinkedHashSet urlSet = TermuxUrlUtils.extractUrls(wordAtTap); + + if (!urlSet.isEmpty()) { + String url = (String) urlSet.iterator().next(); + ShareUtils.openUrl(mActivity, url); + return; + } + } + + if (!term.isMouseTrackingActive() && !e.isFromSource(InputDevice.SOURCE_MOUSE)) { + if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity)) + KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView()); + else + Logger.logVerbose(LOG_TAG, "Not showing soft keyboard onSingleTapUp since its disabled"); + } + } + + @Override + public boolean shouldBackButtonBeMappedToEscape() { + return mActivity.getProperties().isBackKeyTheEscapeKey(); + } + + @Override + public boolean shouldEnforceCharBasedInput() { + return mActivity.getProperties().isEnforcingCharBasedInput(); + } + + @Override + public boolean shouldUseCtrlSpaceWorkaround() { + return mActivity.getProperties().isUsingCtrlSpaceWorkaround(); + } + + @Override + public boolean isTerminalViewSelected() { + return mActivity.getTerminalToolbarViewPager() == null || mActivity.isTerminalViewSelected() || mActivity.getTerminalView().hasFocus(); + } + + + + @Override + public void copyModeChanged(boolean copyMode) { + // Disable drawer while copying. + mActivity.getDrawer().setDrawerLockMode(copyMode ? DrawerLayout.LOCK_MODE_LOCKED_CLOSED : DrawerLayout.LOCK_MODE_UNLOCKED); + } + + + + @SuppressLint("RtlHardcoded") + @Override + public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) { + if (handleVirtualKeys(keyCode, e, true)) return true; + + if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) { + mTermuxTerminalSessionActivityClient.removeFinishedSession(currentSession); + return true; + } else if (!mActivity.getProperties().areHardwareKeyboardShortcutsDisabled() && + e.isCtrlPressed() && e.isAltPressed()) { + // Get the unmodified code point: + int unicodeChar = e.getUnicodeChar(0); + + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) { + mTermuxTerminalSessionActivityClient.switchToSession(true); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) { + mTermuxTerminalSessionActivityClient.switchToSession(false); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + mActivity.getDrawer().openDrawer(Gravity.LEFT); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + mActivity.getDrawer().closeDrawers(); + } else if (unicodeChar == 'k'/* keyboard */) { + onToggleSoftKeyboardRequest(); + } else if (unicodeChar == 'm'/* menu */) { + mActivity.getTerminalView().showContextMenu(); + } else if (unicodeChar == 'r'/* rename */) { + mTermuxTerminalSessionActivityClient.renameSession(currentSession); + } else if (unicodeChar == 'c'/* create */) { + mTermuxTerminalSessionActivityClient.addNewSession(false, null); + } else if (unicodeChar == 'u' /* urls */) { + showUrlSelection(); + } else if (unicodeChar == 'v') { + doPaste(); + } else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') { + // We also check for the shifted char here since shift may be required to produce '+', + // see https://github.com/termux/termux-api/issues/2 + changeFontSize(true); + } else if (unicodeChar == '-') { + changeFontSize(false); + } else if (unicodeChar >= '1' && unicodeChar <= '9') { + int index = unicodeChar - '1'; + mTermuxTerminalSessionActivityClient.switchToSession(index); + } + return true; + } + + return false; + + } + + + + @Override + public boolean onKeyUp(int keyCode, KeyEvent e) { + // If emulator is not set, like if bootstrap installation failed and user dismissed the error + // dialog, then just exit the activity, otherwise they will be stuck in a broken state. + if (keyCode == KeyEvent.KEYCODE_BACK && mActivity.getTerminalView().mEmulator == null) { + mActivity.finishActivityIfNotFinishing(); + return true; + } + + return handleVirtualKeys(keyCode, e, false); + } + + /** Handle dedicated volume buttons as virtual keys if applicable. */ + private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) { + InputDevice inputDevice = event.getDevice(); + if (mActivity.getProperties().areVirtualVolumeKeysDisabled()) { + return false; + } else if (inputDevice != null && inputDevice.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + // Do not steal dedicated buttons from a full external keyboard. + return false; + } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { + mVirtualControlKeyDown = down; + return true; + } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + mVirtualFnKeyDown = down; + return true; + } + return false; + } + + + + @Override + public boolean readControlKey() { + return readExtraKeysSpecialButton(SpecialButton.CTRL) || mVirtualControlKeyDown; + } + + @Override + public boolean readAltKey() { + return readExtraKeysSpecialButton(SpecialButton.ALT); + } + + @Override + public boolean readShiftKey() { + return readExtraKeysSpecialButton(SpecialButton.SHIFT); + } + + @Override + public boolean readFnKey() { + return readExtraKeysSpecialButton(SpecialButton.FN); + } + + public boolean readExtraKeysSpecialButton(SpecialButton specialButton) { + if (mActivity.getExtraKeysView() == null) return false; + Boolean state = mActivity.getExtraKeysView().readSpecialButton(specialButton, true); + if (state == null) { + Logger.logError(LOG_TAG,"Failed to read an unregistered " + specialButton + " special button value from extra keys."); + return false; + } + return state; + } + + @Override + public boolean onLongPress(MotionEvent event) { + return false; + } + + + + @Override + public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) { + if (mVirtualFnKeyDown) { + int resultingKeyCode = -1; + int resultingCodePoint = -1; + boolean altDown = false; + int lowerCase = Character.toLowerCase(codePoint); + switch (lowerCase) { + // Arrow keys. + case 'w': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_UP; + break; + case 'a': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_LEFT; + break; + case 's': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_DOWN; + break; + case 'd': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_RIGHT; + break; + + // Page up and down. + case 'p': + resultingKeyCode = KeyEvent.KEYCODE_PAGE_UP; + break; + case 'n': + resultingKeyCode = KeyEvent.KEYCODE_PAGE_DOWN; + break; + + // Some special keys: + case 't': + resultingKeyCode = KeyEvent.KEYCODE_TAB; + break; + case 'i': + resultingKeyCode = KeyEvent.KEYCODE_INSERT; + break; + case 'h': + resultingCodePoint = '~'; + break; + + // Special characters to input. + case 'u': + resultingCodePoint = '_'; + break; + case 'l': + resultingCodePoint = '|'; + break; + + // Function keys. + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + resultingKeyCode = (codePoint - '1') + KeyEvent.KEYCODE_F1; + break; + case '0': + resultingKeyCode = KeyEvent.KEYCODE_F10; + break; + + // Other special keys. + case 'e': + resultingCodePoint = /*Escape*/ 27; + break; + case '.': + resultingCodePoint = /*^.*/ 28; + break; + + case 'b': // alt+b, jumping backward in readline. + case 'f': // alf+f, jumping forward in readline. + case 'x': // alt+x, common in emacs. + resultingCodePoint = lowerCase; + altDown = true; + break; + + // Volume control. + case 'v': + resultingCodePoint = -1; + AudioManager audio = (AudioManager) mActivity.getSystemService(Context.AUDIO_SERVICE); + audio.adjustSuggestedStreamVolume(AudioManager.ADJUST_SAME, AudioManager.USE_DEFAULT_STREAM_TYPE, AudioManager.FLAG_SHOW_UI); + break; + + // Writing mode: + case 'q': + case 'k': + mActivity.toggleTerminalToolbar(); + mVirtualFnKeyDown=false; // force disable fn key down to restore keyboard input into terminal view, fixes termux/termux-app#1420 + break; + } + + if (resultingKeyCode != -1) { + TerminalEmulator term = session.getEmulator(); + session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode())); + } else if (resultingCodePoint != -1) { + session.writeCodePoint(altDown, resultingCodePoint); + } + return true; + } else if (ctrlDown) { + if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) { + mTermuxTerminalSessionActivityClient.removeFinishedSession(session); + return true; + } + + List shortcuts = mSessionShortcuts; + if (shortcuts != null && !shortcuts.isEmpty()) { + int codePointLowerCase = Character.toLowerCase(codePoint); + for (int i = shortcuts.size() - 1; i >= 0; i--) { + KeyboardShortcut shortcut = shortcuts.get(i); + if (codePointLowerCase == shortcut.codePoint) { + switch (shortcut.shortcutAction) { + case TermuxPropertyConstants.ACTION_SHORTCUT_CREATE_SESSION: + mTermuxTerminalSessionActivityClient.addNewSession(false, null); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION: + mTermuxTerminalSessionActivityClient.switchToSession(true); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION: + mTermuxTerminalSessionActivityClient.switchToSession(false); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION: + mTermuxTerminalSessionActivityClient.renameSession(mActivity.getCurrentSession()); + return true; + } + } + } + } + } + + return false; + } + + /** + * Set the terminal sessions shortcuts. + */ + private void setSessionShortcuts() { + mSessionShortcuts = new ArrayList<>(); + + // The {@link TermuxPropertyConstants#MAP_SESSION_SHORTCUTS} stores the session shortcut key and action pair + for (Map.Entry entry : TermuxPropertyConstants.MAP_SESSION_SHORTCUTS.entrySet()) { + // The mMap stores the code points for the session shortcuts while loading properties + Integer codePoint = (Integer) mActivity.getProperties().getInternalPropertyValue(entry.getKey(), true); + // If codePoint is null, then session shortcut did not exist in properties or was invalid + // as parsed by {@link #getCodePointForSessionShortcuts(String,String)} + // If codePoint is not null, then get the action for the MAP_SESSION_SHORTCUTS key and + // add the code point to sessionShortcuts + if (codePoint != null) + mSessionShortcuts.add(new KeyboardShortcut(codePoint, entry.getValue())); + } + } + + + + + + public void changeFontSize(boolean increase) { + mActivity.getPreferences().changeFontSize(increase); + mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize()); + } + + + + /** + * Called when user requests the soft keyboard to be toggled via "KEYBOARD" toggle button in + * drawer or extra keys, or with ctrl+alt+k hardware keyboard shortcut. + */ + public void onToggleSoftKeyboardRequest() { + // If soft keyboard toggle behaviour is enable/disabled + if (mActivity.getProperties().shouldEnableDisableSoftKeyboardOnToggle()) { + // If soft keyboard is visible + if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity)) { + Logger.logVerbose(LOG_TAG, "Disabling soft keyboard on toggle"); + mActivity.getPreferences().setSoftKeyboardEnabled(false); + KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView()); + } else { + // Show with a delay, otherwise pressing keyboard toggle won't show the keyboard after + // switching back from another app if keyboard was previously disabled by user. + // Also request focus, since it wouldn't have been requested at startup by + // setSoftKeyboardState if keyboard was disabled. #2112 + Logger.logVerbose(LOG_TAG, "Enabling soft keyboard on toggle"); + mActivity.getPreferences().setSoftKeyboardEnabled(true); + KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity); + if(mShowSoftKeyboardWithDelayOnce) { + mShowSoftKeyboardWithDelayOnce = false; + mActivity.getTerminalView().postDelayed(getShowSoftKeyboardRunnable(), 500); + mActivity.getTerminalView().requestFocus(); + } else + KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView()); + } + } + // If soft keyboard toggle behaviour is show/hide + else { + // If soft keyboard is disabled by user for Termux + if (!mActivity.getPreferences().isSoftKeyboardEnabled()) { + Logger.logVerbose(LOG_TAG, "Maintaining disabled soft keyboard on toggle"); + KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView()); + } else { + Logger.logVerbose(LOG_TAG, "Showing/Hiding soft keyboard on toggle"); + KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity); + KeyboardUtils.toggleSoftKeyboard(mActivity); + } + } + } + + public void setSoftKeyboardState(boolean isStartup, boolean isReloadTermuxProperties) { + boolean noShowKeyboard = false; + + // Requesting terminal view focus is necessary regardless of if soft keyboard is to be + // disabled or hidden at startup, otherwise if hardware keyboard is attached and user + // starts typing on hardware keyboard without tapping on the terminal first, then a colour + // tint will be added to the terminal as highlight for the focussed view. Test with a light + // theme. For android 8.+, the "defaultFocusHighlightEnabled" attribute is also set to false + // in TerminalView layout to fix the issue. + + // If soft keyboard is disabled by user for Termux (check function docs for Termux behaviour info) + if (KeyboardUtils.shouldSoftKeyboardBeDisabled(mActivity, + mActivity.getPreferences().isSoftKeyboardEnabled(), + mActivity.getPreferences().isSoftKeyboardEnabledOnlyIfNoHardware())) { + Logger.logVerbose(LOG_TAG, "Maintaining disabled soft keyboard"); + KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView()); + mActivity.getTerminalView().requestFocus(); + noShowKeyboard = true; + // Delay is only required if onCreate() is called like when Termux app is exited with + // double back press, not when Termux app is switched back from another app and keyboard + // toggle is pressed to enable keyboard + if (isStartup && mActivity.isOnResumeAfterOnCreate()) + mShowSoftKeyboardWithDelayOnce = true; + } else { + // Set flag to automatically push up TerminalView when keyboard is opened instead of showing over it + KeyboardUtils.setSoftInputModeAdjustResize(mActivity); + + // Clear any previous flags to disable soft keyboard in case setting updated + KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity); + + // If soft keyboard is to be hidden on startup + if (isStartup && mActivity.getProperties().shouldSoftKeyboardBeHiddenOnStartup()) { + Logger.logVerbose(LOG_TAG, "Hiding soft keyboard on startup"); + // Required to keep keyboard hidden when Termux app is switched back from another app + KeyboardUtils.setSoftKeyboardAlwaysHiddenFlags(mActivity); + + KeyboardUtils.hideSoftKeyboard(mActivity, mActivity.getTerminalView()); + mActivity.getTerminalView().requestFocus(); + noShowKeyboard = true; + // Required to keep keyboard hidden on app startup + mShowSoftKeyboardIgnoreOnce = true; + } + } + + mActivity.getTerminalView().setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + // Force show soft keyboard if TerminalView or toolbar text input view has + // focus and close it if they don't + boolean textInputViewHasFocus = false; + final EditText textInputView = mActivity.findViewById(R.id.terminal_toolbar_text_input); + if (textInputView != null) textInputViewHasFocus = textInputView.hasFocus(); + + if (hasFocus || textInputViewHasFocus) { + if (mShowSoftKeyboardIgnoreOnce) { + mShowSoftKeyboardIgnoreOnce = false; return; + } + Logger.logVerbose(LOG_TAG, "Showing soft keyboard on focus change"); + } else { + Logger.logVerbose(LOG_TAG, "Hiding soft keyboard on focus change"); + } + + KeyboardUtils.setSoftKeyboardVisibility(getShowSoftKeyboardRunnable(), mActivity, mActivity.getTerminalView(), hasFocus || textInputViewHasFocus); + } + }); + + // Do not force show soft keyboard if termux-reload-settings command was run with hardware keyboard + // or soft keyboard is to be hidden or is disabled + if (!isReloadTermuxProperties && !noShowKeyboard) { + // Request focus for TerminalView + // Also show the keyboard, since onFocusChange will not be called if TerminalView already + // had focus on startup to show the keyboard, like when opening url with context menu + // "Select URL" long press and returning to Termux app with back button. This + // will also show keyboard even if it was closed before opening url. #2111 + Logger.logVerbose(LOG_TAG, "Requesting TerminalView focus and showing soft keyboard"); + mActivity.getTerminalView().requestFocus(); + mActivity.getTerminalView().postDelayed(getShowSoftKeyboardRunnable(), 300); + } + } + + private Runnable getShowSoftKeyboardRunnable() { + if (mShowSoftKeyboardRunnable == null) { + mShowSoftKeyboardRunnable = () -> { + KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView()); + }; + } + return mShowSoftKeyboardRunnable; + } + + + + public void setTerminalCursorBlinkerState(boolean start) { + if (start) { + // If set/update the cursor blinking rate is successful, then enable cursor blinker + if (mActivity.getTerminalView().setTerminalCursorBlinkerRate(mActivity.getProperties().getTerminalCursorBlinkRate())) + mActivity.getTerminalView().setTerminalCursorBlinkerState(true, true); + else + Logger.logError(LOG_TAG,"Failed to start cursor blinker"); + } else { + // Disable cursor blinker + mActivity.getTerminalView().setTerminalCursorBlinkerState(false, true); + } + } + + + + public void shareSessionTranscript() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + + String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); + if (transcriptText == null) return; + + // See https://github.com/termux/termux-app/issues/1166. + transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim(); + ShareUtils.shareText(mActivity, mActivity.getString(R.string.title_share_transcript), + transcriptText, mActivity.getString(R.string.title_share_transcript_with)); + } + + public void shareSelectedText() { + String selectedText = mActivity.getTerminalView().getStoredSelectedText(); + if (DataUtils.isNullOrEmpty(selectedText)) return; + ShareUtils.shareText(mActivity, mActivity.getString(R.string.title_share_selected_text), + selectedText, mActivity.getString(R.string.title_share_selected_text_with)); + } + + public void showUrlSelection() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + + String text = ShellUtils.getTerminalSessionTranscriptText(session, true, true); + + LinkedHashSet urlSet = TermuxUrlUtils.extractUrls(text); + if (urlSet.isEmpty()) { + new AlertDialog.Builder(mActivity).setMessage(R.string.title_select_url_none_found).show(); + return; + } + + final CharSequence[] urls = urlSet.toArray(new CharSequence[0]); + Collections.reverse(Arrays.asList(urls)); // Latest first. + + // Click to copy url to clipboard: + final AlertDialog dialog = new AlertDialog.Builder(mActivity).setItems(urls, (di, which) -> { + String url = (String) urls[which]; + ShareUtils.copyTextToClipboard(mActivity, url, mActivity.getString(R.string.msg_select_url_copied_to_clipboard)); + }).setTitle(R.string.title_select_url_dialog).create(); + + // Long press to open URL: + dialog.setOnShowListener(di -> { + ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it + lv.setOnItemLongClickListener((parent, view, position, id) -> { + dialog.dismiss(); + String url = (String) urls[position]; + ShareUtils.openUrl(mActivity, url); + return true; + }); + }); + + dialog.show(); + } + + public void reportIssueFromTranscript() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + + final String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); + if (transcriptText == null) return; + + MessageDialogUtils.showMessage(mActivity, TermuxConstants.TERMUX_APP_NAME + " Report Issue", + mActivity.getString(R.string.msg_add_termux_debug_info), + mActivity.getString(R.string.action_yes), (dialog, which) -> reportIssueFromTranscript(transcriptText, true), + mActivity.getString(R.string.action_no), (dialog, which) -> reportIssueFromTranscript(transcriptText, false), + null); + } + + private void reportIssueFromTranscript(String transcriptText, boolean addTermuxDebugInfo) { + Logger.showToast(mActivity, mActivity.getString(R.string.msg_generating_report), true); + + new Thread() { + @Override + public void run() { + StringBuilder reportString = new StringBuilder(); + + String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue"; + + reportString.append("## Transcript\n"); + reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true)); + reportString.append("\n##\n"); + + if (addTermuxDebugInfo) { + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES)); + } else { + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_PACKAGE)); + } + + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(mActivity, true)); + + if (TermuxBootstrap.isAppPackageManagerAPT()) { + String termuxAptInfo = TermuxUtils.geAPTInfoMarkdownString(mActivity); + if (termuxAptInfo != null) + reportString.append("\n\n").append(termuxAptInfo); + } + + if (addTermuxDebugInfo) { + String termuxDebugInfo = TermuxUtils.getTermuxDebugMarkdownString(mActivity); + if (termuxDebugInfo != null) + reportString.append("\n\n").append(termuxDebugInfo); + } + + String userActionName = UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName(); + + ReportInfo reportInfo = new ReportInfo(userActionName, + TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title); + reportInfo.setReportString(reportString.toString()); + reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity)); + reportInfo.setReportSaveFileLabelAndPath(userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)); + + ReportActivity.startReportActivity(mActivity, reportInfo); + } + }.start(); + } + + public void doPaste() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + if (!session.isRunning()) return; + + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + session.getEmulator().paste(text); + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java b/termux/termux-app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java new file mode 100644 index 000000000..c01f8994d --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java @@ -0,0 +1,68 @@ +package com.termux.app.terminal.io; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import com.termux.app.TermuxActivity; + +/** + * Work around for fullscreen mode in Termux to fix ExtraKeysView not being visible. + * This class is derived from: + * https://stackoverflow.com/questions/7417123/android-how-to-adjust-layout-in-full-screen-mode-when-softkeyboard-is-visible + * and has some additional tweaks + * --- + * For more information, see https://issuetracker.google.com/issues/36911528 + */ +public class FullScreenWorkAround { + private final View mChildOfContent; + private int mUsableHeightPrevious; + private final ViewGroup.LayoutParams mViewGroupLayoutParams; + + private final int mNavBarHeight; + + + public static void apply(TermuxActivity activity) { + new FullScreenWorkAround(activity); + } + + private FullScreenWorkAround(TermuxActivity activity) { + ViewGroup content = activity.findViewById(android.R.id.content); + mChildOfContent = content.getChildAt(0); + mViewGroupLayoutParams = mChildOfContent.getLayoutParams(); + mNavBarHeight = activity.getNavBarHeight(); + mChildOfContent.getViewTreeObserver().addOnGlobalLayoutListener(this::possiblyResizeChildOfContent); + } + + private void possiblyResizeChildOfContent() { + int usableHeightNow = computeUsableHeight(); + if (usableHeightNow != mUsableHeightPrevious) { + int usableHeightSansKeyboard = mChildOfContent.getRootView().getHeight(); + int heightDifference = usableHeightSansKeyboard - usableHeightNow; + if (heightDifference > (usableHeightSansKeyboard / 4)) { + // keyboard probably just became visible + + // ensures that usable layout space does not extend behind the + // soft keyboard, causing the extra keys to not be visible + mViewGroupLayoutParams.height = (usableHeightSansKeyboard - heightDifference) + getNavBarHeight(); + } else { + // keyboard probably just became hidden + mViewGroupLayoutParams.height = usableHeightSansKeyboard; + } + mChildOfContent.requestLayout(); + mUsableHeightPrevious = usableHeightNow; + } + } + + private int getNavBarHeight() { + return mNavBarHeight; + } + + private int computeUsableHeight() { + Rect r = new Rect(); + mChildOfContent.getWindowVisibleDisplayFrame(r); + return (r.bottom - r.top); + } + +} + diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java b/termux/termux-app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java new file mode 100644 index 000000000..00a832dd6 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java @@ -0,0 +1,13 @@ +package com.termux.app.terminal.io; + +public class KeyboardShortcut { + + public final int codePoint; + public final int shortcutAction; + + public KeyboardShortcut(int codePoint, int shortcutAction) { + this.codePoint = codePoint; + this.shortcutAction = shortcutAction; + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java b/termux/termux-app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java new file mode 100644 index 000000000..a526570b0 --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -0,0 +1,117 @@ +package com.termux.app.terminal.io; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.shared.termux.extrakeys.ExtraKeysView; +import com.termux.terminal.TerminalSession; + +public class TerminalToolbarViewPager { + + public static class PageAdapter extends PagerAdapter { + + final TermuxActivity mActivity; + String mSavedTextInput; + + public PageAdapter(TermuxActivity activity, String savedTextInput) { + this.mActivity = activity; + this.mSavedTextInput = savedTextInput; + } + + @Override + public int getCount() { + return 2; + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return view == object; + } + + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup collection, int position) { + LayoutInflater inflater = LayoutInflater.from(mActivity); + View layout; + if (position == 0) { + layout = inflater.inflate(R.layout.view_terminal_toolbar_extra_keys, collection, false); + ExtraKeysView extraKeysView = (ExtraKeysView) layout; + extraKeysView.setExtraKeysViewClient(mActivity.getTermuxTerminalExtraKeys()); + extraKeysView.setButtonTextAllCaps(mActivity.getProperties().shouldExtraKeysTextBeAllCaps()); + mActivity.setExtraKeysView(extraKeysView); + extraKeysView.reload(mActivity.getTermuxTerminalExtraKeys().getExtraKeysInfo(), + mActivity.getTerminalToolbarDefaultHeight()); + + // apply extra keys fix if enabled in prefs + if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) { + FullScreenWorkAround.apply(mActivity); + } + + } else { + layout = inflater.inflate(R.layout.view_terminal_toolbar_text_input, collection, false); + final EditText editText = layout.findViewById(R.id.terminal_toolbar_text_input); + + if (mSavedTextInput != null) { + editText.setText(mSavedTextInput); + mSavedTextInput = null; + } + + editText.setOnEditorActionListener((v, actionId, event) -> { + TerminalSession session = mActivity.getCurrentSession(); + if (session != null) { + if (session.isRunning()) { + String textToSend = editText.getText().toString(); + if (textToSend.length() == 0) textToSend = "\r"; + session.write(textToSend); + } else { + mActivity.getTermuxTerminalSessionClient().removeFinishedSession(session); + } + editText.setText(""); + } + return true; + }); + } + collection.addView(layout); + return layout; + } + + @Override + public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { + collection.removeView((View) view); + } + + } + + + + public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeListener { + + final TermuxActivity mActivity; + final ViewPager mTerminalToolbarViewPager; + + public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) { + this.mActivity = activity; + this.mTerminalToolbarViewPager = viewPager; + } + + @Override + public void onPageSelected(int position) { + if (position == 0) { + mActivity.getTerminalView().requestFocus(); + } else { + final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input); + if (editText != null) editText.requestFocus(); + } + } + + } + +} diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java b/termux/termux-app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java new file mode 100644 index 000000000..a38e8cbbc --- /dev/null +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java @@ -0,0 +1,108 @@ +package com.termux.app.terminal.io; + +import android.annotation.SuppressLint; +import android.view.Gravity; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.drawerlayout.widget.DrawerLayout; + +import com.termux.app.TermuxActivity; +import com.termux.app.terminal.TermuxTerminalSessionActivityClient; +import com.termux.app.terminal.TermuxTerminalViewClient; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.extrakeys.ExtraKeysConstants; +import com.termux.shared.termux.extrakeys.ExtraKeysInfo; +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; +import com.termux.shared.termux.settings.properties.TermuxSharedProperties; +import com.termux.shared.termux.terminal.io.TerminalExtraKeys; +import com.termux.view.TerminalView; + +import org.json.JSONException; + +public class TermuxTerminalExtraKeys extends TerminalExtraKeys { + + private ExtraKeysInfo mExtraKeysInfo; + + final TermuxActivity mActivity; + final TermuxTerminalViewClient mTermuxTerminalViewClient; + final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; + + private static final String LOG_TAG = "TermuxTerminalExtraKeys"; + + public TermuxTerminalExtraKeys(TermuxActivity activity, @NonNull TerminalView terminalView, + TermuxTerminalViewClient termuxTerminalViewClient, + TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) { + super(terminalView); + + mActivity = activity; + mTermuxTerminalViewClient = termuxTerminalViewClient; + mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient; + + setExtraKeys(); + } + + + /** + * Set the terminal extra keys and style. + */ + private void setExtraKeys() { + mExtraKeysInfo = null; + + try { + // The mMap stores the extra key and style string values while loading properties + // Check {@link #getExtraKeysInternalPropertyValueFromValue(String)} and + // {@link #getExtraKeysStyleInternalPropertyValueFromValue(String)} + String extrakeys = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true); + String extraKeysStyle = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true); + + ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap = ExtraKeysInfo.getCharDisplayMapForStyle(extraKeysStyle); + if (ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY.equals(extraKeyDisplayMap) && !TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE.equals(extraKeysStyle)) { + Logger.logError(TermuxSharedProperties.LOG_TAG, "The style \"" + extraKeysStyle + "\" for the key \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE + "\" is invalid. Using default style instead."); + extraKeysStyle = TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE; + } + + mExtraKeysInfo = new ExtraKeysInfo(extrakeys, extraKeysStyle, ExtraKeysConstants.CONTROL_CHARS_ALIASES); + } catch (JSONException e) { + Logger.showToast(mActivity, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: " + e.toString(), true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: ", e); + + try { + mExtraKeysInfo = new ExtraKeysInfo(TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE, ExtraKeysConstants.CONTROL_CHARS_ALIASES); + } catch (JSONException e2) { + Logger.showToast(mActivity, "Can't create default extra keys",true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e); + mExtraKeysInfo = null; + } + } + } + + public ExtraKeysInfo getExtraKeysInfo() { + return mExtraKeysInfo; + } + + @SuppressLint("RtlHardcoded") + @Override + public void onTerminalExtraKeyButtonClick(View view, String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) { + if ("KEYBOARD".equals(key)) { + if(mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onToggleSoftKeyboardRequest(); + } else if ("DRAWER".equals(key)) { + DrawerLayout drawerLayout = mTermuxTerminalViewClient.getActivity().getDrawer(); + if (drawerLayout.isDrawerOpen(Gravity.LEFT)) + drawerLayout.closeDrawer(Gravity.LEFT); + else + drawerLayout.openDrawer(Gravity.LEFT); + } else if ("PASTE".equals(key)) { + if(mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onPasteTextFromClipboard(null); + } else if ("SCROLL".equals(key)) { + TerminalView terminalView = mTermuxTerminalViewClient.getActivity().getTerminalView(); + if (terminalView != null && terminalView.mEmulator != null) + terminalView.mEmulator.toggleAutoScrollDisabled(); + } else { + super.onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown); + } + } + +} diff --git a/termux/termux-app/src/main/res/drawable/banner.png b/termux/termux-app/src/main/res/drawable/banner.png new file mode 100644 index 000000000..445d384ac Binary files /dev/null and b/termux/termux-app/src/main/res/drawable/banner.png differ diff --git a/termux/termux-app/src/main/res/drawable/current_session.xml b/termux/termux-app/src/main/res/drawable/current_session.xml new file mode 100644 index 000000000..90dd28182 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/current_session.xml @@ -0,0 +1,4 @@ + + + + diff --git a/termux/termux-app/src/main/res/drawable/current_session_black.xml b/termux/termux-app/src/main/res/drawable/current_session_black.xml new file mode 100644 index 000000000..6a9264992 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/current_session_black.xml @@ -0,0 +1,4 @@ + + + + diff --git a/termux/termux-app/src/main/res/drawable/ic_foreground.xml b/termux/termux-app/src/main/res/drawable/ic_foreground.xml new file mode 100644 index 000000000..3f3e59b73 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/ic_foreground.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/termux/termux-app/src/main/res/drawable/ic_new_session.xml b/termux/termux-app/src/main/res/drawable/ic_new_session.xml new file mode 100644 index 000000000..9b5b24a01 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/ic_new_session.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/termux/termux-app/src/main/res/drawable/ic_service_notification.xml b/termux/termux-app/src/main/res/drawable/ic_service_notification.xml new file mode 100644 index 000000000..205dcfc0c --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/ic_service_notification.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/termux/termux-app/src/main/res/drawable/ic_settings.xml b/termux/termux-app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..e5d1108b0 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/termux/termux-app/src/main/res/drawable/session_background_black_selected.xml b/termux/termux-app/src/main/res/drawable/session_background_black_selected.xml new file mode 100644 index 000000000..25b7506f4 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/session_background_black_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/termux/termux-app/src/main/res/drawable/session_background_selected.xml b/termux/termux-app/src/main/res/drawable/session_background_selected.xml new file mode 100644 index 000000000..3db6d6e50 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/session_background_selected.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/termux/termux-app/src/main/res/drawable/session_ripple.xml b/termux/termux-app/src/main/res/drawable/session_ripple.xml new file mode 100644 index 000000000..9c4a1e795 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/session_ripple.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/termux/termux-app/src/main/res/drawable/session_ripple_black.xml b/termux/termux-app/src/main/res/drawable/session_ripple_black.xml new file mode 100644 index 000000000..21423eb51 --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/session_ripple_black.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/termux/termux-app/src/main/res/drawable/terminal_scroll_shape.xml b/termux/termux-app/src/main/res/drawable/terminal_scroll_shape.xml new file mode 100644 index 000000000..76cb0719d --- /dev/null +++ b/termux/termux-app/src/main/res/drawable/terminal_scroll_shape.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/termux/termux-app/src/main/res/layout/activity_settings.xml b/termux/termux-app/src/main/res/layout/activity_settings.xml new file mode 100644 index 000000000..d3914191e --- /dev/null +++ b/termux/termux-app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/termux/termux-app/src/main/res/layout/activity_termux.xml b/termux/termux-app/src/main/res/layout/activity_termux.xml new file mode 100644 index 000000000..61e5eee1c --- /dev/null +++ b/termux/termux-app/src/main/res/layout/activity_termux.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/termux/termux-app/src/main/res/layout/item_terminal_sessions_list.xml b/termux/termux-app/src/main/res/layout/item_terminal_sessions_list.xml new file mode 100644 index 000000000..46cfeccb6 --- /dev/null +++ b/termux/termux-app/src/main/res/layout/item_terminal_sessions_list.xml @@ -0,0 +1,9 @@ + diff --git a/termux/termux-app/src/main/res/layout/preference_markdown_text.xml b/termux/termux-app/src/main/res/layout/preference_markdown_text.xml new file mode 100644 index 000000000..f77049e35 --- /dev/null +++ b/termux/termux-app/src/main/res/layout/preference_markdown_text.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/termux/termux-app/src/main/res/layout/view_terminal_toolbar_extra_keys.xml b/termux/termux-app/src/main/res/layout/view_terminal_toolbar_extra_keys.xml new file mode 100644 index 000000000..7dbd46fd1 --- /dev/null +++ b/termux/termux-app/src/main/res/layout/view_terminal_toolbar_extra_keys.xml @@ -0,0 +1,8 @@ + + diff --git a/termux/termux-app/src/main/res/layout/view_terminal_toolbar_text_input.xml b/termux/termux-app/src/main/res/layout/view_terminal_toolbar_text_input.xml new file mode 100644 index 000000000..86b3ce92d --- /dev/null +++ b/termux/termux-app/src/main/res/layout/view_terminal_toolbar_text_input.xml @@ -0,0 +1,16 @@ + + diff --git a/termux/termux-app/src/main/res/xml/root_preferences.xml b/termux/termux-app/src/main/res/xml/root_preferences.xml new file mode 100644 index 000000000..f8efef7b7 --- /dev/null +++ b/termux/termux-app/src/main/res/xml/root_preferences.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/termux/termux-app/src/main/res/xml/termux_debugging_preferences.xml b/termux/termux-app/src/main/res/xml/termux_debugging_preferences.xml new file mode 100644 index 000000000..b59d931de --- /dev/null +++ b/termux/termux-app/src/main/res/xml/termux_debugging_preferences.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/termux/termux-app/src/main/res/xml/termux_preferences.xml b/termux/termux-app/src/main/res/xml/termux_preferences.xml new file mode 100644 index 000000000..90353c51e --- /dev/null +++ b/termux/termux-app/src/main/res/xml/termux_preferences.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/termux/termux-app/src/main/res/xml/termux_terminal_io_preferences.xml b/termux/termux-app/src/main/res/xml/termux_terminal_io_preferences.xml new file mode 100644 index 000000000..ea9a0eb50 --- /dev/null +++ b/termux/termux-app/src/main/res/xml/termux_terminal_io_preferences.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/termux/termux-app/src/main/res/xml/termux_terminal_view_preferences.xml b/termux/termux-app/src/main/res/xml/termux_terminal_view_preferences.xml new file mode 100644 index 000000000..3f6686333 --- /dev/null +++ b/termux/termux-app/src/main/res/xml/termux_terminal_view_preferences.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/termux/termux-app/src/test/java/com/termux/app/TermuxActivityTest.java b/termux/termux-app/src/test/java/com/termux/app/TermuxActivityTest.java new file mode 100644 index 000000000..bcb80f2c7 --- /dev/null +++ b/termux/termux-app/src/test/java/com/termux/app/TermuxActivityTest.java @@ -0,0 +1,32 @@ +package com.termux.app; + +import com.termux.shared.termux.data.TermuxUrlUtils; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Collections; +import java.util.LinkedHashSet; + +public class TermuxActivityTest { + + private void assertUrlsAre(String text, String... urls) { + LinkedHashSet expected = new LinkedHashSet<>(); + Collections.addAll(expected, urls); + Assert.assertEquals(expected, TermuxUrlUtils.extractUrls(text)); + } + + @Test + public void testExtractUrls() { + assertUrlsAre("hello http://example.com world", "http://example.com"); + + assertUrlsAre("http://example.com\nhttp://another.com", "http://example.com", "http://another.com"); + + assertUrlsAre("hello http://example.com world and http://more.example.com with secure https://more.example.com", + "http://example.com", "http://more.example.com", "https://more.example.com"); + + assertUrlsAre("hello https://example.com/#bar https://example.com/foo#bar", + "https://example.com/#bar", "https://example.com/foo#bar"); + } + +} diff --git a/termux/termux-app/src/test/java/com/termux/app/api/file/FileReceiverActivityTest.java b/termux/termux-app/src/test/java/com/termux/app/api/file/FileReceiverActivityTest.java new file mode 100644 index 000000000..1107671da --- /dev/null +++ b/termux/termux-app/src/test/java/com/termux/app/api/file/FileReceiverActivityTest.java @@ -0,0 +1,34 @@ +package com.termux.app.api.file; + +import com.termux.app.api.file.FileReceiverActivity; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class FileReceiverActivityTest { + + @Test + public void testIsSharedTextAnUrl() { + List validUrls = new ArrayList<>(); + validUrls.add("http://example.com"); + validUrls.add("https://example.com"); + validUrls.add("https://example.com/path/parameter=foo"); + validUrls.add("magnet:?xt=urn:btih:d540fc48eb12f2833163eed6421d449dd8f1ce1f&dn=Ubuntu+desktop+19.04+%2864bit%29&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=udp%3A%2F%2Ftracker.publicbt.com%3A80&tr=udp%3A%2F%2Ftracker.ccc.de%3A80"); + for (String url : validUrls) { + Assert.assertTrue(FileReceiverActivity.isSharedTextAnUrl(url)); + } + + List invalidUrls = new ArrayList<>(); + invalidUrls.add("a test with example.com"); + for (String url : invalidUrls) { + Assert.assertFalse(FileReceiverActivity.isSharedTextAnUrl(url)); + } + } + +} diff --git a/termux/termux-app/testkey_untrusted.jks b/termux/termux-app/testkey_untrusted.jks new file mode 100644 index 000000000..174cc95ac Binary files /dev/null and b/termux/termux-app/testkey_untrusted.jks differ diff --git a/termux/termux-emulator/.gitignore b/termux/termux-emulator/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/termux/termux-emulator/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/termux/termux-emulator/build.gradle.kts b/termux/termux-emulator/build.gradle.kts new file mode 100644 index 000000000..da054ecc6 --- /dev/null +++ b/termux/termux-emulator/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + namespace = "com.termux.emulator" + ndkVersion = BuildConfig.ndkVersion + + defaultConfig { + externalNativeBuild { + ndkBuild { + cFlags += arrayOf("-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections") + } + } + + ndk { + abiFilters += arrayOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a") + } + } + + externalNativeBuild { + ndkBuild { + path = file("src/main/jni/Android.mk") + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +tasks.withType(Test::class.java) { + testLogging { + events("started", "passed", "skipped", "failed") + } +} + +dependencies { + implementation(libs.androidx.annotation) + testImplementation(projects.testing.unit) +} diff --git a/termux/termux-emulator/proguard-rules.pro b/termux/termux-emulator/proguard-rules.pro new file mode 100644 index 000000000..2e56e600a --- /dev/null +++ b/termux/termux-emulator/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/fornwall/lib/android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/termux/termux-emulator/src/main/AndroidManifest.xml b/termux/termux-emulator/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/termux/termux-emulator/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/ByteQueue.java b/termux/termux-emulator/src/main/java/com/termux/terminal/ByteQueue.java new file mode 100644 index 000000000..adfdfa864 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/ByteQueue.java @@ -0,0 +1,108 @@ +package com.termux.terminal; + +/** A circular byte buffer allowing one producer and one consumer thread. */ +final class ByteQueue { + + private final byte[] mBuffer; + private int mHead; + private int mStoredBytes; + private boolean mOpen = true; + + public ByteQueue(int size) { + mBuffer = new byte[size]; + } + + public synchronized void close() { + mOpen = false; + notify(); + } + + public synchronized int read(byte[] buffer, boolean block) { + while (mStoredBytes == 0 && mOpen) { + if (block) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } else { + return 0; + } + } + if (!mOpen) return -1; + + int totalRead = 0; + int bufferLength = mBuffer.length; + boolean wasFull = bufferLength == mStoredBytes; + int length = buffer.length; + int offset = 0; + while (length > 0 && mStoredBytes > 0) { + int oneRun = Math.min(bufferLength - mHead, mStoredBytes); + int bytesToCopy = Math.min(length, oneRun); + System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy); + mHead += bytesToCopy; + if (mHead >= bufferLength) mHead = 0; + mStoredBytes -= bytesToCopy; + length -= bytesToCopy; + offset += bytesToCopy; + totalRead += bytesToCopy; + } + if (wasFull) notify(); + return totalRead; + } + + /** + * Attempt to write the specified portion of the provided buffer to the queue. + *

+ * Returns whether the output was totally written, false if it was closed before. + */ + public boolean write(byte[] buffer, int offset, int lengthToWrite) { + if (lengthToWrite + offset > buffer.length) { + throw new IllegalArgumentException("length + offset > buffer.length"); + } else if (lengthToWrite <= 0) { + throw new IllegalArgumentException("length <= 0"); + } + + final int bufferLength = mBuffer.length; + + synchronized (this) { + while (lengthToWrite > 0) { + while (bufferLength == mStoredBytes && mOpen) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } + if (!mOpen) return false; + final boolean wasEmpty = mStoredBytes == 0; + int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes); + lengthToWrite -= bytesToWriteBeforeWaiting; + + while (bytesToWriteBeforeWaiting > 0) { + int tail = mHead + mStoredBytes; + int oneRun; + if (tail >= bufferLength) { + // Buffer: [.............] + // ________________H_______T + // => + // Buffer: [.............] + // ___________T____H + // onRun= _____----_ + tail = tail - bufferLength; + oneRun = mHead - tail; + } else { + oneRun = bufferLength - tail; + } + int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting); + System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy); + offset += bytesToCopy; + bytesToWriteBeforeWaiting -= bytesToCopy; + mStoredBytes += bytesToCopy; + } + if (wasEmpty) notify(); + } + } + return true; + } +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/JNI.java b/termux/termux-emulator/src/main/java/com/termux/terminal/JNI.java new file mode 100644 index 000000000..99229a652 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/JNI.java @@ -0,0 +1,41 @@ +package com.termux.terminal; + +/** + * Native methods for creating and managing pseudoterminal subprocesses. C code is in jni/termux.c. + */ +final class JNI { + + static { + System.loadLibrary("termux"); + } + + /** + * Create a subprocess. Differs from {@link ProcessBuilder} in that a pseudoterminal is used to communicate with the + * subprocess. + *

+ * Callers are responsible for calling {@link #close(int)} on the returned file descriptor. + * + * @param cmd The command to execute + * @param cwd The current working directory for the executed command + * @param args An array of arguments to the command + * @param envVars An array of strings of the form "VAR=value" to be added to the environment of the process + * @param processId A one-element array to which the process ID of the started process will be written. + * @return the file descriptor resulting from opening /dev/ptmx master device. The sub process will have opened the + * slave device counterpart (/dev/pts/$N) and have it as stdint, stdout and stderr. + */ + public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns); + + /** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */ + public static native void setPtyWindowSize(int fd, int rows, int cols); + + /** + * Causes the calling thread to wait for the process associated with the receiver to finish executing. + * + * @return if >= 0, the exit status of the process. If < 0, the signal causing the process to stop negated. + */ + public static native int waitFor(int processId); + + /** Close a file descriptor through the close(2) system call. */ + public static native void close(int fileDescriptor); + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/KeyHandler.java b/termux/termux-emulator/src/main/java/com/termux/terminal/KeyHandler.java new file mode 100644 index 000000000..e6323c599 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/KeyHandler.java @@ -0,0 +1,373 @@ +package com.termux.terminal; + +import java.util.HashMap; +import java.util.Map; + +import static android.view.KeyEvent.KEYCODE_BACK; +import static android.view.KeyEvent.KEYCODE_BREAK; +import static android.view.KeyEvent.KEYCODE_DEL; +import static android.view.KeyEvent.KEYCODE_DPAD_CENTER; +import static android.view.KeyEvent.KEYCODE_DPAD_DOWN; +import static android.view.KeyEvent.KEYCODE_DPAD_LEFT; +import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT; +import static android.view.KeyEvent.KEYCODE_DPAD_UP; +import static android.view.KeyEvent.KEYCODE_ENTER; +import static android.view.KeyEvent.KEYCODE_ESCAPE; +import static android.view.KeyEvent.KEYCODE_F1; +import static android.view.KeyEvent.KEYCODE_F10; +import static android.view.KeyEvent.KEYCODE_F11; +import static android.view.KeyEvent.KEYCODE_F12; +import static android.view.KeyEvent.KEYCODE_F2; +import static android.view.KeyEvent.KEYCODE_F3; +import static android.view.KeyEvent.KEYCODE_F4; +import static android.view.KeyEvent.KEYCODE_F5; +import static android.view.KeyEvent.KEYCODE_F6; +import static android.view.KeyEvent.KEYCODE_F7; +import static android.view.KeyEvent.KEYCODE_F8; +import static android.view.KeyEvent.KEYCODE_F9; +import static android.view.KeyEvent.KEYCODE_FORWARD_DEL; +import static android.view.KeyEvent.KEYCODE_INSERT; +import static android.view.KeyEvent.KEYCODE_MOVE_END; +import static android.view.KeyEvent.KEYCODE_MOVE_HOME; +import static android.view.KeyEvent.KEYCODE_NUMPAD_0; +import static android.view.KeyEvent.KEYCODE_NUMPAD_1; +import static android.view.KeyEvent.KEYCODE_NUMPAD_2; +import static android.view.KeyEvent.KEYCODE_NUMPAD_3; +import static android.view.KeyEvent.KEYCODE_NUMPAD_4; +import static android.view.KeyEvent.KEYCODE_NUMPAD_5; +import static android.view.KeyEvent.KEYCODE_NUMPAD_6; +import static android.view.KeyEvent.KEYCODE_NUMPAD_7; +import static android.view.KeyEvent.KEYCODE_NUMPAD_8; +import static android.view.KeyEvent.KEYCODE_NUMPAD_9; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD; +import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER; +import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS; +import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY; +import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT; +import static android.view.KeyEvent.KEYCODE_NUM_LOCK; +import static android.view.KeyEvent.KEYCODE_PAGE_DOWN; +import static android.view.KeyEvent.KEYCODE_PAGE_UP; +import static android.view.KeyEvent.KEYCODE_SPACE; +import static android.view.KeyEvent.KEYCODE_SYSRQ; +import static android.view.KeyEvent.KEYCODE_TAB; + +public final class KeyHandler { + + public static final int KEYMOD_ALT = 0x80000000; + public static final int KEYMOD_CTRL = 0x40000000; + public static final int KEYMOD_SHIFT = 0x20000000; + public static final int KEYMOD_NUM_LOCK = 0x10000000; + + private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); + + static { + // terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html + // termcap: http://man7.org/linux/man-pages/man5/termcap.5.html + TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT); + TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home + TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key + + TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12); + TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12); + + TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key + + TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key + TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT); + + // K1=Upper left of keypad: + // t_K1 keypad home key + // t_K3 keypad page-up key + // t_K4 keypad end key + // t_K5 keypad page-down key + TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN); + + TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP); + + TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab + TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key + TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down + TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key + TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT); + TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_DOWN); + TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key + TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up + + TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER); + } + + static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) { + Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap); + if (keyCodeAndMod == null) return null; + int keyCode = keyCodeAndMod; + int keyMod = 0; + if ((keyCode & KEYMOD_SHIFT) != 0) { + keyMod |= KEYMOD_SHIFT; + keyCode &= ~KEYMOD_SHIFT; + } + if ((keyCode & KEYMOD_CTRL) != 0) { + keyMod |= KEYMOD_CTRL; + keyCode &= ~KEYMOD_CTRL; + } + if ((keyCode & KEYMOD_ALT) != 0) { + keyMod |= KEYMOD_ALT; + keyCode &= ~KEYMOD_ALT; + } + if ((keyCode & KEYMOD_NUM_LOCK) != 0) { + keyMod |= KEYMOD_NUM_LOCK; + keyCode &= ~KEYMOD_NUM_LOCK; + } + return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication); + } + + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) { + boolean numLockOn = (keyMode & KEYMOD_NUM_LOCK) != 0; + keyMode &= ~KEYMOD_NUM_LOCK; + switch (keyCode) { + case KEYCODE_DPAD_CENTER: + return "\015"; + + case KEYCODE_DPAD_UP: + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + case KEYCODE_DPAD_DOWN: + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + case KEYCODE_DPAD_RIGHT: + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + case KEYCODE_DPAD_LEFT: + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + + case KEYCODE_MOVE_HOME: + // Note that KEYCODE_HOME is handled by the system and never delivered to applications. + // On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow. + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + case KEYCODE_MOVE_END: + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + + // An xterm can send function keys F1 to F4 in two modes: vt100 compatible or + // not. Because Vim may not know what the xterm is sending, both types of keys + // are recognized. The same happens for the and keys. + // normal vt100 ~ + // t_k1 [11~ OP *-xterm* + // t_k2 [12~ OQ *-xterm* + // t_k3 [13~ OR *-xterm* + // t_k4 [14~ OS *-xterm* + // t_kh [7~ OH *-xterm* + // t_@7 [4~ OF *-xterm* + case KEYCODE_F1: + return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P'); + case KEYCODE_F2: + return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q'); + case KEYCODE_F3: + return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R'); + case KEYCODE_F4: + return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S'); + case KEYCODE_F5: + return transformForModifiers("\033[15", keyMode, '~'); + case KEYCODE_F6: + return transformForModifiers("\033[17", keyMode, '~'); + case KEYCODE_F7: + return transformForModifiers("\033[18", keyMode, '~'); + case KEYCODE_F8: + return transformForModifiers("\033[19", keyMode, '~'); + case KEYCODE_F9: + return transformForModifiers("\033[20", keyMode, '~'); + case KEYCODE_F10: + return transformForModifiers("\033[21", keyMode, '~'); + case KEYCODE_F11: + return transformForModifiers("\033[23", keyMode, '~'); + case KEYCODE_F12: + return transformForModifiers("\033[24", keyMode, '~'); + + case KEYCODE_SYSRQ: + return "\033[32~"; // Sys Request / Print + // Is this Scroll lock? case Cancel: return "\033[33~"; + case KEYCODE_BREAK: + return "\033[34~"; // Pause/Break + + case KEYCODE_ESCAPE: + case KEYCODE_BACK: + return "\033"; + + case KEYCODE_INSERT: + return transformForModifiers("\033[2", keyMode, '~'); + case KEYCODE_FORWARD_DEL: + return transformForModifiers("\033[3", keyMode, '~'); + + case KEYCODE_PAGE_UP: + return transformForModifiers("\033[5", keyMode, '~'); + case KEYCODE_PAGE_DOWN: + return transformForModifiers("\033[6", keyMode, '~'); + case KEYCODE_DEL: + String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033"; + // Just do what xterm and gnome-terminal does: + return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008"); + case KEYCODE_NUM_LOCK: + if (keypadApplication) { + return "\033OP"; + } else { + return null; + } + case KEYCODE_SPACE: + // If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a + // combining accent to be written): + return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0"; + case KEYCODE_TAB: + // This is back-tab when shifted: + return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z"; + case KEYCODE_ENTER: + return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r"; + + case KEYCODE_NUMPAD_ENTER: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n"; + case KEYCODE_NUMPAD_MULTIPLY: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*"; + case KEYCODE_NUMPAD_ADD: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+"; + case KEYCODE_NUMPAD_COMMA: + return ","; + case KEYCODE_NUMPAD_DOT: + if (numLockOn) { + return keypadApplication ? "\033On" : "."; + } else { + // DELETE + return transformForModifiers("\033[3", keyMode, '~'); + } + case KEYCODE_NUMPAD_SUBTRACT: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-"; + case KEYCODE_NUMPAD_DIVIDE: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/"; + case KEYCODE_NUMPAD_0: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0"; + } else { + // INSERT + return transformForModifiers("\033[2", keyMode, '~'); + } + case KEYCODE_NUMPAD_1: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1"; + } else { + // END + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + } + case KEYCODE_NUMPAD_2: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2"; + } else { + // DOWN + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + } + case KEYCODE_NUMPAD_3: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3"; + } else { + // PGDN + return "\033[6~"; + } + case KEYCODE_NUMPAD_4: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4"; + } else { + // LEFT + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + } + case KEYCODE_NUMPAD_5: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5"; + case KEYCODE_NUMPAD_6: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6"; + } else { + // RIGHT + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + } + case KEYCODE_NUMPAD_7: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7"; + } else { + // HOME + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + } + case KEYCODE_NUMPAD_8: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8"; + } else { + // UP + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + } + case KEYCODE_NUMPAD_9: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9"; + } else { + // PGUP + return "\033[5~"; + } + case KEYCODE_NUMPAD_EQUALS: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "="; + } + + return null; + } + + private static String transformForModifiers(String start, int keymod, char lastChar) { + int modifier; + switch (keymod) { + case KEYMOD_SHIFT: + modifier = 2; + break; + case KEYMOD_ALT: + modifier = 3; + break; + case (KEYMOD_SHIFT | KEYMOD_ALT): + modifier = 4; + break; + case KEYMOD_CTRL: + modifier = 5; + break; + case KEYMOD_SHIFT | KEYMOD_CTRL: + modifier = 6; + break; + case KEYMOD_ALT | KEYMOD_CTRL: + modifier = 7; + break; + case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL: + modifier = 8; + break; + default: + return start + lastChar; + } + return start + (";" + modifier) + lastChar; + } +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/Logger.java b/termux/termux-emulator/src/main/java/com/termux/terminal/Logger.java new file mode 100644 index 000000000..d4d502e0e --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/Logger.java @@ -0,0 +1,80 @@ +package com.termux.terminal; + +import android.util.Log; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +public class Logger { + + public static void logError(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logError(logTag, message); + else + Log.e(logTag, message); + } + + public static void logWarn(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logWarn(logTag, message); + else + Log.w(logTag, message); + } + + public static void logInfo(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logInfo(logTag, message); + else + Log.i(logTag, message); + } + + public static void logDebug(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logDebug(logTag, message); + else + Log.d(logTag, message); + } + + public static void logVerbose(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logVerbose(logTag, message); + else + Log.v(logTag, message); + } + + public static void logStackTraceWithMessage(TerminalSessionClient client, String tag, String message, Throwable throwable) { + logError(client, tag, getMessageAndStackTraceString(message, throwable)); + } + + public static String getMessageAndStackTraceString(String message, Throwable throwable) { + if (message == null && throwable == null) + return null; + else if (message != null && throwable != null) + return message + ":\n" + getStackTraceString(throwable); + else if (throwable == null) + return message; + else + return getStackTraceString(throwable); + } + + public static String getStackTraceString(Throwable throwable) { + if (throwable == null) return null; + + String stackTraceString = null; + + try { + StringWriter errors = new StringWriter(); + PrintWriter pw = new PrintWriter(errors); + throwable.printStackTrace(pw); + pw.close(); + stackTraceString = errors.toString(); + errors.close(); + } catch (IOException e) { + e.printStackTrace(); + } + + return stackTraceString; + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java new file mode 100644 index 000000000..21d651878 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -0,0 +1,497 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll + * history. + *

+ * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + */ +public final class TerminalBuffer { + + TerminalRow[] mLines; + /** The length of {@link #mLines}. */ + int mTotalRows; + /** The number of rows and columns visible on the screen. */ + int mScreenRows, mColumns; + /** The number of rows kept in history. */ + private int mActiveTranscriptRows = 0; + /** The index in the circular buffer where the visible screen starts. */ + private int mScreenFirstRow = 0; + + /** + * Create a transcript screen. + * + * @param columns the width of the screen in characters. + * @param totalRows the height of the entire text area, in rows of text. + * @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off + * the top of the screen. + */ + public TerminalBuffer(int columns, int totalRows, int screenRows) { + mColumns = columns; + mTotalRows = totalRows; + mScreenRows = screenRows; + mLines = new TerminalRow[totalRows]; + + blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + } + + public String getTranscriptText() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); + } + + public String getTranscriptTextWithoutJoinedLines() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim(); + } + + public String getTranscriptTextWithFullLinesJoined() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim(); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2) { + return getSelectedText(selX1, selY1, selX2, selY2, true); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) { + return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, false); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) { + final StringBuilder builder = new StringBuilder(); + final int columns = mColumns; + + if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows(); + if (selY2 >= mScreenRows) selY2 = mScreenRows - 1; + + for (int row = selY1; row <= selY2; row++) { + int x1 = (row == selY1) ? selX1 : 0; + int x2; + if (row == selY2) { + x2 = selX2 + 1; + if (x2 > columns) x2 = columns; + } else { + x2 = columns; + } + TerminalRow lineObject = mLines[externalToInternalRow(row)]; + int x1Index = lineObject.findStartOfColumn(x1); + int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed(); + if (x2Index == x1Index) { + // Selected the start of a wide character. + x2Index = lineObject.findStartOfColumn(x2 + 1); + } + char[] line = lineObject.mText; + int lastPrintingCharIndex = -1; + int i; + boolean rowLineWrap = getLineWrap(row); + if (rowLineWrap && x2 == columns) { + // If the line was wrapped, we shouldn't lose trailing space: + lastPrintingCharIndex = x2Index - 1; + } else { + for (i = x1Index; i < x2Index; ++i) { + char c = line[i]; + if (c != ' ') lastPrintingCharIndex = i; + } + } + + int len = lastPrintingCharIndex - x1Index + 1; + if (lastPrintingCharIndex != -1 && len > 0) + builder.append(line, x1Index, len); + + boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1; + if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth) + && row < selY2 && row < mScreenRows - 1) builder.append('\n'); + } + return builder.toString(); + } + + public String getWordAtLocation(int x, int y) { + // Set y1 and y2 to the lines where the wrapped line starts and ends. + // I.e. if a line that is wrapped to 3 lines starts at line 4, and this + // is called with y=5, then y1 would be set to 4 and y2 would be set to 6. + int y1 = y; + int y2 = y; + while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, true, true).contains("\n")) { + y1--; + } + while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, true, true).contains("\n")) { + y2++; + } + + // Get the text for the whole wrapped line + String text = getSelectedText(0, y1, mColumns, y2, true, true); + // The index of x in text + int textOffset = (y - y1) * mColumns + x; + + if (textOffset >= text.length()) { + // The click was to the right of the last word on the line, so + // there's no word to return + return ""; + } + + // Set x1 and x2 to the indices of the last space before x and the + // first space after x in text respectively + int x1 = text.lastIndexOf(' ', textOffset); + int x2 = text.indexOf(' ', textOffset); + if (x2 == -1) { + x2 = text.length(); + } + + if (x1 == x2) { + // The click was on a space, so there's no word to return + return ""; + } + return text.substring(x1 + 1, x2); + } + + public int getActiveTranscriptRows() { + return mActiveTranscriptRows; + } + + public int getActiveRows() { + return mActiveTranscriptRows + mScreenRows; + } + + /** + * Convert a row value from the public external coordinate system to our internal private coordinate system. + * + *

+     * - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
+     * - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
+     *   mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
+     *
+     * External ↔ Internal:
+     *
+     * [ ...                            ]     [ ...                                     ]
+     * [ -mActiveTranscriptRows         ]     [ mScreenFirstRow - mActiveTranscriptRows ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ 0 (visible screen starts here) ]  ↔  [ mScreenFirstRow                         ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ mScreenRows-1                  ]     [ mScreenFirstRow + mScreenRows-1         ]
+     * 
+ * + * @param externalRow a row in the external coordinate system. + * @return The row corresponding to the input argument in the private coordinate system. + */ + public int externalToInternalRow(int externalRow) { + if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows) + throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows); + final int internalRow = mScreenFirstRow + externalRow; + return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows); + } + + public void setLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = true; + } + + public boolean getLineWrap(int row) { + return mLines[externalToInternalRow(row)].mLineWrap; + } + + public void clearLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = false; + } + + /** + * Resize the screen which this transcript backs. Currently, this only works if the number of columns does not + * change or the rows expand (that is, it only works when shrinking the number of rows). + * + * @param newColumns The number of columns the screen should have. + * @param newRows The number of rows the screen should have. + * @param cursor An int[2] containing the (column, row) cursor location. + */ + public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) { + // newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000): + if (newColumns == mColumns && newRows <= mTotalRows) { + // Fast resize where just the rows changed. + int shiftDownOfTopRow = mScreenRows - newRows; + if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) { + // Shrinking. Check if we can skip blank rows at bottom below cursor. + for (int i = mScreenRows - 1; i > 0; i--) { + if (cursor[1] >= i) break; + int r = externalToInternalRow(i); + if (mLines[r] == null || mLines[r].isBlank()) { + if (--shiftDownOfTopRow == 0) break; + } + } + } else if (shiftDownOfTopRow < 0) { + // Negative shift down = expanding. Only move screen up if there is transcript to show: + int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows); + if (shiftDownOfTopRow != actualShift) { + // The new lines revealed by the resizing are not all from the transcript. Blank the below ones. + for (int i = 0; i < actualShift - shiftDownOfTopRow; i++) + allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle); + shiftDownOfTopRow = actualShift; + } + } + mScreenFirstRow += shiftDownOfTopRow; + mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows); + mTotalRows = newTotalRows; + mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow); + cursor[1] -= shiftDownOfTopRow; + mScreenRows = newRows; + } else { + // Copy away old state and update new: + TerminalRow[] oldLines = mLines; + mLines = new TerminalRow[newTotalRows]; + for (int i = 0; i < newTotalRows; i++) + mLines[i] = new TerminalRow(newColumns, currentStyle); + + final int oldActiveTranscriptRows = mActiveTranscriptRows; + final int oldScreenFirstRow = mScreenFirstRow; + final int oldScreenRows = mScreenRows; + final int oldTotalRows = mTotalRows; + mTotalRows = newTotalRows; + mScreenRows = newRows; + mActiveTranscriptRows = mScreenFirstRow = 0; + mColumns = newColumns; + + int newCursorRow = -1; + int newCursorColumn = -1; + int oldCursorRow = cursor[1]; + int oldCursorColumn = cursor[0]; + boolean newCursorPlaced = false; + + int currentOutputExternalRow = 0; + int currentOutputExternalColumn = 0; + + // Loop over every character in the initial state. + // Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we + // keep track how many blank lines we have skipped if we later on find a non-blank line. + int skippedBlankLines = 0; + for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) { + // Do what externalToInternalRow() does but for the old state: + int internalOldRow = oldScreenFirstRow + externalOldRow; + internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows); + + TerminalRow oldLine = oldLines[internalOldRow]; + boolean cursorAtThisRow = externalOldRow == oldCursorRow; + // The cursor may only be on a non-null line, which we should not skip: + if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) { + skippedBlankLines++; + continue; + } else if (skippedBlankLines > 0) { + // After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines. + for (int i = 0; i < skippedBlankLines; i++) { + if (currentOutputExternalRow == mScreenRows - 1) { + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + skippedBlankLines = 0; + } + + int lastNonSpaceIndex = 0; + boolean justToCursor = false; + if (cursorAtThisRow || oldLine.mLineWrap) { + // Take the whole line, either because of cursor on it, or if line wrapping. + lastNonSpaceIndex = oldLine.getSpaceUsed(); + if (cursorAtThisRow) justToCursor = true; + } else { + for (int i = 0; i < oldLine.getSpaceUsed(); i++) + // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices + if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */) + lastNonSpaceIndex = i + 1; + } + + int currentOldCol = 0; + long styleAtCol = 0; + for (int i = 0; i < lastNonSpaceIndex; i++) { + // Note that looping over java character, not cells. + char c = oldLine.mText[i]; + int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c; + int displayWidth = WcWidth.width(codePoint); + // Use the last style if this is a zero-width character: + if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol); + + // Line wrap as necessary: + if (currentOutputExternalColumn + displayWidth > mColumns) { + setLineWrap(currentOutputExternalRow); + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0); + int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar; + setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol); + + if (displayWidth > 0) { + if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) { + newCursorColumn = currentOutputExternalColumn; + newCursorRow = currentOutputExternalRow; + newCursorPlaced = true; + } + currentOldCol += displayWidth; + currentOutputExternalColumn += displayWidth; + if (justToCursor && newCursorPlaced) break; + } + } + // Old row has been copied. Check if we need to insert newline if old line was not wrapping: + if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) { + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + } + + cursor[0] = newCursorColumn; + cursor[1] = newCursorRow; + } + + // Handle cursor scrolling off screen: + if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0; + } + + /** + * Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound + * into account. + * + * @param srcInternal The first line to be copied. + * @param len The number of lines to be copied. + */ + private void blockCopyLinesDown(int srcInternal, int len) { + if (len == 0) return; + int totalRows = mTotalRows; + + int start = len - 1; + // Save away line to be overwritten: + TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows]; + // Do the copy from bottom to top. + for (int i = start; i >= 0; --i) + mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows]; + // Put back overwritten line, now above the block: + mLines[(srcInternal) % totalRows] = lineToBeOverWritten; + } + + /** + * Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24). + * + * @param topMargin First line that is scrolled. + * @param bottomMargin One line after the last line that is scrolled. + * @param style the style for the newly exposed line. + */ + public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { + if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows) + throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows); + + // Copy the fixed topMargin lines one line down so that they remain on screen in same position: + blockCopyLinesDown(mScreenFirstRow, topMargin); + // Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same + // position: + blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin); + + // Update the screen location in the ring buffer: + mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows; + // Note that the history has grown if not already full: + if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++; + + // Blank the newly revealed line above the bottom margin: + int blankRow = externalToInternalRow(bottomMargin - 1); + if (mLines[blankRow] == null) { + mLines[blankRow] = new TerminalRow(mColumns, style); + } else { + mLines[blankRow].clear(style); + } + } + + /** + * Block copy characters from one position in the screen to another. The two positions can overlap. All characters + * of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will + * be thrown. + * + * @param sx source X coordinate + * @param sy source Y coordinate + * @param w width + * @param h height + * @param dx destination X coordinate + * @param dy destination Y coordinate + */ + public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) { + if (w == 0) return; + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows) + throw new IllegalArgumentException(); + boolean copyingUp = sy > dy; + for (int y = 0; y < h; y++) { + int y2 = copyingUp ? y : (h - (y + 1)); + TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2)); + allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx); + } + } + + /** + * Block set characters. All characters must be within the bounds of the screen, or else and + * InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block + * of characters. + */ + public void blockSet(int sx, int sy, int w, int h, int val, long style) { + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) { + throw new IllegalArgumentException( + "Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")"); + } + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + setChar(sx + x, sy + y, val, style); + } + + public TerminalRow allocateFullLineIfNecessary(int row) { + return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row]; + } + + public void setChar(int column, int row, int codePoint, long style) { + if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns) + throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); + row = externalToInternalRow(row); + allocateFullLineIfNecessary(row).setChar(column, codePoint, style); + } + + public long getStyleAt(int externalRow, int column) { + return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column); + } + + /** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */ + public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left, + int bottom, int right) { + for (int y = top; y < bottom; y++) { + TerminalRow line = mLines[externalToInternalRow(y)]; + int startOfLine = (rectangular || y == top) ? left : leftMargin; + int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin; + for (int x = startOfLine; x < endOfLine; x++) { + long currentStyle = line.getStyle(x); + int foreColor = TextStyle.decodeForeColor(currentStyle); + int backColor = TextStyle.decodeBackColor(currentStyle); + int effect = TextStyle.decodeEffect(currentStyle); + if (reverse) { + // Clear out the bits to reverse and add them back in reversed: + effect = (effect & ~bits) | (bits & ~effect); + } else if (setOrClear) { + effect |= bits; + } else { + effect &= ~bits; + } + line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect); + } + } + } + + public void clearTranscript() { + if (mScreenFirstRow < mActiveTranscriptRows) { + Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null); + Arrays.fill(mLines, 0, mScreenFirstRow, null); + } else { + Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); + } + mActiveTranscriptRows = 0; + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java new file mode 100644 index 000000000..408878180 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java @@ -0,0 +1,126 @@ +package com.termux.terminal; + +import java.util.Map; +import java.util.Properties; + +/** + * Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using + * Operating System Control (OSC) sequences. + * + * @see TerminalColors + */ +public final class TerminalColorScheme { + + /** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */ + private static final int[] DEFAULT_COLORSCHEME = { + // 16 original colors. First 8 are dim. + 0xff000000, // black + 0xffcd0000, // dim red + 0xff00cd00, // dim green + 0xffcdcd00, // dim yellow + 0xff6495ed, // dim blue + 0xffcd00cd, // dim magenta + 0xff00cdcd, // dim cyan + 0xffe5e5e5, // dim white + // Second 8 are bright: + 0xff7f7f7f, // medium grey + 0xffff0000, // bright red + 0xff00ff00, // bright green + 0xffffff00, // bright yellow + 0xff5c5cff, // light blue + 0xffff00ff, // bright magenta + 0xff00ffff, // bright cyan + 0xffffffff, // bright white + + // 216 color cube, six shades of each color: + 0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff, + 0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff, + 0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff, + 0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff, + 0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff, + 0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff, + 0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff, + 0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff, + 0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff, + 0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff, + 0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff, + 0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff, + 0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff, + 0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff, + 0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff, + 0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff, + 0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff, + 0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff, + + // 24 grey scale ramp: + 0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676, + 0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee, + + // COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR: + 0xffffffff, 0xff000000, 0xffffffff}; + + public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + public TerminalColorScheme() { + reset(); + } + + private void reset() { + System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + public void updateWith(Properties props) { + reset(); + boolean cursorPropExists = false; + for (Map.Entry entries : props.entrySet()) { + String key = (String) entries.getKey(); + String value = (String) entries.getValue(); + int colorIndex; + + if (key.equals("foreground")) { + colorIndex = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (key.equals("background")) { + colorIndex = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (key.equals("cursor")) { + colorIndex = TextStyle.COLOR_INDEX_CURSOR; + cursorPropExists = true; + } else if (key.startsWith("color")) { + try { + colorIndex = Integer.parseInt(key.substring(5)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + } else { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + + int colorValue = TerminalColors.parse(value); + if (colorValue == 0) + throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'"); + + mDefaultColors[colorIndex] = colorValue; + } + + if (!cursorPropExists) + setCursorColorForBackground(); + } + + /** + * If the "cursor" color is not set by user, we need to decide on the appropriate color that will + * be visible on the current terminal background. White will not be visible on light backgrounds + * and black won't be visible on dark backgrounds. So we find the perceived brightness of the + * background color and if its below the threshold (too dark), we use white cursor and if its + * above (too bright), we use black cursor. + */ + public void setCursorColorForBackground() { + int backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND]; + int brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor); + if (brightness > 0) { + if (brightness < 130) + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xffffffff; + else + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xff000000; + } + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalColors.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalColors.java new file mode 100644 index 000000000..25135a2fa --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalColors.java @@ -0,0 +1,96 @@ +package com.termux.terminal; + +import android.graphics.Color; + +/** Current terminal colors (if different from default). */ +public final class TerminalColors { + + /** Static data - a bit ugly but ok for now. */ + public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme(); + + /** + * The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC + * 4 control sequence. + */ + public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + /** Create a new instance with default colors from the theme. */ + public TerminalColors() { + reset(); + } + + /** Reset a particular indexed color with the default color from the color theme. */ + public void reset(int index) { + mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index]; + } + + /** Reset all indexed colors with the default color from the color theme. */ + public void reset() { + System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + /** + * Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html + *

+ * Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed. + */ + static int parse(String c) { + try { + int skipInitial, skipBetween; + if (c.charAt(0) == '#') { + // #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits. + skipInitial = 1; + skipBetween = 0; + } else if (c.startsWith("rgb:")) { + // rgb:// where , , := h | hh | hhh | hhhh. Scaled. + skipInitial = 4; + skipBetween = 1; + } else { + return 0; + } + int charsForColors = c.length() - skipInitial - 2 * skipBetween; + if (charsForColors % 3 != 0) return 0; // Unequal lengths. + int componentLength = charsForColors / 3; + double mult = 255 / (Math.pow(2, componentLength * 4) - 1); + + int currentPosition = skipInitial; + String rString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String gString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String bString = c.substring(currentPosition, currentPosition + componentLength); + + int r = (int) (Integer.parseInt(rString, 16) * mult); + int g = (int) (Integer.parseInt(gString, 16) * mult); + int b = (int) (Integer.parseInt(bString, 16) * mult); + return 0xFF << 24 | r << 16 | g << 8 | b; + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return 0; + } + } + + /** Try parse a color from a text parameter and into a specified index. */ + public void tryParseColor(int intoIndex, String textParameter) { + int c = parse(textParameter); + if (c != 0) mCurrentColors[intoIndex] = c; + } + + /** + * Get the perceived brightness of the color based on its RGB components. + * + * https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx + * http://alienryderflex.com/hsp.html + * + * @param color The color code int. + * @return Returns value between 0-255. + */ + public static int getPerceivedBrightnessOfColor(int color) { + return (int) + Math.floor(Math.sqrt( + Math.pow(Color.red(color), 2) * 0.241 + + Math.pow(Color.green(color), 2) * 0.691 + + Math.pow(Color.blue(color), 2) * 0.068 + )); + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java new file mode 100644 index 000000000..aeef393c6 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -0,0 +1,2507 @@ +package com.termux.terminal; + +import android.util.Base64; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.Stack; + +/** + * Renders text into a screen. Contains all the terminal-specific knowledge and state. Emulates a subset of the X Window + * System xterm terminal, which in turn is an emulator for a subset of the Digital Equipment Corporation vt100 terminal. + *

+ * References: + *

    + *
  • http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
  • + *
  • http://en.wikipedia.org/wiki/ANSI_escape_code
  • + *
  • http://man.he.net/man4/console_codes
  • + *
  • http://bazaar.launchpad.net/~leonerd/libvterm/trunk/view/head:/src/state.c
  • + *
  • http://www.columbia.edu/~kermit/k95manual/iso2022.html
  • + *
  • http://www.vt100.net/docs/vt510-rm/chapter4
  • + *
  • http://en.wikipedia.org/wiki/ISO/IEC_2022 - for 7-bit and 8-bit GL GR explanation
  • + *
  • http://bjh21.me.uk/all-escapes/all-escapes.txt - extensive!
  • + *
  • http://woldlab.caltech.edu/~diane/kde4.10/workingdir/kubuntu/konsole/doc/developer/old-documents/VT100/techref. + * html - document for konsole - accessible!
  • + *
+ */ +public final class TerminalEmulator { + + /** Log unknown or unimplemented escape sequences received from the shell process. */ + private static final boolean LOG_ESCAPE_SEQUENCES = false; + + public static final int MOUSE_LEFT_BUTTON = 0; + + /** Mouse moving while having left mouse button pressed. */ + public static final int MOUSE_LEFT_BUTTON_MOVED = 32; + public static final int MOUSE_WHEELUP_BUTTON = 64; + public static final int MOUSE_WHEELDOWN_BUTTON = 65; + + /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */ + public static final int UNICODE_REPLACEMENT_CHAR = 0xFFFD; + + /** Escape processing: Not currently in an escape sequence. */ + private static final int ESC_NONE = 0; + /** Escape processing: Have seen an ESC character - proceed to {@link #doEsc(int)} */ + private static final int ESC = 1; + /** Escape processing: Have seen ESC POUND */ + private static final int ESC_POUND = 2; + /** Escape processing: Have seen ESC and a character-set-select ( char */ + private static final int ESC_SELECT_LEFT_PAREN = 3; + /** Escape processing: Have seen ESC and a character-set-select ) char */ + private static final int ESC_SELECT_RIGHT_PAREN = 4; + /** Escape processing: "ESC [" or CSI (Control Sequence Introducer). */ + private static final int ESC_CSI = 6; + /** Escape processing: ESC [ ? */ + private static final int ESC_CSI_QUESTIONMARK = 7; + /** Escape processing: ESC [ $ */ + private static final int ESC_CSI_DOLLAR = 8; + /** Escape processing: ESC % */ + private static final int ESC_PERCENT = 9; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) */ + private static final int ESC_OSC = 10; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) ESC */ + private static final int ESC_OSC_ESC = 11; + /** Escape processing: ESC [ > */ + private static final int ESC_CSI_BIGGERTHAN = 12; + /** Escape procession: "ESC P" or Device Control String (DCS) */ + private static final int ESC_P = 13; + /** Escape processing: CSI > */ + private static final int ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14; + /** Escape processing: CSI $ARGS ' ' */ + private static final int ESC_CSI_ARGS_SPACE = 15; + /** Escape processing: CSI $ARGS '*' */ + private static final int ESC_CSI_ARGS_ASTERIX = 16; + /** Escape processing: CSI " */ + private static final int ESC_CSI_DOUBLE_QUOTE = 17; + /** Escape processing: CSI ' */ + private static final int ESC_CSI_SINGLE_QUOTE = 18; + /** Escape processing: CSI ! */ + private static final int ESC_CSI_EXCLAMATION = 19; + + /** The number of parameter arguments. This name comes from the ANSI standard for terminal escape codes. */ + private static final int MAX_ESCAPE_PARAMETERS = 16; + + /** Needs to be large enough to contain reasonable OSC 52 pastes. */ + private static final int MAX_OSC_STRING_LENGTH = 8192; + + /** DECSET 1 - application cursor keys. */ + private static final int DECSET_BIT_APPLICATION_CURSOR_KEYS = 1; + private static final int DECSET_BIT_REVERSE_VIDEO = 1 << 1; + /** + * http://www.vt100.net/docs/vt510-rm/DECOM: "When DECOM is set, the home cursor position is at the upper-left + * corner of the screen, within the margins. The starting point for line numbers depends on the current top margin + * setting. The cursor cannot move outside of the margins. When DECOM is reset, the home cursor position is at the + * upper-left corner of the screen. The starting point for line numbers is independent of the margins. The cursor + * can move outside of the margins." + */ + private static final int DECSET_BIT_ORIGIN_MODE = 1 << 2; + /** + * http://www.vt100.net/docs/vt510-rm/DECAWM: "If the DECAWM function is set, then graphic characters received when + * the cursor is at the right border of the page appear at the beginning of the next line. Any text on the page + * scrolls up if the cursor is at the end of the scrolling region. If the DECAWM function is reset, then graphic + * characters received when the cursor is at the right border of the page replace characters already on the page." + */ + private static final int DECSET_BIT_AUTOWRAP = 1 << 3; + /** DECSET 25 - if the cursor should be enabled, {@link #isCursorEnabled()}. */ + private static final int DECSET_BIT_CURSOR_ENABLED = 1 << 4; + private static final int DECSET_BIT_APPLICATION_KEYPAD = 1 << 5; + /** DECSET 1000 - if to report mouse press&release events. */ + private static final int DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE = 1 << 6; + /** DECSET 1002 - like 1000, but report moving mouse while pressed. */ + private static final int DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT = 1 << 7; + /** DECSET 1004 - NOT implemented. */ + private static final int DECSET_BIT_SEND_FOCUS_EVENTS = 1 << 8; + /** DECSET 1006 - SGR-like mouse protocol (the modern sane choice). */ + private static final int DECSET_BIT_MOUSE_PROTOCOL_SGR = 1 << 9; + /** DECSET 2004 - see {@link #paste(String)} */ + private static final int DECSET_BIT_BRACKETED_PASTE_MODE = 1 << 10; + /** Toggled with DECLRMM - http://www.vt100.net/docs/vt510-rm/DECLRMM */ + private static final int DECSET_BIT_LEFTRIGHT_MARGIN_MODE = 1 << 11; + /** Not really DECSET bit... - http://www.vt100.net/docs/vt510-rm/DECSACE */ + private static final int DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE = 1 << 12; + + + private String mTitle; + private final Stack mTitleStack = new Stack<>(); + + /** If processing first character of first parameter of {@link #ESC_CSI}. */ + private boolean mIsCSIStart; + /** The last character processed of a parameter of {@link #ESC_CSI}. */ + private Integer mLastCSIArg; + + /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ + private int mCursorRow, mCursorCol; + + /** The number of character rows and columns in the terminal screen. */ + public int mRows, mColumns; + + /** The number of terminal transcript rows that can be scrolled back to. */ + public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100; + public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000; + public static final int DEFAULT_TERMINAL_TRANSCRIPT_ROWS = 2000; + + + /* The supported terminal cursor styles. */ + + public static final int TERMINAL_CURSOR_STYLE_BLOCK = 0; + public static final int TERMINAL_CURSOR_STYLE_UNDERLINE = 1; + public static final int TERMINAL_CURSOR_STYLE_BAR = 2; + public static final int DEFAULT_TERMINAL_CURSOR_STYLE = TERMINAL_CURSOR_STYLE_BLOCK; + public static final Integer[] TERMINAL_CURSOR_STYLES_LIST = new Integer[]{TERMINAL_CURSOR_STYLE_BLOCK, TERMINAL_CURSOR_STYLE_UNDERLINE, TERMINAL_CURSOR_STYLE_BAR}; + + /** The terminal cursor styles. */ + private int mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + + + /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */ + private final TerminalBuffer mMainBuffer; + /** + * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when + * the alternate screen buffer is active, you cannot scroll back to view saved lines). + *

+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer + */ + final TerminalBuffer mAltBuffer; + /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */ + private TerminalBuffer mScreen; + + /** The terminal session this emulator is bound to. */ + private final TerminalOutput mSession; + + TerminalSessionClient mClient; + + /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */ + private int mArgIndex; + /** Holds the arguments of the current escape sequence. */ + private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS]; + + /** Holds OSC and device control arguments, which can be strings. */ + private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); + + /** + * True if the current escape sequence should continue, false if the current escape sequence should be terminated. + * Used when parsing a single character. + */ + private boolean mContinueSequence; + + /** The current state of the escape sequence state machine. One of the ESC_* constants. */ + private int mEscapeState; + + private final SavedScreenState mSavedStateMain = new SavedScreenState(); + private final SavedScreenState mSavedStateAlt = new SavedScreenState(); + + /** http://www.vt100.net/docs/vt102-ug/table5-15.html */ + private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + + /** + * @see TerminalEmulator#mapDecSetBitToInternalBit(int) + */ + private int mCurrentDecSetFlags, mSavedDecSetFlags; + + /** + * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing + * existing text to the right. Characters moved past the right margin are lost. + */ + private boolean mInsertMode; + + /** An array of tab stops. mTabStop[i] is true if there is a tab stop set for column i. */ + private boolean[] mTabStop; + + /** + * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows + * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns]. + */ + private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin; + + /** + * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case + * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be + * output in the last column, the cursor not moving but this flag will be set. When outputting another character + * this will move to the next line. + */ + private boolean mAboutToAutoWrap; + + /** + * If the cursor blinking is enabled. It requires cursor itself to be enabled, which is controlled + * byt whether {@link #DECSET_BIT_CURSOR_ENABLED} bit is set or not. + */ + private boolean mCursorBlinkingEnabled; + + /** + * If currently cursor should be in a visible state or not if {@link #mCursorBlinkingEnabled} + * is {@code true}. + */ + private boolean mCursorBlinkState; + + /** + * Current foreground and background colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. + * For a 24-bit value the top byte (0xff000000) is set. + * + * @see TextStyle + */ + int mForeColor, mBackColor; + + /** Current {@link TextStyle} effect. */ + private int mEffect; + + /** + * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along + * with the scrolling text. + */ + private int mScrollCounter = 0; + + /** If automatic scrolling of terminal is disabled */ + private boolean mAutoScrollDisabled; + + private byte mUtf8ToFollow, mUtf8Index; + private final byte[] mUtf8InputBuffer = new byte[4]; + private int mLastEmittedCodePoint = -1; + + public final TerminalColors mColors = new TerminalColors(); + + private static final String LOG_TAG = "TerminalEmulator"; + + private boolean isDecsetInternalBitSet(int bit) { + return (mCurrentDecSetFlags & bit) != 0; + } + + private void setDecsetinternalBit(int internalBit, boolean set) { + if (set) { + // The mouse modes are mutually exclusive. + if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false); + } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false); + } + } + if (set) { + mCurrentDecSetFlags |= internalBit; + } else { + mCurrentDecSetFlags &= ~internalBit; + } + } + + static int mapDecSetBitToInternalBit(int decsetBit) { + switch (decsetBit) { + case 1: + return DECSET_BIT_APPLICATION_CURSOR_KEYS; + case 5: + return DECSET_BIT_REVERSE_VIDEO; + case 6: + return DECSET_BIT_ORIGIN_MODE; + case 7: + return DECSET_BIT_AUTOWRAP; + case 25: + return DECSET_BIT_CURSOR_ENABLED; + case 66: + return DECSET_BIT_APPLICATION_KEYPAD; + case 69: + return DECSET_BIT_LEFTRIGHT_MARGIN_MODE; + case 1000: + return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE; + case 1002: + return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT; + case 1004: + return DECSET_BIT_SEND_FOCUS_EVENTS; + case 1006: + return DECSET_BIT_MOUSE_PROTOCOL_SGR; + case 2004: + return DECSET_BIT_BRACKETED_PASTE_MODE; + default: + return -1; + // throw new IllegalArgumentException("Unsupported decset: " + decsetBit); + } + } + + public TerminalEmulator(TerminalOutput session, int columns, int rows, Integer transcriptRows, TerminalSessionClient client) { + mSession = session; + mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows); + mAltBuffer = new TerminalBuffer(columns, rows, rows); + mClient = client; + mRows = rows; + mColumns = columns; + mTabStop = new boolean[mColumns]; + reset(); + } + + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + setCursorStyle(); + setCursorBlinkState(true); + } + + public TerminalBuffer getScreen() { + return mScreen; + } + + public boolean isAlternateBufferActive() { + return mScreen == mAltBuffer; + } + + private int getTerminalTranscriptRows(Integer transcriptRows) { + if (transcriptRows == null || transcriptRows < TERMINAL_TRANSCRIPT_ROWS_MIN || transcriptRows > TERMINAL_TRANSCRIPT_ROWS_MAX) + return DEFAULT_TERMINAL_TRANSCRIPT_ROWS; + else + return transcriptRows; + } + + /** + * @param mouseButton one of the MOUSE_* constants of this class. + */ + public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) { + if (column < 1) column = 1; + if (column > mColumns) column = mColumns; + if (row < 1) row = 1; + if (row > mRows) row = mRows; + + if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) { + // Do not send tracking. + } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) { + mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row)); + } else { + mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons. + // Clip to screen, and clip to the limits of 8-bit data. + boolean out_of_bounds = column > 255 - 32 || row > 255 - 32; + if (!out_of_bounds) { + byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)}; + mSession.write(data, 0, data.length); + } + } + } + + public void resize(int columns, int rows) { + if (mRows == rows && mColumns == columns) { + return; + } else if (columns < 2 || rows < 2) { + throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns); + } + + if (mRows != rows) { + mRows = rows; + mTopMargin = 0; + mBottomMargin = mRows; + } + if (mColumns != columns) { + int oldColumns = mColumns; + mColumns = columns; + boolean[] oldTabStop = mTabStop; + mTabStop = new boolean[mColumns]; + setDefaultTabStops(); + int toTransfer = Math.min(oldColumns, columns); + System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer); + mLeftMargin = 0; + mRightMargin = mColumns; + } + + resizeScreen(); + } + + private void resizeScreen() { + final int[] cursor = {mCursorCol, mCursorRow}; + int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows; + mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive()); + mCursorCol = cursor[0]; + mCursorRow = cursor[1]; + } + + public int getCursorRow() { + return mCursorRow; + } + + public int getCursorCol() { + return mCursorCol; + } + + /** Get the terminal cursor style. It will be one of {@link #TERMINAL_CURSOR_STYLES_LIST} */ + public int getCursorStyle() { + return mCursorStyle; + } + + /** Set the terminal cursor style. */ + public void setCursorStyle() { + Integer cursorStyle = null; + + if (mClient != null) + cursorStyle = mClient.getTerminalCursorStyle(); + + if (cursorStyle == null || !Arrays.asList(TERMINAL_CURSOR_STYLES_LIST).contains(cursorStyle)) + mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + else + mCursorStyle = cursorStyle; + } + + public boolean isReverseVideo() { + return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO); + } + + + + public boolean isCursorEnabled() { + return isDecsetInternalBitSet(DECSET_BIT_CURSOR_ENABLED); + } + public boolean shouldCursorBeVisible() { + if (!isCursorEnabled()) + return false; + else + return mCursorBlinkingEnabled ? mCursorBlinkState : true; + } + + public void setCursorBlinkingEnabled(boolean cursorBlinkingEnabled) { + this.mCursorBlinkingEnabled = cursorBlinkingEnabled; + } + + public void setCursorBlinkState(boolean cursorBlinkState) { + this.mCursorBlinkState = cursorBlinkState; + } + + + + public boolean isKeypadApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD); + } + + public boolean isCursorKeysApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS); + } + + /** If mouse events are being sent as escape codes to the terminal. */ + public boolean isMouseTrackingActive() { + return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT); + } + + private void setDefaultTabStops() { + for (int i = 0; i < mColumns; i++) + mTabStop[i] = (i & 7) == 0 && i != 0; + } + + /** + * Accept bytes (typically from the pseudo-teletype) and process them. + * + * @param buffer a byte array containing the bytes to be processed + * @param length the number of bytes in the array to process + */ + public void append(byte[] buffer, int length) { + for (int i = 0; i < length; i++) + processByte(buffer[i]); + } + + private void processByte(byte byteToProcess) { + if (mUtf8ToFollow > 0) { + if ((byteToProcess & 0b11000000) == 0b10000000) { + // 10xxxxxx, a continuation byte. + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + if (--mUtf8ToFollow == 0) { + byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111)); + int codePoint = (mUtf8InputBuffer[0] & firstByteMask); + for (int i = 1; i < mUtf8Index; i++) + codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111)); + if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2) + || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) { + // Overlong encoding. + codePoint = UNICODE_REPLACEMENT_CHAR; + } + + mUtf8Index = mUtf8ToFollow = 0; + + if (codePoint >= 0x80 && codePoint <= 0x9F) { + // Sequence decoded to a C1 control character which we ignore. They are + // not used nowadays and increases the risk of messing up the terminal state + // on binary input. XTerm does not allow them in utf-8: + // "It is not possible to use a C1 control obtained from decoding the + // UTF-8 text" - http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + } else { + switch (Character.getType(codePoint)) { + case Character.UNASSIGNED: + case Character.SURROGATE: + codePoint = UNICODE_REPLACEMENT_CHAR; + } + processCodePoint(codePoint); + } + } + } else { + // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char: + mUtf8Index = mUtf8ToFollow = 0; + emitCodePoint(UNICODE_REPLACEMENT_CHAR); + // The Unicode Standard Version 6.2 – Core Specification + // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf): + // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first + // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the + // successor bytes as part of the ill-formed subsequence + // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit + // subsequence." + processByte(byteToProcess); + } + } else { + if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character. + processCodePoint(byteToProcess); + return; + } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence. + mUtf8ToFollow = 1; + } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence. + mUtf8ToFollow = 2; + } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence. + mUtf8ToFollow = 3; + } else { + // Not a valid UTF-8 sequence start, signal invalid data: + processCodePoint(UNICODE_REPLACEMENT_CHAR); + return; + } + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + } + } + + public void processCodePoint(int b) { + switch (b) { + case 0: // Null character (NUL, ^@). Do nothing. + break; + case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell. + if (mEscapeState == ESC_OSC) + doOsc(b); + else + mSession.onBell(); + break; + case 8: // Backspace (BS, ^H). + if (mLeftMargin == mCursorCol) { + // Jump to previous line if it was auto-wrapped. + int previousRow = mCursorRow - 1; + if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) { + mScreen.clearLineWrap(previousRow); + setCursorRowCol(previousRow, mRightMargin - 1); + } + } else { + setCursorCol(mCursorCol - 1); + } + break; + case 9: // Horizontal tab (HT, \t) - move to next tab stop, but not past edge of screen + // XXX: Should perhaps use color if writing to new cells. Try with + // printf "\033[41m\tXX\033[0m\n" + // The OSX Terminal.app colors the spaces from the tab red, but xterm does not. + // Note that Terminal.app only colors on new cells, in e.g. + // printf "\033[41m\t\r\033[42m\tXX\033[0m\n" + // the first cells are created with a red background, but when tabbing over + // them again with a green background they are not overwritten. + mCursorCol = nextTabStop(1); + break; + case 10: // Line feed (LF, \n). + case 11: // Vertical tab (VT, \v). + case 12: // Form feed (FF, \f). + doLinefeed(); + break; + case 13: // Carriage return (CR, \r). + setCursorCol(mLeftMargin); + break; + case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. + mUseLineDrawingUsesG0 = false; + break; + case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set. + mUseLineDrawingUsesG0 = true; + break; + case 24: // CAN. + case 26: // SUB. + if (mEscapeState != ESC_NONE) { + // FIXME: What is this?? + mEscapeState = ESC_NONE; + emitCodePoint(127); + } + break; + case 27: // ESC + // Starts an escape sequence unless we're parsing a string + if (mEscapeState == ESC_P) { + // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator. + return; + } else if (mEscapeState != ESC_OSC) { + startEscapeSequence(); + } else { + doOsc(b); + } + break; + default: + mContinueSequence = false; + switch (mEscapeState) { + case ESC_NONE: + if (b >= 32) emitCodePoint(b); + break; + case ESC: + doEsc(b); + break; + case ESC_POUND: + doEscPound(b); + break; + case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100). + mUseLineDrawingG0 = (b == '0'); + break; + case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100). + mUseLineDrawingG1 = (b == '0'); + break; + case ESC_CSI: + doCsi(b); + break; + case ESC_CSI_EXCLAMATION: + if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). + reset(); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_QUESTIONMARK: + doCsiQuestionMark(b); + break; + case ESC_CSI_BIGGERTHAN: + doCsiBiggerThan(b); + break; + case ESC_CSI_DOLLAR: + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + switch (b) { + case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v" + // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA): + // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA. + // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM). + // DECCRA is not affected by the page margins. + // The copied text takes on the line attributes of the destination area. + // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value + // is treated as the width or height of that page. + // If the destination area is partially off the page, then DECCRA clips the off-page data. + // DECCRA does not change the active cursor position." + int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows); + int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns); + // Inclusive, so do not subtract one: + int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows); + int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns); + // int sourcePage = getArg(4, 1, true); + int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows); + int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns); + // int destinationPage = getArg(7, 1, true); + int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource); + int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource); + mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop); + break; + case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${" + // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA). + case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x" + // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA). + case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z" + // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA). + boolean erase = b != 'x'; + boolean selective = b == '{'; + // Only DECSERA keeps visual attributes, DECERA does not: + boolean keepVisualAttributes = erase && selective; + int argIndex = 0; + int fillChar = erase ? ' ' : getArg(argIndex++, -1, true); + // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the + // terminal ignores the DECFRA command": + if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) { + // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value + // is treated as the width or height of that page." + int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1); + int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1); + int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin); + int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin); + long style = getStyle(); + for (int row = top - 1; row < bottom; row++) + for (int col = left - 1; col < right; col++) + if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style); + } + break; + case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r" + // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA). + case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t" + // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA). + boolean reverse = b == 't'; + // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)". + int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin; + int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin; + int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin; + int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin; + if (mArgIndex >= 4) { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 4; i <= mArgIndex; i++) { + int bits = 0; + boolean setOrClear = true; // True if setting, false if clearing. + switch (getArg(i, 0, false)) { + case 0: // Attributes off (no bold, no underline, no blink, positive image). + bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK + | TextStyle.CHARACTER_ATTRIBUTE_INVERSE); + if (!reverse) setOrClear = false; + break; + case 1: // Bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + break; + case 4: // Underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + break; + case 5: // Blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + break; + case 7: // Negative image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + break; + case 22: // No bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + setOrClear = false; + break; + case 24: // No underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + setOrClear = false; + break; + case 25: // No blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + setOrClear = false; + break; + case 27: // Positive image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + setOrClear = false; + break; + } + if (reverse && !setOrClear) { + // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits. + } else { + mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE), + effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right); + } + } + } else { + // Do nothing. + } + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_DOUBLE_QUOTE: + if (b == 'q') { + // http://www.vt100.net/docs/vt510-rm/DECSCA + int arg = getArg0(0); + if (arg == 0 || arg == 2) { + // DECSED and DECSEL can erase characters. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else if (arg == 1) { + // DECSED and DECSEL cannot erase characters. + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else { + unknownSequence(b); + } + } else { + unknownSequence(b); + } + break; + case ESC_CSI_SINGLE_QUOTE: + if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToInsert; + mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0); + blockClear(mCursorCol, 0, columnsToInsert, mRows); + } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToDelete; + mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0); + } else { + unknownSequence(b); + } + break; + case ESC_PERCENT: + break; + case ESC_OSC: + doOsc(b); + break; + case ESC_OSC_ESC: + doOscEsc(b); + break; + case ESC_P: + doDeviceControl(b); + break; + case ESC_CSI_QUESTIONMARK_ARG_DOLLAR: + if (b == 'p') { + // Request DEC private mode (DECRQM). + int mode = getArg0(0); + int value; + if (mode == 47 || mode == 1047 || mode == 1049) { + // This state is carried by mScreen pointer. + value = (mScreen == mAltBuffer) ? 1 : 2; + } else { + int internalBit = mapDecSetBitToInternalBit(mode); + if (internalBit != -1) { + value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset. + } else { + Logger.logError(mClient, LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode); + value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset + } + } + mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value)); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_SPACE: + int arg = getArg0(0); + switch (b) { + case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR). + switch (arg) { + case 0: // Blinking block. + case 1: // Blinking block. + case 2: // Steady block. + mCursorStyle = TERMINAL_CURSOR_STYLE_BLOCK; + break; + case 3: // Blinking underline. + case 4: // Steady underline. + mCursorStyle = TERMINAL_CURSOR_STYLE_UNDERLINE; + break; + case 5: // Blinking bar (xterm addition). + case 6: // Steady bar (xterm addition). + mCursorStyle = TERMINAL_CURSOR_STYLE_BAR; + break; + } + break; + case 't': + case 'u': + // Set margin-bell volume - ignore. + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_ASTERIX: + int attributeChangeExtent = getArg0(0); + if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) { + // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE). + setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2); + } else { + unknownSequence(b); + } + break; + default: + unknownSequence(b); + break; + } + if (!mContinueSequence) mEscapeState = ESC_NONE; + break; + } + } + + /** When in {@link #ESC_P} ("device control") sequence. */ + private void doDeviceControl(int b) { + switch (b) { + case (byte) '\\': // End of ESC \ string Terminator + { + String dcs = mOSCOrDeviceControlArgs.toString(); + // DCS $ q P t ST. Request Status String (DECRQSS) + if (dcs.startsWith("$q")) { + if (dcs.equals("$q\"p")) { + // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL: + String csiString = "64;1\"p"; + mSession.write("\033P1$r" + csiString + "\033\\"); + } else { + finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'"); + } + } else if (dcs.startsWith("+q")) { + // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in + // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key + // names. + // Two special features are also recognized, which are not key names: Co for termcap colors (or colors + // for terminfo colors), and TN for termcap name (or name for terminfo name). + // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the + // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are + // encoded in hexadecimal (2 digits per character). + // Example: + // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\ + // where + // kd=down-arrow key + // kl=left-arrow key + // kr=right-arrow key + // ku=up-arrow key + // #2=key_shome, "shifted home" + // #4=key_sleft, "shift arrow left" + // %i=key_sright, "shift arrow right" + // *7=key_send, "shifted end" + // k1=F1 function key + + // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal. + // Xterm response in normal cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A + // Xterm response in application cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A + + // #4 is "shift arrow left": + // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \' + // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \ + // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D + // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D"); + + // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to + // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for + // the meaning of e.g. "ku", "kd", "kr", "kl" + + for (String part : dcs.substring(2).split(";")) { + if (part.length() % 2 == 0) { + StringBuilder transBuffer = new StringBuilder(); + char c; + for (int i = 0; i < part.length(); i += 2) { + try { + c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue(); + } catch (NumberFormatException e) { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Invalid device termcap/terminfo encoded name \"" + part + "\"", e); + continue; + } + transBuffer.append(c); + } + + String trans = transBuffer.toString(); + String responseValue; + switch (trans) { + case "Co": + case "colors": + responseValue = "256"; // Number of colors. + break; + case "TN": + case "name": + responseValue = "xterm"; + break; + default: + responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS), + isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD)); + break; + } + if (responseValue == null) { + switch (trans) { + case "%1": // Help key - ignore + case "&8": // Undo key - ignore. + break; + default: + Logger.logWarn(mClient, LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'"); + } + // Respond with invalid request: + mSession.write("\033P0+r" + part + "\033\\"); + } else { + StringBuilder hexEncoded = new StringBuilder(); + for (int j = 0; j < responseValue.length(); j++) { + hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j))); + } + mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\"); + } + } else { + Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); + } + } + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs); + } + finishSequence(); + } + break; + default: + if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) { + // Too long. + mOSCOrDeviceControlArgs.setLength(0); + finishSequence(); + } else { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } + } + } + + private int nextTabStop(int numTabs) { + for (int i = mCursorCol + 1; i < mColumns; i++) + if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); + return mRightMargin - 1; + } + + /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ + private void doCsiQuestionMark(int b) { + switch (b) { + case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED. + case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL. + mAboutToAutoWrap = false; + int fillChar = ' '; + int startCol = -1; + int startRow = -1; + int endCol = -1; + int endRow = -1; + boolean justRow = (b == 'K'); + switch (getArg0(0)) { + case 0: // Erase from the active position to the end, inclusive (default). + startCol = mCursorCol; + startRow = mCursorRow; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + case 1: // Erase from start to the active position, inclusive. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mCursorCol + 1; + endRow = mCursorRow + 1; + break; + case 2: // Erase all of the display/line. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + default: + unknownSequence(b); + break; + } + long style = getStyle(); + for (int row = startRow; row < endRow; row++) { + for (int col = startCol; col < endCol; col++) { + if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, style); + } + } + break; + case 'h': + case 'l': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) + doDecSetOrReset(b == 'h', mArgs[i]); + break; + case 'n': // Device Status Report (DSR, DEC-specific). + switch (getArg0(-1)) { + case 6: + // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1. + mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1)); + break; + default: + finishSequence(); + return; + } + break; + case 'r': + case 's': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int externalBit = mArgs[i]; + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit == -1) { + Logger.logWarn(mClient, LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit); + } else { + if (b == 's') { + mSavedDecSetFlags |= internalBit; + } else { + doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit); + } + } + } + break; + case '$': + continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR); + return; + default: + parseArg(b); + } + } + + public void doDecSetOrReset(boolean setting, int externalBit) { + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit != -1) { + setDecsetinternalBit(internalBit, setting); + } + switch (externalBit) { + case 1: // Application Cursor Keys (DECCKM). + break; + case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM. + // We don't actually set/reset 132 cols, but we do want the side effects + // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?): + // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for + // the "reset" utility to really reset the terminal: + mLeftMargin = mTopMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable": + setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false); + // "Erases all data in page memory": + blockClear(0, 0, mColumns, mRows); + setCursorRowCol(0, 0); + break; + case 4: // DECSCLM-Scrolling Mode. Ignore. + break; + case 5: // Reverse video. No action. + break; + case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM. + if (setting) setCursorPosition(0, 0); + break; + case 7: // Wrap-around bit, not specific action. + case 8: // Auto-repeat Keys (DECARM). Do not implement. + case 9: // X10 mouse reporting - outdated. Do not implement. + case 12: // Control cursor blinking - ignore. + case 25: // Hide/show cursor - no action needed, renderer will check with shouldCursorBeVisible(). + if (mClient != null) + mClient.onTerminalCursorStateChange(setting); + break; + case 40: // Allow 80 => 132 Mode, ignore. + case 45: // TODO: Reverse wrap-around. Implement??? + case 66: // Application keypad (DECNKM). + break; + case 69: // Left and right margin mode (DECLRMM). + if (!setting) { + mLeftMargin = 0; + mRightMargin = mColumns; + } + break; + case 1000: + case 1001: + case 1002: + case 1003: + case 1004: + case 1005: // UTF-8 mouse mode, ignore. + case 1006: // SGR Mouse Mode + case 1015: + case 1034: // Interpret "meta" key, sets eighth bit. + break; + case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC. + if (setting) + saveCursor(); + else + restoreCursor(); + break; + case 47: + case 1047: + case 1049: { + // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first. + // Reset: Use Normal Screen Buffer and restore cursor as in DECRC. + TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer; + if (newScreen != mScreen) { + boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows); + if (setting) saveCursor(); + mScreen = newScreen; + if (!setting) { + int col = mSavedStateMain.mSavedCursorCol; + int row = mSavedStateMain.mSavedCursorRow; + restoreCursor(); + if (resized) { + // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that): + mCursorCol = col; + mCursorRow = row; + } + } + // Check if buffer size needs to be updated: + if (resized) resizeScreen(); + // Clear new screen if alt buffer: + if (newScreen == mAltBuffer) + newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle()); + } + break; + } + case 2004: + // Bracketed paste mode - setting bit is enough. + break; + default: + unknownParameter(externalBit); + break; + } + } + + private void doCsiBiggerThan(int b) { + switch (b) { + case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2). + // Originally this was used for the terminal to respond with "identification code, firmware version level, + // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420 + // terminal type. This is not used anymore, but the second version level field has been changed by xterm + // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html), + // and some applications use it as a feature check: + // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check, + // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used. + // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR + // mouse report. + // The third number is a keyboard identifier not used nowadays. + mSession.write("\033[>41;320;0c"); + break; + case 'm': + // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25 + // Depending on the first number parameter, this can set one of the xterm resources + // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys. + // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES + + // * modifyKeyboard (parameter=1): + // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard + // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related + // terminals that implement user-defined keys (UDK). + // The bits of the resource value selectively enable modification of the given category when these keyboards + // are selected. The default is "0": + // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered + // function-keys. Other special keys are not modified. + // (1) allows modification of the numeric keypad + // (2) allows modification of the editing keypad + // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK. + // (8) allows modification of other special keys + + // * modifyCursorKeys (parameter=2): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a cursor-key. The default is "2". + // - Set it to -1 to disable it. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + + // * modifyFunctionKeys (parameter=3): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a (numbered) function- + // key. The default is "2". The resource values are similar to modifyCursorKeys: + // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings + // using the normal encoding scheme. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct + // numbered function-keys beyond the set provided by the keyboard: + // (Control) adds the value given by the ctrlFKeys resource. + // (Shift) adds twice the value given by the ctrlFKeys resource. + // (Control/Shift) adds three times the value given by the ctrlFKeys resource. + // + // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true) + // keyboards interpret only the Control-modifier when constructing numbered function-keys. + // This is done to provide compatible keyboards for DEC VT220 and related terminals that + // implement user-defined keys (UDK). + + // * modifyOtherKeys (parameter=4): + // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when + // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and + // well-defined keys such as ESC or the control keys. The default is "0". + // (0) disables this feature. + // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and + // some special control character cases, e.g., Control-Space to make a NUL. + // (2) enables this feature for keys including the exceptions listed. + Logger.logError(mClient, LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1)); + break; + default: + parseArg(b); + break; + } + } + + private void startEscapeSequence() { + mEscapeState = ESC; + mArgIndex = 0; + Arrays.fill(mArgs, -1); + } + + private void doLinefeed() { + boolean belowScrollingRegion = mCursorRow >= mBottomMargin; + int newCursorRow = mCursorRow + 1; + if (belowScrollingRegion) { + // Move down (but not scroll) as long as we are above the last row. + if (mCursorRow != mRows - 1) { + setCursorRow(newCursorRow); + } + } else { + if (newCursorRow == mBottomMargin) { + scrollDownOneLine(); + newCursorRow = mBottomMargin - 1; + } + setCursorRow(newCursorRow); + } + } + + private void continueSequence(int state) { + mEscapeState = state; + mContinueSequence = true; + } + + private void doEscPound(int b) { + switch (b) { + case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's. + mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle()); + break; + default: + unknownSequence(b); + break; + } + } + + /** Encountering a character in the {@link #ESC} state. */ + private void doEsc(int b) { + switch (b) { + case '#': + continueSequence(ESC_POUND); + break; + case '(': + continueSequence(ESC_SELECT_LEFT_PAREN); + break; + case ')': + continueSequence(ESC_SELECT_RIGHT_PAREN); + break; + case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start. + if (mCursorCol > mLeftMargin) { + mCursorCol--; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin); + mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC + saveCursor(); + break; + case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC + restoreCursor(); + break; + case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end. + if (mCursorCol < mRightMargin - 1) { + mCursorCol++; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin); + mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS). + reset(); + mMainBuffer.clearTranscript(); + blockClear(0, 0, mColumns, mRows); + setCursorPosition(0, 0); + break; + case 'D': // INDEX + doLinefeed(); + break; + case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL). + setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0); + doLinefeed(); + break; + case 'F': // Cursor to lower-left corner of screen + setCursorRowCol(0, mBottomMargin - 1); + break; + case 'H': // Tab set + mTabStop[mCursorCol] = true; + break; + case 'M': // "${ESC}M" - reverse index (RI). + // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal + // position on the preceding line. If the active position is at the top margin, a scroll down is performed". + if (mCursorRow <= mTopMargin) { + mScreen.blockCopy(0, mTopMargin, mColumns, mBottomMargin - (mTopMargin + 1), 0, mTopMargin + 1); + blockClear(0, mTopMargin, mColumns); + } else { + mCursorRow--; + } + break; + case 'N': // SS2, ignore. + case '0': // SS3, ignore. + break; + case 'P': // Device control string + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_P); + break; + case '[': + continueSequence(ESC_CSI); + mIsCSIStart = true; + mLastCSIArg = null; + break; + case '=': // DECKPAM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); + break; + case ']': // OSC + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_OSC); + break; + case '>': // DECKPNM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); + break; + default: + unknownSequence(b); + break; + } + } + + /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */ + private void saveCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + state.mSavedCursorRow = mCursorRow; + state.mSavedCursorCol = mCursorCol; + state.mSavedEffect = mEffect; + state.mSavedForeColor = mForeColor; + state.mSavedBackColor = mBackColor; + state.mSavedDecFlags = mCurrentDecSetFlags; + state.mUseLineDrawingG0 = mUseLineDrawingG0; + state.mUseLineDrawingG1 = mUseLineDrawingG1; + state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0; + } + + /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */ + private void restoreCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol); + mEffect = state.mSavedEffect; + mForeColor = state.mSavedForeColor; + mBackColor = state.mSavedBackColor; + int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE); + mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask); + mUseLineDrawingG0 = state.mUseLineDrawingG0; + mUseLineDrawingG1 = state.mUseLineDrawingG1; + mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0; + } + + /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */ + private void doCsi(int b) { + switch (b) { + case '!': + continueSequence(ESC_CSI_EXCLAMATION); + break; + case '"': + continueSequence(ESC_CSI_DOUBLE_QUOTE); + break; + case '\'': + continueSequence(ESC_CSI_SINGLE_QUOTE); + break; + case '$': + continueSequence(ESC_CSI_DOLLAR); + break; + case '*': + continueSequence(ESC_CSI_ARGS_ASTERIX); + break; + case '@': { + // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH. + mAboutToAutoWrap = false; + int columnsAfterCursor = mColumns - mCursorCol; + int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor); + int charsToMove = columnsAfterCursor - spacesToInsert; + mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow); + blockClear(mCursorCol, mCursorRow, spacesToInsert); + } + break; + case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows. + setCursorRow(Math.max(0, mCursorRow - getArg0(1))); + break; + case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows. + setCursorRow(Math.min(mRows - 1, mCursorRow + getArg0(1))); + break; + case 'C': // "CSI${n}C" - Cursor forward (CUF). + case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48. + setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1))); + break; + case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns. + setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1))); + break; + case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow + getArg0(1)); + break; + case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow - getArg0(1)); + break; + case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}. + setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1); + break; + case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP). + case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). + setCursorPosition(getArg1(1) - 1, getArg0(1) - 1); + break; + case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward. + setCursorCol(nextTabStop(getArg0(1))); + break; + case 'J': // "${CSI}${0,1,2,3}J" - Erase in Display (ED) + // ED ignores the scrolling margins. + switch (getArg0(0)) { + case 0: // Erase from the active position to the end of the screen, inclusive (default). + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1)); + break; + case 1: // Erase from start of the screen to the active position, inclusive. + blockClear(0, 0, mColumns, mCursorRow); + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not + // move.. + blockClear(0, 0, mColumns, mRows); + break; + case 3: // Delete all lines saved in the scrollback buffer (xterm etc) + mMainBuffer.clearTranscript(); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'K': // "CSI{n}K" - Erase in line (EL). + switch (getArg0(0)) { + case 0: // Erase from the cursor to the end of the line, inclusive (default) + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + break; + case 1: // Erase from the start of the screen to the cursor, inclusive. + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the line. + blockClear(0, mCursorRow, mColumns); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'L': // "${CSI}{N}L" - insert ${N} lines (IL). + { + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToInsert = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToInsert; + mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert); + blockClear(0, mCursorRow, mColumns, linesToInsert); + } + break; + case 'M': // "${CSI}${N}M" - delete N lines (DL). + { + mAboutToAutoWrap = false; + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToDelete = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToDelete; + mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow); + blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete); + } + break; + case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH). + { + // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the + // cursor and the right margin, then DCH only deletes the remaining characters. + // As characters are deleted, the remaining characters between the cursor and right margin move to the left. + // Character attributes move with the characters. The terminal adds blank spaces with no visual character + // attributes at the right margin. DCH has no effect outside the scrolling margins." + mAboutToAutoWrap = false; + int cellsAfterCursor = mColumns - mCursorCol; + int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor); + int cellsToMove = cellsAfterCursor - cellsToDelete; + mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow); + blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete); + } + break; + case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU). + final int linesToScroll = getArg0(1); + for (int i = 0; i < linesToScroll; i++) + scrollDownOneLine(); + break; + } + case 'T': + if (mArgIndex == 0) { + // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD). + // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page + // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the + // display. You cannot pan past the top margin of the current page". + final int linesToScrollArg = getArg0(1); + final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin; + final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg); + mScreen.blockCopy(0, mTopMargin, mColumns, linesBetweenTopAndBottomMargins - linesToScroll, 0, mTopMargin + linesToScroll); + blockClear(0, mTopMargin, mColumns, linesToScroll); + } else { + // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. + unimplementedSequence(b); + } + break; + case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes? + mAboutToAutoWrap = false; + mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle()); + break; + case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward. + int numberOfTabs = getArg0(1); + int newCol = mLeftMargin; + for (int i = mCursorCol - 1; i >= 0; i--) + if (mTabStop[i]) { + if (--numberOfTabs == 0) { + newCol = Math.max(i, mLeftMargin); + break; + } + } + mCursorCol = newCol; + break; + case '?': // Esc [ ? -- start of a private mode set + continueSequence(ESC_CSI_QUESTIONMARK); + break; + case '>': // "Esc [ >" -- + continueSequence(ESC_CSI_BIGGERTHAN); + break; + case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). + setCursorColRespectingOriginMode(getArg0(1) - 1); + break; + case 'b': // Repeat the preceding graphic character Ps times (REP). + if (mLastEmittedCodePoint == -1) break; + final int numRepeat = getArg0(1); + for (int i = 0; i < numRepeat; i++) emitCodePoint(mLastEmittedCodePoint); + break; + case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero. + // The important part that may still be used by some (tmux stores this value but does not currently use it) + // is the first response parameter identifying the terminal service class, where we send 64 for "vt420". + // This is followed by a list of attributes which is probably unused by applications. Send like xterm. + if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c"); + break; + case 'd': // ESC [ Pn d - Vert Position Absolute + setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1); + break; + case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48). + setCursorPosition(mCursorCol, mCursorRow + getArg0(1)); + break; + // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'. + case 'g': // Clear tab stop + switch (getArg0(0)) { + case 0: + mTabStop[mCursorCol] = false; + break; + case 3: + for (int i = 0; i < mColumns; i++) { + mTabStop[i] = false; + } + break; + default: + // Specified to have no effect. + break; + } + break; + case 'h': // Set Mode + doSetMode(true); + break; + case 'l': // Reset Mode + doSetMode(false); + break; + case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments) + selectGraphicRendition(); + break; + case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands + // sendDeviceAttributes() + switch (getArg0(0)) { + case 5: // Device status report (DSR): + // Answer is ESC [ 0 n (Terminal OK). + byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'}; + mSession.write(dsr, 0, dsr.length); + break; + case 6: // Cursor position report (CPR): + // Answer is ESC [ y ; x R, where x,y is + // the cursor location. + mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1)); + break; + default: + break; + } + break; + case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM). + { + // https://vt100.net/docs/vt510-rm/DECSTBM.html + // The top margin defaults to 1, the bottom margin defaults to mRows. + // The escape sequence numbers top 1..23, but we number top 0..22. + // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering + // scheme, but we store the first line below the bottom-most scrolling line. + // As a result, we adjust the top line by -1, but we leave the bottom line alone. + // Also require that top + 2 <= bottom. + mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2)); + mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows)); + + // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode. + setCursorPosition(0, 0); + } + break; + case 's': + if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) { + // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM). + mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2); + mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns)); + // DECSLRM moves the cursor to column 1, line 1 of the page. + setCursorPosition(0, 0); + } else { + // Save cursor (ANSI.SYS), available only when DECLRMM is disabled. + saveCursor(); + } + break; + case 't': // Window manipulation (from dtterm, as well as extensions) + switch (getArg0(0)) { + case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t . + mSession.write("\033[1t"); + break; + case 13: // Report xterm window position. Result is CSI 3 ; x ; y t + mSession.write("\033[3;0;0t"); + break; + case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t + // We just report characters time 12 here. + mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * 12, mColumns * 12)); + break; + case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t + mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); + break; + case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t + // We report the same size as the view, since it's the view really isn't resizable from the shell. + mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns)); + break; + case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns: + mSession.write("\033]LIconLabel\033\\"); + break; + case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns: + mSession.write("\033]l\033\\"); + break; + case 22: + // 22;0 -> Save xterm icon and window title on stack. + // 22;1 -> Save xterm icon title on stack. + // 22;2 -> Save xterm window title on stack. + mTitleStack.push(mTitle); + if (mTitleStack.size() > 20) { + // Limit size + mTitleStack.remove(0); + } + break; + case 23: // Like 22 above but restore from stack. + if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop()); + break; + default: + // Ignore window manipulation. + break; + } + break; + case 'u': // Restore cursor (ANSI.SYS). + restoreCursor(); + break; + case ' ': + continueSequence(ESC_CSI_ARGS_SPACE); + break; + default: + parseArg(b); + break; + } + } + + /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */ + private void selectGraphicRendition() { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int code = mArgs[i]; + if (code < 0) { + if (mArgIndex > 0) { + continue; + } else { + code = 0; + } + } + if (code == 0) { // reset + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + mEffect = 0; + } else if (code == 1) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD; + } else if (code == 2) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM; + } else if (code == 3) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 4) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 5) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 7) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 8) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 9) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code == 10) { + // Exit alt charset (TERM=linux) - ignore. + } else if (code == 11) { + // Enter alt charset (TERM=linux) - ignore. + } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint. + mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM); + } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 24) { // underline: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 25) { // blink: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 27) { // image: positive + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 28) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 29) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code >= 30 && code <= 37) { + mForeColor = code - 30; + } else if (code == 38 || code == 48) { + // Extended set foreground(38)/background (48) color. + // This is followed by either "2;$R;$G;$B" to set a 24-bit color or + // "5;$INDEX" to set an indexed color. + if (i + 2 > mArgIndex) continue; + int firstArg = mArgs[i + 1]; + if (firstArg == 2) { + if (i + 4 > mArgIndex) { + Logger.logWarn(mClient, LOG_TAG, "Too few CSI" + code + ";2 RGB arguments"); + } else { + int red = mArgs[i + 2], green = mArgs[i + 3], blue = mArgs[i + 4]; + if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { + finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); + } else { + int argbColor = 0xff000000 | (red << 16) | (green << 8) | blue; + if (code == 38) { + mForeColor = argbColor; + } else { + mBackColor = argbColor; + } + } + i += 4; // "2;P_r;P_g;P_r" + } + } else if (firstArg == 5) { + int color = mArgs[i + 2]; + i += 2; // "5;P_s" + if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { + if (code == 38) { + mForeColor = color; + } else { + mBackColor = color; + } + } else { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color); + } + } else { + finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg); + } + } else if (code == 39) { // Set default foreground color. + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 40 && code <= 47) { // Set background color. + mBackColor = code - 40; + } else if (code == 49) { // Set default background color. + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). + mForeColor = code - 90 + 8; + } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). + mBackColor = code - 100 + 8; + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logWarn(mClient, LOG_TAG, String.format("SGR unknown code %d", code)); + } + } + } + + private void doOsc(int b) { + switch (b) { + case 7: // Bell. + doOscSetTextParameters("\007"); + break; + case 27: // Escape. + continueSequence(ESC_OSC_ESC); + break; + default: + collectOSCArgs(b); + break; + } + } + + private void doOscEsc(int b) { + switch (b) { + case '\\': + doOscSetTextParameters("\033\\"); + break; + default: + // The ESC character was not followed by a \, so insert the ESC and + // the current character in arg buffer. + collectOSCArgs(27); + collectOSCArgs(b); + continueSequence(ESC_OSC); + break; + } + } + + /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */ + private void doOscSetTextParameters(String bellOrStringTerminator) { + int value = -1; + String textParameter = ""; + // Extract initial $value from initial "$value;..." string. + for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) { + char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex); + if (b == ';') { + textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1); + break; + } else if (b >= '0' && b <= '9') { + value = ((value < 0) ? 0 : value * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + } + + switch (value) { + case 0: // Change icon name and window title to T. + case 1: // Change icon name to T. + case 2: // Change window title to T. + setTitle(textParameter); + break; + case 4: + // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB + // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond + // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or + // 256-color table. + // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the + // same form which can be used to set the corresponding color. Because more than one pair of color number + // and specification can be given in one control sequence, xterm can make more than one reply. + int colorIndex = -1; + int parsingPairStart = -1; + for (int i = 0; ; i++) { + boolean endOfInput = i == textParameter.length(); + char b = endOfInput ? ';' : textParameter.charAt(i); + if (b == ';') { + if (parsingPairStart < 0) { + parsingPairStart = i + 1; + } else { + if (colorIndex < 0 || colorIndex > 255) { + unknownSequence(b); + return; + } else { + mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i)); + mSession.onColorsChanged(); + colorIndex = -1; + parsingPairStart = -1; + } + } + } else if (parsingPairStart >= 0) { + // We have passed a color index and are now going through color spec. + } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) { + colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + if (endOfInput) break; + } + break; + case 10: // Set foreground color. + case 11: // Set background color. + case 12: // Set cursor color. + int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10); + int lastSemiIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + String colorSpec = textParameter.substring(lastSemiIndex, charIndex); + if ("?".equals(colorSpec)) { + // Report current color in the same format xterm and gnome-terminal does. + int rgb = mColors.mCurrentColors[specialIndex]; + int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255; + int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255; + int b = (65535 * ((rgb & 0x000000FF))) / 255; + mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/" + + String.format(Locale.US, "%04x", b) + bellOrStringTerminator); + } else { + mColors.tryParseColor(specialIndex, colorSpec); + mSession.onColorsChanged(); + } + specialIndex++; + if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length()) + break; + lastSemiIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + break; + case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s). + int startIndex = textParameter.indexOf(";") + 1; + try { + String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8); + mSession.onCopyTextToClipboard(clipboardText); + } catch (Exception e) { + Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); + } + break; + case 104: + // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X + // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7, + // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no + // parameters are given, the entire table will be reset. + if (textParameter.isEmpty()) { + mColors.reset(); + mSession.onColorsChanged(); + } else { + int lastIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex)); + mColors.reset(colorToReset); + mSession.onColorsChanged(); + if (endOfInput) break; + charIndex++; + lastIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + } + break; + case 110: // Reset foreground color. + case 111: // Reset background color. + case 112: // Reset cursor color. + mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110)); + mSession.onColorsChanged(); + break; + case 119: // Reset highlight color. + break; + default: + unknownParameter(value); + break; + } + finishSequence(); + } + + private void blockClear(int sx, int sy, int w) { + blockClear(sx, sy, w, 1); + } + + private void blockClear(int sx, int sy, int w, int h) { + mScreen.blockSet(sx, sy, w, h, ' ', getStyle()); + } + + private long getStyle() { + return TextStyle.encode(mForeColor, mBackColor, mEffect); + } + + /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */ + private void doSetMode(boolean newValue) { + int modeBit = getArg0(0); + switch (modeBit) { + case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM). + mInsertMode = newValue; + break; + case 20: // Normal Linefeed (LNM). + unknownParameter(modeBit); + // http://www.vt100.net/docs/vt510-rm/LNM + break; + case 34: + // Normal cursor visibility - when using TERM=screen, see + // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html + break; + default: + unknownParameter(modeBit); + break; + } + } + + /** + * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use + * {@link #setCursorRowCol(int, int)} for absolute pos. + */ + private void setCursorPosition(int x, int y) { + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1)); + int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1)); + setCursorRowCol(newRow, newCol); + } + + private void scrollDownOneLine() { + mScrollCounter++; + if (mLeftMargin != 0 || mRightMargin != mColumns) { + // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. + mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); + // .. and blank bottom row between margins: + mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', mEffect); + } else { + mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, getStyle()); + } + } + + /** + * Process the next ASCII character of a parameter. + * + * Parameter characters modify the action or interpretation of the sequence. You can use up to + * 16 parameters per sequence. You must use the ; character to separate parameters. + * All parameters are unsigned, positive decimal integers, with the most significant + * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999 + * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value + * or omitted parameter indicates a default value for the sequence. For most + * sequences, the default value is 1. + * + * https://vt100.net/docs/vt510-rm/chapter4.html#S4.3.3 + * */ + private void parseArg(int inputByte) { + int[] bytes = new int[]{inputByte}; + // Only doing this for ESC_CSI and not for other ESC_CSI_* since they seem to be using their + // own defaults with getArg*() calls, but there may be missed cases + if (mEscapeState == ESC_CSI) { + if ((mIsCSIStart && inputByte == ';') || // If sequence starts with a ; character, like \033[;m + (!mIsCSIStart && mLastCSIArg != null && mLastCSIArg == ';' && inputByte == ';')) { // If sequence contains sequential ; characters, like \033[;;m + bytes = new int[]{'0', ';'}; // Assume 0 was passed + } + } + + mIsCSIStart = false; + + for (int b : bytes) { + if (b >= '0' && b <= '9') { + if (mArgIndex < mArgs.length) { + int oldValue = mArgs[mArgIndex]; + int thisDigit = b - '0'; + int value; + if (oldValue >= 0) { + value = oldValue * 10 + thisDigit; + } else { + value = thisDigit; + } + if (value > 9999) + value = 9999; + mArgs[mArgIndex] = value; + } + continueSequence(mEscapeState); + } else if (b == ';') { + if (mArgIndex < mArgs.length) { + mArgIndex++; + } + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + mLastCSIArg = b; + } + } + + private int getArg0(int defaultValue) { + return getArg(0, defaultValue, true); + } + + private int getArg1(int defaultValue) { + return getArg(1, defaultValue, true); + } + + private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { + int result = mArgs[index]; + if (result < 0 || (result == 0 && treatZeroAsDefault)) { + result = defaultValue; + } + return result; + } + + private void collectOSCArgs(int b) { + if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private void unimplementedSequence(int b) { + logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); + finishSequence(); + } + + private void unknownSequence(int b) { + logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")"); + finishSequence(); + } + + private void unknownParameter(int parameter) { + logError("Unknown parameter: " + parameter); + finishSequence(); + } + + private void logError(String errorType) { + if (LOG_ESCAPE_SEQUENCES) { + StringBuilder buf = new StringBuilder(); + buf.append(errorType); + buf.append(", escapeState="); + buf.append(mEscapeState); + boolean firstArg = true; + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int value = mArgs[i]; + if (value >= 0) { + if (firstArg) { + firstArg = false; + buf.append(", args={"); + } else { + buf.append(','); + } + buf.append(value); + } + } + if (!firstArg) buf.append('}'); + finishSequenceAndLogError(buf.toString()); + } + } + + private void finishSequenceAndLogError(String error) { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, error); + finishSequence(); + } + + private void finishSequence() { + mEscapeState = ESC_NONE; + } + + /** + * Send a Unicode code point to the screen. + * + * @param codePoint The code point of the character to display + */ + private void emitCodePoint(int codePoint) { + mLastEmittedCodePoint = codePoint; + if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) { + // http://www.vt100.net/docs/vt102-ug/table5-15.html. + switch (codePoint) { + case '_': + codePoint = ' '; // Blank. + break; + case '`': + codePoint = '◆'; // Diamond. + break; + case '0': + codePoint = '█'; // Solid block; + break; + case 'a': + codePoint = '▒'; // Checker board. + break; + case 'b': + codePoint = '␉'; // Horizontal tab. + break; + case 'c': + codePoint = '␌'; // Form feed. + break; + case 'd': + codePoint = '\r'; // Carriage return. + break; + case 'e': + codePoint = '␊'; // Linefeed. + break; + case 'f': + codePoint = '°'; // Degree. + break; + case 'g': + codePoint = '±'; // Plus-minus. + break; + case 'h': + codePoint = '\n'; // Newline. + break; + case 'i': + codePoint = '␋'; // Vertical tab. + break; + case 'j': + codePoint = '┘'; // Lower right corner. + break; + case 'k': + codePoint = '┐'; // Upper right corner. + break; + case 'l': + codePoint = '┌'; // Upper left corner. + break; + case 'm': + codePoint = '└'; // Left left corner. + break; + case 'n': + codePoint = '┼'; // Crossing lines. + break; + case 'o': + codePoint = '⎺'; // Horizontal line - scan 1. + break; + case 'p': + codePoint = '⎻'; // Horizontal line - scan 3. + break; + case 'q': + codePoint = '─'; // Horizontal line - scan 5. + break; + case 'r': + codePoint = '⎼'; // Horizontal line - scan 7. + break; + case 's': + codePoint = '⎽'; // Horizontal line - scan 9. + break; + case 't': + codePoint = '├'; // T facing rightwards. + break; + case 'u': + codePoint = '┤'; // T facing leftwards. + break; + case 'v': + codePoint = '┴'; // T facing upwards. + break; + case 'w': + codePoint = '┬'; // T facing downwards. + break; + case 'x': + codePoint = '│'; // Vertical line. + break; + case 'y': + codePoint = '≤'; // Less than or equal to. + break; + case 'z': + codePoint = '≥'; // Greater than or equal to. + break; + case '{': + codePoint = 'π'; // Pi. + break; + case '|': + codePoint = '≠'; // Not equal to. + break; + case '}': + codePoint = '£'; // UK pound. + break; + case '~': + codePoint = '·'; // Centered dot. + break; + } + } + + final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP); + final int displayWidth = WcWidth.width(codePoint); + final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1; + + if (autoWrap) { + if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) { + mScreen.setLineWrap(mCursorRow); + mCursorCol = mLeftMargin; + if (mCursorRow + 1 < mBottomMargin) { + mCursorRow++; + } else { + scrollDownOneLine(); + } + } + } else if (cursorInLastColumn && displayWidth == 2) { + // The behaviour when a wide character is output with cursor in the last column when + // autowrap is disabled is not obvious - it's ignored here. + return; + } + + if (mInsertMode && displayWidth > 0) { + // Move character to right one space. + int destCol = mCursorCol + displayWidth; + if (destCol < mRightMargin) + mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow); + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0); + int column = mCursorCol - offsetDueToCombiningChar; + + // Fix TerminalRow.setChar() ArrayIndexOutOfBoundsException index=-1 exception reported + // The offsetDueToCombiningChar would never be 1 if mCursorCol was 0 to get column/index=-1, + // so was mCursorCol changed after the offsetDueToCombiningChar conditional by another thread? + // TODO: Check if there are thread synchronization issues with mCursorCol and mCursorRow, possibly causing others bugs too. + if (column < 0) column = 0; + mScreen.setChar(column, mCursorRow, codePoint, getStyle()); + + if (autoWrap && displayWidth > 0) + mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth); + + mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1); + } + + private void setCursorRow(int row) { + mCursorRow = row; + mAboutToAutoWrap = false; + } + + private void setCursorCol(int col) { + mCursorCol = col; + mAboutToAutoWrap = false; + } + + /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */ + private void setCursorColRespectingOriginMode(int col) { + setCursorPosition(col, mCursorRow); + } + + /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */ + private void setCursorRowCol(int row, int col) { + mCursorRow = Math.max(0, Math.min(row, mRows - 1)); + mCursorCol = Math.max(0, Math.min(col, mColumns - 1)); + mAboutToAutoWrap = false; + } + + public int getScrollCounter() { + return mScrollCounter; + } + + public void clearScrollCounter() { + mScrollCounter = 0; + } + + public boolean isAutoScrollDisabled() { + return mAutoScrollDisabled; + } + + public void toggleAutoScrollDisabled() { + mAutoScrollDisabled = !mAutoScrollDisabled; + } + + + /** Reset terminal state so user can interact with it regardless of present state. */ + public void reset() { + setCursorStyle(); + mArgIndex = 0; + mContinueSequence = false; + mEscapeState = ESC_NONE; + mInsertMode = false; + mTopMargin = mLeftMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + mAboutToAutoWrap = false; + mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + setDefaultTabStops(); + + mUseLineDrawingG0 = mUseLineDrawingG1 = false; + mUseLineDrawingUsesG0 = true; + + mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0; + mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0; + mCurrentDecSetFlags = 0; + // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen: + setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true); + setDecsetinternalBit(DECSET_BIT_CURSOR_ENABLED, true); + mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags; + + // XXX: Should we set terminal driver back to IUTF8 with termios? + mUtf8Index = mUtf8ToFollow = 0; + + mColors.reset(); + mSession.onColorsChanged(); + } + + public String getSelectedText(int x1, int y1, int x2, int y2) { + return mScreen.getSelectedText(x1, y1, x2, y2); + } + + /** Get the terminal session's title (null if not set). */ + public String getTitle() { + return mTitle; + } + + /** Change the terminal session's title. */ + private void setTitle(String newTitle) { + String oldTitle = mTitle; + mTitle = newTitle; + if (!Objects.equals(oldTitle, newTitle)) { + mSession.titleChanged(oldTitle, newTitle); + } + } + + /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */ + public void paste(String text) { + // First: Always remove escape key and C1 control characters [0x80,0x9F]: + text = text.replaceAll("(\u001B|[\u0080-\u009F])", ""); + // Second: Replace all newlines (\n) or CRLF (\r\n) with carriage returns (\r). + text = text.replaceAll("\r?\n", "\r"); + + // Then: Implement bracketed paste mode if enabled: + boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE); + if (bracketed) mSession.write("\033[200~"); + mSession.write(text); + if (bracketed) mSession.write("\033[201~"); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSC */ + static final class SavedScreenState { + /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */ + int mSavedCursorRow, mSavedCursorCol; + int mSavedEffect, mSavedForeColor, mSavedBackColor; + int mSavedDecFlags; + boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + } + + @Override + public String toString() { + return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin + + "," + mLeftMargin + "}]"; + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalOutput.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalOutput.java new file mode 100644 index 000000000..305082a52 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalOutput.java @@ -0,0 +1,32 @@ +package com.termux.terminal; + +import java.nio.charset.StandardCharsets; + +/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */ +public abstract class TerminalOutput { + + /** Write a string using the UTF-8 encoding to the terminal client. */ + public final void write(String data) { + if (data == null) return; + byte[] bytes = data.getBytes(StandardCharsets.UTF_8); + write(bytes, 0, bytes.length); + } + + /** Write bytes to the terminal client. */ + public abstract void write(byte[] data, int offset, int count); + + /** Notify the terminal client that the terminal title has changed. */ + public abstract void titleChanged(String oldTitle, String newTitle); + + /** Notify the terminal client that text should be copied to clipboard. */ + public abstract void onCopyTextToClipboard(String text); + + /** Notify the terminal client that text should be pasted from clipboard. */ + public abstract void onPasteTextFromClipboard(); + + /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */ + public abstract void onBell(); + + public abstract void onColorsChanged(); + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalRow.java new file mode 100644 index 000000000..cbeaf5224 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -0,0 +1,250 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A row in a terminal, composed of a fixed number of cells. + *

+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering. + */ +public final class TerminalRow { + + private static final float SPARE_CAPACITY_FACTOR = 1.5f; + + /** The number of columns in this terminal row. */ + private final int mColumns; + /** The text filling this terminal row. */ + public char[] mText; + /** The number of java char:s used in {@link #mText}. */ + private short mSpaceUsed; + /** If this row has been line wrapped due to text output at the end of line. */ + boolean mLineWrap; + /** The style bits of each cell in the row. See {@link TextStyle}. */ + final long[] mStyle; + /** If this row might contain chars with width != 1, used for deactivating fast path */ + boolean mHasNonOneWidthOrSurrogateChars; + + /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ + public TerminalRow(int columns, long style) { + mColumns = columns; + mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)]; + mStyle = new long[columns]; + clear(style); + } + + /** NOTE: The sourceX2 is exclusive. */ + public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) { + mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars; + final int x1 = line.findStartOfColumn(sourceX1); + final int x2 = line.findStartOfColumn(sourceX2); + boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)); + final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText; + int latestNonCombiningWidth = 0; + for (int i = x1; i < x2; i++) { + char sourceChar = sourceChars[i]; + int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar; + if (startingFromSecondHalfOfWideChar) { + // Just treat copying second half of wide char as copying whitespace. + codePoint = ' '; + startingFromSecondHalfOfWideChar = false; + } + int w = WcWidth.width(codePoint); + if (w > 0) { + destinationX += latestNonCombiningWidth; + sourceX1 += latestNonCombiningWidth; + latestNonCombiningWidth = w; + } + setChar(destinationX, codePoint, line.getStyle(sourceX1)); + } + } + + public int getSpaceUsed() { + return mSpaceUsed; + } + + /** Note that the column may end of second half of wide character. */ + public int findStartOfColumn(int column) { + if (column == mColumns) return getSpaceUsed(); + + int currentColumn = 0; + int currentCharIndex = 0; + while (true) { // 0<2 1 < 2 + int newCharIndex = currentCharIndex; + char c = mText[newCharIndex++]; // cci=1, cci=2 + boolean isHigh = Character.isHighSurrogate(c); + int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); // 1, 2 + if (wcwidth > 0) { + currentColumn += wcwidth; + if (currentColumn == column) { + while (newCharIndex < mSpaceUsed) { + // Skip combining chars. + if (Character.isHighSurrogate(mText[newCharIndex])) { + if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) { + newCharIndex += 2; + } else { + break; + } + } else if (WcWidth.width(mText[newCharIndex]) <= 0) { + newCharIndex++; + } else { + break; + } + } + return newCharIndex; + } else if (currentColumn > column) { + // Wide column going past end. + return currentCharIndex; + } + } + currentCharIndex = newCharIndex; + } + } + + private boolean wideDisplayCharacterStartingAt(int column) { + for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) { + char c = mText[currentCharIndex++]; + int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); + if (wcwidth > 0) { + if (currentColumn == column && wcwidth == 2) return true; + currentColumn += wcwidth; + if (currentColumn > column) return false; + } + } + return false; + } + + public void clear(long style) { + Arrays.fill(mText, ' '); + Arrays.fill(mStyle, style); + mSpaceUsed = (short) mColumns; + mHasNonOneWidthOrSurrogateChars = false; + } + + // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 + public void setChar(int columnToSet, int codePoint, long style) { + if (columnToSet < 0 || columnToSet >= mStyle.length) + throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style); + + mStyle[columnToSet] = style; + + final int newCodePointDisplayWidth = WcWidth.width(codePoint); + + // Fast path when we don't have any chars with width != 1 + if (!mHasNonOneWidthOrSurrogateChars) { + if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) { + mHasNonOneWidthOrSurrogateChars = true; + } else { + mText[columnToSet] = (char) codePoint; + return; + } + } + + final boolean newIsCombining = newCodePointDisplayWidth <= 0; + + boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1); + + if (newIsCombining) { + // When standing at second half of wide character and inserting combining: + if (wasExtraColForWideChar) columnToSet--; + } else { + // Check if we are overwriting the second half of a wide character starting at the previous column: + if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style); + // Check if we are overwriting the first half of a wide character starting at the next column: + boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1); + if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style); + } + + char[] text = mText; + final int oldStartOfColumnIndex = findStartOfColumn(columnToSet); + final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex); + + // Get the number of elements in the mText array this column uses now + int oldCharactersUsedForColumn; + if (columnToSet + oldCodePointDisplayWidth < mColumns) { + oldCharactersUsedForColumn = findStartOfColumn(columnToSet + oldCodePointDisplayWidth) - oldStartOfColumnIndex; + } else { + // Last character. + oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex; + } + + // Find how many chars this column will need + int newCharactersUsedForColumn = Character.charCount(codePoint); + if (newIsCombining) { + // Combining characters are added to the contents of the column instead of overwriting them, so that they + // modify the existing contents. + // FIXME: Put a limit of combining characters. + // FIXME: Unassigned characters also get width=0. + newCharactersUsedForColumn += oldCharactersUsedForColumn; + } + + int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn; + int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn; + + final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn; + if (javaCharDifference > 0) { + // Shift the rest of the line right. + int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex; + if (mSpaceUsed + javaCharDifference > text.length) { + // We need to grow the array + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, oldStartOfColumnIndex + oldCharactersUsedForColumn); + System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn); + mText = text = newText; + } else { + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn); + } + } else if (javaCharDifference < 0) { + // Shift the rest of the line left. + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex); + } + mSpaceUsed += javaCharDifference; + + // Store char. A combining character is stored at the end of the existing contents so that it modifies them: + //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used. + Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0)); + + if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) { + // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character. + if (mSpaceUsed + 1 > text.length) { + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, newNextColumnIndex); + System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + mText = text = newText; + } else { + System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + } + text[newNextColumnIndex] = ' '; + + ++mSpaceUsed; + } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) { + if (columnToSet == mColumns - 1) { + throw new IllegalArgumentException("Cannot put wide character in last column"); + } else if (columnToSet == mColumns - 2) { + // Truncate the line to the second part of this wide char: + mSpaceUsed = (short) newNextColumnIndex; + } else { + // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the + // check at the beginning of this method we know that we are not overwriting a wide char. + int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1); + int nextLen = newNextNextColumnIndex - newNextColumnIndex; + + // Shift the array leftwards. + System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex); + mSpaceUsed -= nextLen; + } + } + } + + boolean isBlank() { + for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++) + if (mText[charIndex] != ' ') return false; + return true; + } + + public final long getStyle(int column) { + return mStyle[column]; + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalSession.java new file mode 100644 index 000000000..c081108d8 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -0,0 +1,373 @@ +package com.termux.terminal; + +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Message; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * A terminal session, consisting of a process coupled to a terminal interface. + *

+ * The subprocess will be executed by the constructor, and when the size is made known by a call to + * {@link #updateSize(int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O. + * All terminal emulation and callback methods will be performed on the main thread. + *

+ * The child process may be exited forcefully by using the {@link #finishIfRunning()} method. + *

+ * NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks! + */ +public final class TerminalSession extends TerminalOutput { + + private static final int MSG_NEW_INPUT = 1; + private static final int MSG_PROCESS_EXITED = 4; + + public final String mHandle = UUID.randomUUID().toString(); + + TerminalEmulator mEmulator; + + /** + * A queue written to from a separate thread when the process outputs, and read by main thread to process by + * terminal emulator. + */ + final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096); + /** + * A queue written to from the main thread due to user interaction, and read by another thread which forwards by + * writing to the {@link #mTerminalFileDescriptor}. + */ + final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096); + /** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */ + private final byte[] mUtf8InputBuffer = new byte[5]; + + /** Callback which gets notified when a session finishes or changes title. */ + TerminalSessionClient mClient; + + /** The pid of the shell process. 0 if not started and -1 if finished running. */ + int mShellPid; + + /** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */ + int mShellExitStatus; + + /** + * The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling + * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int)}. + */ + private int mTerminalFileDescriptor; + + /** Set by the application for user identification of session, not by terminal. */ + public String mSessionName; + + final Handler mMainThreadHandler = new MainThreadHandler(); + + private final String mShellPath; + private final String mCwd; + private final String[] mArgs; + private final String[] mEnv; + private final Integer mTranscriptRows; + + + private static final String LOG_TAG = "TerminalSession"; + + public TerminalSession(String shellPath, String cwd, String[] args, String[] env, Integer transcriptRows, TerminalSessionClient client) { + this.mShellPath = shellPath; + this.mCwd = cwd; + this.mArgs = args; + this.mEnv = env; + this.mTranscriptRows = transcriptRows; + this.mClient = client; + } + + /** + * @param client The {@link TerminalSessionClient} interface implementation to allow + * for communication between {@link TerminalSession} and its client. + */ + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + + if (mEmulator != null) + mEmulator.updateTerminalSessionClient(client); + } + + /** Inform the attached pty of the new size and reflow or initialize the emulator. */ + public void updateSize(int columns, int rows) { + if (mEmulator == null) { + initializeEmulator(columns, rows); + } else { + JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns); + mEmulator.resize(columns, rows); + } + } + + /** The terminal title as set through escape sequences or null if none set. */ + public String getTitle() { + return (mEmulator == null) ? null : mEmulator.getTitle(); + } + + /** + * Set the terminal emulator's window size and start terminal emulation. + * + * @param columns The number of columns in the terminal window. + * @param rows The number of rows in the terminal window. + */ + public void initializeEmulator(int columns, int rows) { + mEmulator = new TerminalEmulator(this, columns, rows, mTranscriptRows, mClient); + + int[] processId = new int[1]; + mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns); + mShellPid = processId[0]; + mClient.setTerminalShellPid(this, mShellPid); + + final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient); + + new Thread("TermSessionInputReader[pid=" + mShellPid + "]") { + @Override + public void run() { + try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) { + final byte[] buffer = new byte[4096]; + while (true) { + int read = termIn.read(buffer); + if (read == -1) return; + if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return; + mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT); + } + } catch (Exception e) { + // Ignore, just shutting down. + } + } + }.start(); + + new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") { + @Override + public void run() { + final byte[] buffer = new byte[4096]; + try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) { + while (true) { + int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true); + if (bytesToWrite == -1) return; + termOut.write(buffer, 0, bytesToWrite); + } + } catch (IOException e) { + // Ignore. + } + } + }.start(); + + new Thread("TermSessionWaiter[pid=" + mShellPid + "]") { + @Override + public void run() { + int processExitCode = JNI.waitFor(mShellPid); + mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode)); + } + }.start(); + + } + + /** Write data to the shell process. */ + @Override + public void write(byte[] data, int offset, int count) { + if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count); + } + + /** Write the Unicode code point to the terminal encoded in UTF-8. */ + public void writeCodePoint(boolean prependEscape, int codePoint) { + if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + // 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range. + throw new IllegalArgumentException("Invalid code point: " + codePoint); + } + + int bufferPosition = 0; + if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27; + + if (codePoint <= /* 7 bits */0b1111111) { + mUtf8InputBuffer[bufferPosition++] = (byte) codePoint; + } else if (codePoint <= /* 11 bits */0b11111111111) { + /* 110xxxxx leading byte with leading 5 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else if (codePoint <= /* 16 bits */0b1111111111111111) { + /* 1110xxxx leading byte with leading 4 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */ + /* 11110xxx leading byte with leading 3 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } + write(mUtf8InputBuffer, 0, bufferPosition); + } + + public TerminalEmulator getEmulator() { + return mEmulator; + } + + /** Notify the {@link #mClient} that the screen has changed. */ + protected void notifyScreenUpdate() { + mClient.onTextChanged(this); + } + + /** Reset state for terminal emulator state. */ + public void reset() { + mEmulator.reset(); + notifyScreenUpdate(); + } + + /** Finish this terminal session by sending SIGKILL to the shell. */ + public void finishIfRunning() { + if (isRunning()) { + try { + Os.kill(mShellPid, OsConstants.SIGKILL); + } catch (ErrnoException e) { + Logger.logWarn(mClient, LOG_TAG, "Failed sending SIGKILL: " + e.getMessage()); + } + } + } + + /** Cleanup resources when the process exits. */ + void cleanupResources(int exitStatus) { + synchronized (this) { + mShellPid = -1; + mShellExitStatus = exitStatus; + } + + // Stop the reader and writer threads, and close the I/O streams + mTerminalToProcessIOQueue.close(); + mProcessToTerminalIOQueue.close(); + JNI.close(mTerminalFileDescriptor); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + mClient.onTitleChanged(this); + } + + public synchronized boolean isRunning() { + return mShellPid != -1; + } + + /** Only valid if not {@link #isRunning()}. */ + public synchronized int getExitStatus() { + return mShellExitStatus; + } + + @Override + public void onCopyTextToClipboard(String text) { + mClient.onCopyTextToClipboard(this, text); + } + + @Override + public void onPasteTextFromClipboard() { + mClient.onPasteTextFromClipboard(this); + } + + @Override + public void onBell() { + mClient.onBell(this); + } + + @Override + public void onColorsChanged() { + mClient.onColorsChanged(this); + } + + public int getPid() { + return mShellPid; + } + + /** Returns the shell's working directory or null if it was unavailable. */ + public String getCwd() { + if (mShellPid < 1) { + return null; + } + try { + final String cwdSymlink = String.format("/proc/%s/cwd/", mShellPid); + String outputPath = new File(cwdSymlink).getCanonicalPath(); + String outputPathWithTrailingSlash = outputPath; + if (!outputPath.endsWith("/")) { + outputPathWithTrailingSlash += '/'; + } + if (!cwdSymlink.equals(outputPathWithTrailingSlash)) { + return outputPath; + } + } catch (IOException | SecurityException e) { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Error getting current directory", e); + } + return null; + } + + private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) { + FileDescriptor result = new FileDescriptor(); + try { + Field descriptorField; + try { + descriptorField = FileDescriptor.class.getDeclaredField("descriptor"); + } catch (NoSuchFieldException e) { + // For desktop java: + descriptorField = FileDescriptor.class.getDeclaredField("fd"); + } + descriptorField.setAccessible(true); + descriptorField.set(result, fileDescriptor); + } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { + Logger.logStackTraceWithMessage(client, LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e); + System.exit(1); + } + return result; + } + + @SuppressLint("HandlerLeak") + class MainThreadHandler extends Handler { + + final byte[] mReceiveBuffer = new byte[4 * 1024]; + + @Override + public void handleMessage(Message msg) { + int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false); + if (bytesRead > 0) { + mEmulator.append(mReceiveBuffer, bytesRead); + notifyScreenUpdate(); + } + + if (msg.what == MSG_PROCESS_EXITED) { + int exitCode = (Integer) msg.obj; + cleanupResources(exitCode); + + String exitDescription = "\r\n[Process completed"; + if (exitCode > 0) { + // Non-zero process exit. + exitDescription += " (code " + exitCode + ")"; + } else if (exitCode < 0) { + // Negated signal. + exitDescription += " (signal " + (-exitCode) + ")"; + } + exitDescription += " - press Enter]"; + + byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8); + mEmulator.append(bytesToWrite, bytesToWrite.length); + notifyScreenUpdate(); + + mClient.onSessionFinished(TerminalSession.this); + } + } + + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java new file mode 100644 index 000000000..fbd8e5509 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java @@ -0,0 +1,51 @@ +package com.termux.terminal; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * The interface for communication between {@link TerminalSession} and its client. It is used to + * send callbacks to the client when {@link TerminalSession} changes or for sending other + * back data to the client like logs. + */ +public interface TerminalSessionClient { + + void onTextChanged(@NonNull TerminalSession changedSession); + + void onTitleChanged(@NonNull TerminalSession changedSession); + + void onSessionFinished(@NonNull TerminalSession finishedSession); + + void onCopyTextToClipboard(@NonNull TerminalSession session, String text); + + void onPasteTextFromClipboard(@Nullable TerminalSession session); + + void onBell(@NonNull TerminalSession session); + + void onColorsChanged(@NonNull TerminalSession session); + + void onTerminalCursorStateChange(boolean state); + + void setTerminalShellPid(@NonNull TerminalSession session, int pid); + + + + Integer getTerminalCursorStyle(); + + + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/TextStyle.java b/termux/termux-emulator/src/main/java/com/termux/terminal/TextStyle.java new file mode 100644 index 000000000..173d6ae94 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/TextStyle.java @@ -0,0 +1,90 @@ +package com.termux.terminal; + +/** + *

+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal + * row in {@link TerminalRow#mStyle}. + *

+ *

+ * The bit layout is: + *

+ * - 16 flags (11 currently used). + * - 24 for foreground color (only 9 first bits if a color index). + * - 24 for background color (only 9 first bits if a color index). + */ +public final class TextStyle { + + public final static int CHARACTER_ATTRIBUTE_BOLD = 1; + public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1; + public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2; + public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3; + public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4; + public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5; + public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6; + /** + * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable. + *

+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that + * come after it as erasable from the screen. + *

+ */ + public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7; + /** Dim colors. Also known as faint or half intensity. */ + public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10; + + public final static int COLOR_INDEX_FOREGROUND = 256; + public final static int COLOR_INDEX_BACKGROUND = 257; + public final static int COLOR_INDEX_CURSOR = 258; + + /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */ + public final static int NUM_INDEXED_COLORS = 259; + + /** Normal foreground and background colors and no effects. */ + final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0); + + static long encode(int foreColor, int backColor, int effect) { + long result = effect & 0b111111111; + if ((0xff000000 & foreColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L); + } else { + // Indexed color. + result |= (foreColor & 0b111111111L) << 40; + } + if ((0xff000000 & backColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L); + } else { + // Indexed color. + result |= (backColor & 0b111111111L) << 16L; + } + + return result; + } + + public static int decodeForeColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) { + return (int) ((style >>> 40) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL); + } + + } + + public static int decodeBackColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) { + return (int) ((style >>> 16) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL); + } + } + + public static int decodeEffect(long style) { + return (int) (style & 0b11111111111); + } + +} diff --git a/termux/termux-emulator/src/main/java/com/termux/terminal/WcWidth.java b/termux/termux-emulator/src/main/java/com/termux/terminal/WcWidth.java new file mode 100644 index 000000000..5981c4a88 --- /dev/null +++ b/termux/termux-emulator/src/main/java/com/termux/terminal/WcWidth.java @@ -0,0 +1,541 @@ +package com.termux.terminal; + +/** + * Implementation of wcwidth(3) for Unicode 15. + * + * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters. + * + * IMPORTANT: + * Must be kept in sync with the following: + * https://github.com/termux/wcwidth + * https://github.com/termux/libandroid-support + * https://github.com/termux/termux-packages/tree/master/packages/libandroid-support + */ +public final class WcWidth { + + // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] ZERO_WIDTH = { + {0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le + {0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli + {0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg + {0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe + {0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot + {0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot + {0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata + {0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra + {0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below + {0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip + {0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen + {0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda + {0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon + {0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem + {0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip + {0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh + {0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun + {0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot + {0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan + {0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh + {0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A + {0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U + {0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa + {0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark + {0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M + {0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S + {0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara + {0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe + {0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta + {0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai + {0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama + {0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu + {0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo + {0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu + {0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta + {0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal + {0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama + {0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal + {0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark + {0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi + {0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta + {0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu + {0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai + {0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama + {0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat + {0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak + {0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash + {0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara + {0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta + {0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand + {0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai + {0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama + {0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca + {0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle + {0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu + {0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta + {0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I + {0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic + {0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama + {0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark + {0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic + {0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara + {0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii + {0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama + {0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca + {0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An + {0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta + {0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii + {0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai + {0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama + {0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark + {0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali + {0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu + {0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta + {0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I + {0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E + {0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama + {0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal + {0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin + {0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular + {0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc + {0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama + {0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc + {0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu + {0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna + {0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti + {0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga + {0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a + {0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu + {0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan + {0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan + {0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo + {0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil) + {0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig + {0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru + {0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga + {0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta + {0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags + {0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter + {0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter + {0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda + {0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu + {0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below + {0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat + {0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal + {0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah + {0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S + {0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan + {0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci + {0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton + {0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin + {0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama + {0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U + {0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U + {0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U + {0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa + {0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua + {0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit + {0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat + {0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan + {0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation + {0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation + {0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U + {0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O + {0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv + {0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i + {0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U + {0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae + {0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign + {0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign + {0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot + {0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai + {0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B + {0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue + {0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt + {0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le + {0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang + {0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan + {0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R + {0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L + {0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe + {0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol + {0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar + {0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan + {0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan + {0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign + {0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi + {0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee + {0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O + {0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H + {0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T + {0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta + {0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha + {0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash + {0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda + {0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak + {0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above + {0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A + {0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea + {0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above + {0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu + {0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine + {0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton + {0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag + {0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous + {0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer + {0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk + {0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva + {0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant + {0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva + {0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign + {0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern + {0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi + {0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig + {0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay + {0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop + {0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R + {0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar + {0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu + {0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku + {0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign + {0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw + {0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe + {0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue + {0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa + {0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T + {0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang + {0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U + {0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia + {0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek + {0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho + {0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama + {0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek + {0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani + {0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16 + {0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo + {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi + {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M + {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let + {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo + {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O + {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga + {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo + {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama + {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation + {0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas + {0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M + {0x10efd, 0x10eff}, // (nil) ..(nil) + {0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke + {0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two + {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara + {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama + {0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi + {0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta + {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara + {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai + {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta + {0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali + {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga + {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu + {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa + {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta + {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara + {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O + {0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe + {0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca + {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai + {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara + {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda + {0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun + {0x11241, 0x11241}, // (nil) ..(nil) + {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara + {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama + {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu + {0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta + {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii + {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit + {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter + {0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai + {0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara + {0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta + {0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark + {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal + {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short + {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara + {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta + {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal + {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara + {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta + {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter + {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai + {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara + {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra + {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara + {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa + {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au + {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta + {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi + {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu + {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer + {0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara + {0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta + {0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab + {0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama + {0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta + {0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V + {0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A + {0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama + {0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L + {0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An + {0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster + {0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin + {0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe + {0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar + {0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara + {0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner + {0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc + {0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara + {0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama + {0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter + {0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa + {0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E + {0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu + {0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama + {0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara + {0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign + {0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv + {0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama + {0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U + {0x11f00, 0x11f01}, // (nil) ..(nil) + {0x11f36, 0x11f3a}, // (nil) ..(nil) + {0x11f40, 0x11f40}, // (nil) ..(nil) + {0x11f42, 0x11f42}, // (nil) ..(nil) + {0x13440, 0x13440}, // (nil) ..(nil) + {0x13447, 0x13455}, // (nil) ..(nil) + {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High + {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta + {0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi + {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below + {0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill + {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark + {0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark + {0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie + {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical + {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking + {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement + {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T + {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea + {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie + {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod + {0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e08f, 0x1e08f}, // (nil) ..(nil) + {0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T + {0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone + {0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini + {0x1e4ec, 0x1e4ef}, // (nil) ..(nil) + {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining + {0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta + {0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256 + }; + + // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] WIDE_EASTASIAN = { + {0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler + {0x0231a, 0x0231b}, // Watch ..Hourglass + {0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra + {0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub + {0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock + {0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S + {0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar + {0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage + {0x02648, 0x02653}, // Aries ..Pisces + {0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol + {0x02693, 0x02693}, // Anchor ..Anchor + {0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign + {0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle + {0x026bd, 0x026be}, // Soccer Ball ..Baseball + {0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud + {0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus + {0x026d4, 0x026d4}, // No Entry ..No Entry + {0x026ea, 0x026ea}, // Church ..Church + {0x026f2, 0x026f3}, // Fountain ..Flag In Hole + {0x026f5, 0x026f5}, // Sailboat ..Sailboat + {0x026fa, 0x026fa}, // Tent ..Tent + {0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump + {0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark + {0x0270a, 0x0270b}, // Raised Fist ..Raised Hand + {0x02728, 0x02728}, // Sparkles ..Sparkles + {0x0274c, 0x0274c}, // Cross Mark ..Cross Mark + {0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M + {0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O + {0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S + {0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign + {0x027b0, 0x027b0}, // Curly Loop ..Curly Loop + {0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop + {0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square + {0x02b50, 0x02b50}, // White Medium Star ..White Medium Star + {0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle + {0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap + {0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified + {0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute + {0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description + {0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In + {0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke + {0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto + {0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn + {0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae + {0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q + {0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha + {0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto + {0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d + {0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr + {0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke + {0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo + {0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih + {0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil) + {0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve + {0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop + {0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign + {0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At + {0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa + {0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign + {0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill + {0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea + {0x17000, 0x187f7}, // (nil) ..(nil) + {0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char + {0x18d00, 0x18d08}, // (nil) ..(nil) + {0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T + {0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N + {0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N + {0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic + {0x1b132, 0x1b132}, // (nil) ..(nil) + {0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo + {0x1b155, 0x1b155}, // (nil) ..(nil) + {0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N + {0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb + {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon + {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker + {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab + {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs + {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa + {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo + {0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed + {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept + {0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai + {0x1f300, 0x1f320}, // Cyclone ..Shooting Star + {0x1f32d, 0x1f335}, // Hot Dog ..Cactus + {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle + {0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap + {0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer + {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And + {0x1f3e0, 0x1f3f0}, // House Building ..European Castle + {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag + {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints + {0x1f440, 0x1f440}, // Eyes ..Eyes + {0x1f442, 0x1f4fc}, // Ear ..Videocassette + {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red + {0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch + {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty + {0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing + {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be + {0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart + {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands + {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage + {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation + {0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley + {0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator + {0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy + {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving + {0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate + {0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square + {0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign + {0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer + {0x1f93c, 0x1f945}, // Wrestlers ..Goal Net + {0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet + {0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch + {0x1fa80, 0x1fa88}, // Yo-yo ..(nil) + {0x1fa90, 0x1fabd}, // Ringed Planet ..(nil) + {0x1fabf, 0x1fac5}, // (nil) ..Person With Crown + {0x1face, 0x1fadb}, // (nil) ..(nil) + {0x1fae0, 0x1fae8}, // Melting Face ..(nil) + {0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil) + {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil) + {0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil) + }; + + + private static boolean intable(int[][] table, int c) { + // First quick check f|| Latin1 etc. characters. + if (c < table[0][0]) return false; + + // Binary search in table. + int bot = 0; + int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1); + while (top >= bot) { + int mid = (bot + top) / 2; + if (table[mid][1] < c) { + bot = mid + 1; + } else if (table[mid][0] > c) { + top = mid - 1; + } else { + return true; + } + } + return false; + } + + /** Return the terminal display width of a code point: 0, 1 || 2. */ + public static int width(int ucs) { + if (ucs == 0 || + ucs == 0x034F || + (0x200B <= ucs && ucs <= 0x200F) || + ucs == 0x2028 || + ucs == 0x2029 || + (0x202A <= ucs && ucs <= 0x202E) || + (0x2060 <= ucs && ucs <= 0x2063)) { + return 0; + } + + // C0/C1 control characters + // Termux change: Return 0 instead of -1. + if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0; + + // combining characters with zero width + if (intable(ZERO_WIDTH, ucs)) return 0; + + return intable(WIDE_EASTASIAN, ucs) ? 2 : 1; + } + + /** The width at an index position in a java char array. */ + public static int width(char[] chars, int index) { + char c = chars[index]; + return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c); + } + +} diff --git a/termux/termux-emulator/src/main/jni/Android.mk b/termux/termux-emulator/src/main/jni/Android.mk new file mode 100644 index 000000000..6c6f8b229 --- /dev/null +++ b/termux/termux-emulator/src/main/jni/Android.mk @@ -0,0 +1,5 @@ +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) +LOCAL_MODULE:= libtermux +LOCAL_SRC_FILES:= termux.c +include $(BUILD_SHARED_LIBRARY) diff --git a/termux/termux-emulator/src/main/jni/termux.c b/termux/termux-emulator/src/main/jni/termux.c new file mode 100644 index 000000000..22f1d507d --- /dev/null +++ b/termux/termux-emulator/src/main/jni/termux.c @@ -0,0 +1,214 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TERMUX_UNUSED(x) x __attribute__((__unused__)) +#ifdef __APPLE__ +# define LACKS_PTSNAME_R +#endif + +static int throw_runtime_exception(JNIEnv* env, char const* message) +{ + jclass exClass = (*env)->FindClass(env, "java/lang/RuntimeException"); + (*env)->ThrowNew(env, exClass, message); + return -1; +} + +static int create_subprocess(JNIEnv* env, + char const* cmd, + char const* cwd, + char* const argv[], + char** envp, + int* pProcessId, + jint rows, + jint columns) +{ + int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC); + if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx"); + +#ifdef LACKS_PTSNAME_R + char* devname; +#else + char devname[64]; +#endif + if (grantpt(ptm) || unlockpt(ptm) || +#ifdef LACKS_PTSNAME_R + (devname = ptsname(ptm)) == NULL +#else + ptsname_r(ptm, devname, sizeof(devname)) +#endif + ) { + return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx"); + } + + // Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display. + struct termios tios; + tcgetattr(ptm, &tios); + tios.c_iflag |= IUTF8; + tios.c_iflag &= ~(IXON | IXOFF); + tcsetattr(ptm, TCSANOW, &tios); + + /** Set initial winsize. */ + struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns }; + ioctl(ptm, TIOCSWINSZ, &sz); + + pid_t pid = fork(); + if (pid < 0) { + return throw_runtime_exception(env, "Fork failed"); + } else if (pid > 0) { + *pProcessId = (int) pid; + return ptm; + } else { + // Clear signals which the Android java process may have blocked: + sigset_t signals_to_unblock; + sigfillset(&signals_to_unblock); + sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0); + + close(ptm); + setsid(); + + int pts = open(devname, O_RDWR); + if (pts < 0) exit(-1); + + dup2(pts, 0); + dup2(pts, 1); + dup2(pts, 2); + + DIR* self_dir = opendir("/proc/self/fd"); + if (self_dir != NULL) { + int self_dir_fd = dirfd(self_dir); + struct dirent* entry; + while ((entry = readdir(self_dir)) != NULL) { + int fd = atoi(entry->d_name); + if (fd > 2 && fd != self_dir_fd) close(fd); + } + closedir(self_dir); + } + + clearenv(); + if (envp) for (; *envp; ++envp) putenv(*envp); + + if (chdir(cwd) != 0) { + char* error_message; + // No need to free asprintf()-allocated memory since doing execvp() or exit() below. + if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1) error_message = "chdir()"; + perror(error_message); + fflush(stderr); + } + execvp(cmd, argv); + // Show terminal output about failing exec() call: + char* error_message; + if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1) error_message = "exec()"; + perror(error_message); + _exit(1); + } +} + +JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess( + JNIEnv* env, + jclass TERMUX_UNUSED(clazz), + jstring cmd, + jstring cwd, + jobjectArray args, + jobjectArray envVars, + jintArray processIdArray, + jint rows, + jint columns) +{ + jsize size = args ? (*env)->GetArrayLength(env, args) : 0; + char** argv = NULL; + if (size > 0) { + argv = (char**) malloc((size + 1) * sizeof(char*)); + if (!argv) return throw_runtime_exception(env, "Couldn't allocate argv array"); + for (int i = 0; i < size; ++i) { + jstring arg_java_string = (jstring) (*env)->GetObjectArrayElement(env, args, i); + char const* arg_utf8 = (*env)->GetStringUTFChars(env, arg_java_string, NULL); + if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv"); + argv[i] = strdup(arg_utf8); + (*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8); + } + argv[size] = NULL; + } + + size = envVars ? (*env)->GetArrayLength(env, envVars) : 0; + char** envp = NULL; + if (size > 0) { + envp = (char**) malloc((size + 1) * sizeof(char *)); + if (!envp) return throw_runtime_exception(env, "malloc() for envp array failed"); + for (int i = 0; i < size; ++i) { + jstring env_java_string = (jstring) (*env)->GetObjectArrayElement(env, envVars, i); + char const* env_utf8 = (*env)->GetStringUTFChars(env, env_java_string, 0); + if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env"); + envp[i] = strdup(env_utf8); + (*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8); + } + envp[size] = NULL; + } + + int procId = 0; + char const* cmd_cwd = (*env)->GetStringUTFChars(env, cwd, NULL); + char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL); + int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns); + (*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8); + (*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd); + + if (argv) { + for (char** tmp = argv; *tmp; ++tmp) free(*tmp); + free(argv); + } + if (envp) { + for (char** tmp = envp; *tmp; ++tmp) free(*tmp); + free(envp); + } + + int* pProcId = (int*) (*env)->GetPrimitiveArrayCritical(env, processIdArray, NULL); + if (!pProcId) return throw_runtime_exception(env, "JNI call GetPrimitiveArrayCritical(processIdArray, &isCopy) failed"); + + *pProcId = procId; + (*env)->ReleasePrimitiveArrayCritical(env, processIdArray, pProcId, 0); + + return ptm; +} + +JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyWindowSize(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd, jint rows, jint cols) +{ + struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols }; + ioctl(fd, TIOCSWINSZ, &sz); +} + +JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyUTF8Mode(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd) +{ + struct termios tios; + tcgetattr(fd, &tios); + if ((tios.c_iflag & IUTF8) == 0) { + tios.c_iflag |= IUTF8; + tcsetattr(fd, TCSANOW, &tios); + } +} + +JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_waitFor(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint pid) +{ + int status; + waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + return -WTERMSIG(status); + } else { + // Should never happen - waitpid(2) says "One of the first three macros will evaluate to a non-zero (true) value". + return 0; + } +} + +JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_close(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fileDescriptor) +{ + close(fileDescriptor); +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java new file mode 100644 index 000000000..44a605bdd --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java @@ -0,0 +1,54 @@ +package com.termux.terminal; + +import junit.framework.TestCase; + +public class ByteQueueTest extends TestCase { + + private static void assertArrayEquals(byte[] expected, byte[] actual) { + if (expected.length != actual.length) { + fail("Difference array length"); + } + for (int i = 0; i < expected.length; i++) { + if (expected[i] != actual[i]) { + fail("Inequals at index=" + i + ", expected=" + (int) expected[i] + ", actual=" + (int) actual[i]); + } + } + } + + public void testCompleteWrites() throws Exception { + ByteQueue q = new ByteQueue(10); + assertTrue(q.write(new byte[]{1, 2, 3}, 0, 3)); + + byte[] arr = new byte[10]; + assertEquals(3, q.read(arr, true)); + assertArrayEquals(new byte[]{1, 2, 3}, new byte[]{arr[0], arr[1], arr[2]}); + + assertTrue(q.write(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0, 10)); + assertEquals(10, q.read(arr, true)); + assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, arr); + } + + public void testQueueWraparound() throws Exception { + ByteQueue q = new ByteQueue(10); + + byte[] origArray = new byte[]{1, 2, 3, 4, 5, 6}; + byte[] readArray = new byte[origArray.length]; + for (int i = 0; i < 20; i++) { + q.write(origArray, 0, origArray.length); + assertEquals(origArray.length, q.read(readArray, true)); + assertArrayEquals(origArray, readArray); + } + } + + public void testWriteNotesClosing() throws Exception { + ByteQueue q = new ByteQueue(10); + q.close(); + assertFalse(q.write(new byte[]{1, 2, 3}, 0, 3)); + } + + public void testReadNonBlocking() throws Exception { + ByteQueue q = new ByteQueue(10); + assertEquals(0, q.read(new byte[128], false)); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java new file mode 100644 index 000000000..5a12edd74 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java @@ -0,0 +1,65 @@ +package com.termux.terminal; + +/** "\033[" is the Control Sequence Introducer char sequence (CSI). */ +public class ControlSequenceIntroducerTest extends TerminalTestCase { + + /** CSI Ps P Scroll down Ps lines (default = 1) (SD). */ + public void testCsiT() { + withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[2Tyo\r\nA\r\nB").assertLinesAre(" ", " ", "1 ", "2 yo", "A ", + "Bi "); + // Default value (1): + withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[Tyo\r\nA\r\nB").assertLinesAre(" ", "1 ", "2 ", "3 yo", "Ai ", + "B "); + } + + /** CSI Ps S Scroll up Ps lines (default = 1) (SU). */ + public void testCsiS() { + // The behaviour here is a bit inconsistent between terminals - this is how the OS X Terminal.app does it: + withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[2Sy").assertLinesAre("3 ", "hi ", " ", " y"); + // Default value (1): + withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[Sy").assertLinesAre("2 ", "3 ", "hi ", " y"); + } + + /** CSI Ps X Erase Ps Character(s) (default = 1) (ECH). */ + public void testCsiX() { + // See https://code.google.com/p/chromium/issues/detail?id=212712 where test was extraced from. + withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[X").assertLinesAre("abcdefg ijkl ", " "); + withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[1X").assertLinesAre("abcdefg ijkl ", " "); + withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[2X").assertLinesAre("abcdefg jkl ", " "); + withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[20X").assertLinesAre("abcdefg ", " "); + } + + /** CSI Pm m Set SGR parameter(s) from semicolon-separated list Pm. */ + public void testCsiSGRParameters() { + // Set more parameters (19) than supported (16). Additional parameters should be silently consumed. + withTerminalSized(3, 2).enterString("\033[0;38;2;255;255;255;48;2;0;0;0;1;2;3;4;5;7;8;9mabc").assertLinesAre("abc", " "); + } + + /** CSI Ps b Repeat the preceding graphic character Ps times (REP). */ + public void testRepeat() { + withTerminalSized(3, 2).enterString("a\033[b").assertLinesAre("aa ", " "); + withTerminalSized(3, 2).enterString("a\033[2b").assertLinesAre("aaa", " "); + // When no char has been output we ignore REP: + withTerminalSized(3, 2).enterString("\033[b").assertLinesAre(" ", " "); + // This shows that REP outputs the last emitted code point and not the one relative to the + // current cursor position: + withTerminalSized(5, 2).enterString("abcde\033[2G\033[2b\n").assertLinesAre("aeede", " "); + } + + /** CSI 3 J Clear scrollback (xterm, libvte; non-standard). */ + public void testCsi3J() { + withTerminalSized(3, 2).enterString("a\r\nb\r\nc\r\nd"); + assertEquals("a\nb\nc\nd", mTerminal.getScreen().getTranscriptText()); + enterString("\033[3J"); + assertEquals("c\nd", mTerminal.getScreen().getTranscriptText()); + + withTerminalSized(3, 2).enterString("Lorem_ipsum"); + assertEquals("Lorem_ipsum", mTerminal.getScreen().getTranscriptText()); + enterString("\033[3J"); + assertEquals("ipsum", mTerminal.getScreen().getTranscriptText()); + + withTerminalSized(3, 2).enterString("w\r\nx\r\ny\r\nz\033[?1049h\033[3J\033[?1049l"); + assertEquals("y\nz", mTerminal.getScreen().getTranscriptText()); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java new file mode 100644 index 000000000..8a65e66d0 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java @@ -0,0 +1,266 @@ +package com.termux.terminal; + +import org.junit.Assert; + +public class CursorAndScreenTest extends TerminalTestCase { + + public void testDeleteLinesKeepsStyles() { + int cols = 5, rows = 5; + withTerminalSized(cols, rows); + for (int row = 0; row < 5; row++) { + for (int col = 0; col < 5; col++) { + // Foreground color to col, background to row: + enterString("\033[38;5;" + col + "m"); + enterString("\033[48;5;" + row + "m"); + enterString(Character.toString((char) ('A' + col + row * 5))); + } + } + assertLinesAre("ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"); + for (int row = 0; row < 5; row++) { + for (int col = 0; col < 5; col++) { + long s = getStyleAt(row, col); + Assert.assertEquals(col, TextStyle.decodeForeColor(s)); + Assert.assertEquals(row, TextStyle.decodeBackColor(s)); + } + } + // "${CSI}H" - place cursor at 1,1, then "${CSI}2M" to delete two lines. + enterString("\033[H\033[2M"); + assertLinesAre("KLMNO", "PQRST", "UVWXY", " ", " "); + for (int row = 0; row < 3; row++) { + for (int col = 0; col < 5; col++) { + long s = getStyleAt(row, col); + Assert.assertEquals(col, TextStyle.decodeForeColor(s)); + Assert.assertEquals(row + 2, TextStyle.decodeBackColor(s)); + } + } + // Set default fg and background for the new blank lines: + enterString("\033[38;5;98m"); + enterString("\033[48;5;99m"); + // "${CSI}B" to go down one line, then "${CSI}2L" to insert two lines: + enterString("\033[B\033[2L"); + assertLinesAre("KLMNO", " ", " ", "PQRST", "UVWXY"); + for (int row = 0; row < 5; row++) { + for (int col = 0; col < 5; col++) { + int wantedForeground = (row == 1 || row == 2) ? 98 : col; + int wantedBackground = (row == 1 || row == 2) ? 99 : (row == 0 ? 2 : row); + long s = getStyleAt(row, col); + Assert.assertEquals(wantedForeground, TextStyle.decodeForeColor(s)); + Assert.assertEquals(wantedBackground, TextStyle.decodeBackColor(s)); + } + } + } + + public void testDeleteCharacters() { + withTerminalSized(5, 2).enterString("枝ce").assertLinesAre("枝ce ", " "); + withTerminalSized(5, 2).enterString("a枝ce").assertLinesAre("a枝ce", " "); + withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[P").assertLinesAre("ice ", " "); + withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[2P").assertLinesAre("ce ", " "); + withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[2P").assertLinesAre("ne ", " "); + // "${CSI}${n}P, the delete characters (DCH) sequence should cap characters to delete. + withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[99P").assertLinesAre(" ", " "); + // With combining char U+0302. + withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[2P").assertLinesAre("ce ", " "); + withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[P").assertLinesAre("ice ", " "); + withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[2G\033[2P").assertLinesAre("n\u0302e ", " "); + // With wide 枝 char, checking that putting char at part replaces other with whitespace: + withTerminalSized(5, 2).enterString("枝ce").enterString("\033[Ga").assertLinesAre("a ce ", " "); + withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2Ga").assertLinesAre(" ace ", " "); + // With wide 枝 char, deleting either part replaces other with whitespace: + withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[P").assertLinesAre(" ce ", " "); + withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[P").assertLinesAre(" ce ", " "); + withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[2P").assertLinesAre(" e ", " "); + withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[2P").assertLinesAre("ce ", " "); + withTerminalSized(5, 2).enterString("a枝ce").enterString("\033[G\033[P").assertLinesAre("枝ce ", " "); + } + + public void testInsertMode() { + // "${CSI}4h" enables insert mode. + withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hA").assertLinesAre("Anice", " "); + withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4hA").assertLinesAre("nAice", " "); + withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hABC").assertLinesAre("ABCni", " "); + // With combining char U+0302. + withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hA").assertLinesAre("An\u0302ice", " "); + withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hAB").assertLinesAre("ABn\u0302ic", " "); + withTerminalSized(5, 2).enterString("n\u0302ic\u0302e").enterString("\033[2G\033[4hA").assertLinesAre("n\u0302Aic\u0302e", " "); + // ... but without insert mode, combining char should be overwritten: + withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[GA").assertLinesAre("Aice ", " "); + // ... also with two combining: + withTerminalSized(5, 2).enterString("n\u0302\u0302i\u0302ce").enterString("\033[GA").assertLinesAre("Ai\u0302ce ", " "); + // ... and in last column: + withTerminalSized(5, 2).enterString("n\u0302\u0302ice!\u0302").enterString("\033[5GA").assertLinesAre("n\u0302\u0302iceA", " "); + withTerminalSized(5, 2).enterString("nic\u0302e!\u0302").enterString("\033[4G枝").assertLinesAre("nic\u0302枝", " "); + withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " "); + withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " "); + // With wide 枝 char. + withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4h枝").assertLinesAre("枝nic", " "); + withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4h枝").assertLinesAre("n枝ic", " "); + withTerminalSized(5, 2).enterString("n枝ce").enterString("\033[G\033[4ha").assertLinesAre("an枝c", " "); + } + + /** HPA—Horizontal Position Absolute (http://www.vt100.net/docs/vt510-rm/HPA) */ + public void testCursorHorizontalPositionAbsolute() { + withTerminalSized(4, 4).enterString("ABC\033[`").assertCursorAt(0, 0); + enterString("\033[1`").assertCursorAt(0, 0).enterString("\033[2`").assertCursorAt(0, 1); + enterString("\r\n\033[3`").assertCursorAt(1, 2).enterString("\033[22`").assertCursorAt(1, 3); + // Enable and configure right and left margins, first without origin mode: + enterString("\033[?69h\033[2;3s\033[`").assertCursorAt(0, 0).enterString("\033[22`").assertCursorAt(0, 3); + // .. now with origin mode: + enterString("\033[?6h\033[`").assertCursorAt(0, 1).enterString("\033[22`").assertCursorAt(0, 2); + } + + public void testCursorForward() { + // "${CSI}${N:=1}C" moves cursor forward N columns: + withTerminalSized(6, 2).enterString("A\033[CB\033[2CC").assertLinesAre("A B C", " "); + // If an attempt is made to move the cursor to the right of the right margin, the cursor stops at the right margin: + withTerminalSized(6, 2).enterString("A\033[44CB").assertLinesAre("A B", " "); + // Enable right margin and verify that CUF ends at the set right margin: + withTerminalSized(6, 2).enterString("\033[?69h\033[1;3s\033[44CAB").assertLinesAre(" A ", "B "); + } + + public void testCursorBack() { + // "${CSI}${N:=1}D" moves cursor back N columns: + withTerminalSized(3, 2).enterString("A\033[DB").assertLinesAre("B ", " "); + withTerminalSized(3, 2).enterString("AB\033[2DC").assertLinesAre("CB ", " "); + // If an attempt is made to move the cursor to the left of the left margin, the cursor stops at the left margin: + withTerminalSized(3, 2).enterString("AB\033[44DC").assertLinesAre("CB ", " "); + // Enable left margin and verify that CUB ends at the set left margin: + withTerminalSized(6, 2).enterString("ABCD\033[?69h\033[2;6s\033[44DE").assertLinesAre("AECD ", " "); + } + + public void testCursorUp() { + // "${CSI}${N:=1}A" moves cursor up N rows: + withTerminalSized(3, 3).enterString("ABCDEFG\033[AH").assertLinesAre("ABC", "DHF", "G "); + withTerminalSized(3, 3).enterString("ABCDEFG\033[2AH").assertLinesAre("AHC", "DEF", "G "); + // If an attempt is made to move the cursor above the top margin, the cursor stops at the top margin: + withTerminalSized(3, 3).enterString("ABCDEFG\033[44AH").assertLinesAre("AHC", "DEF", "G "); + } + + public void testCursorDown() { + // "${CSI}${N:=1}B" moves cursor down N rows: + withTerminalSized(3, 3).enterString("AB\033[BC").assertLinesAre("AB ", " C", " "); + withTerminalSized(3, 3).enterString("AB\033[2BC").assertLinesAre("AB ", " ", " C"); + // If an attempt is made to move the cursor below the bottom margin, the cursor stops at the bottom margin: + withTerminalSized(3, 3).enterString("AB\033[44BC").assertLinesAre("AB ", " ", " C"); + } + + public void testReportCursorPosition() { + withTerminalSized(10, 10); + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + enterString("\033[" + (i + 1) + ";" + (j + 1) + "H"); // CUP cursor position. + assertCursorAt(i, j); + // Device Status Report (DSR): + assertEnteringStringGivesResponse("\033[6n", "\033[" + (i + 1) + ";" + (j + 1) + "R"); + // DECXCPR — Extended Cursor Position. Note that http://www.vt100.net/docs/vt510-rm/DECXCPR says + // the response is "${CSI}${LINE};${COLUMN};${PAGE}R" while xterm (http://invisible-island.net/xterm/ctlseqs/ctlseqs.html) + // drops the question mark. Expect xterm behaviour here. + assertEnteringStringGivesResponse("\033[?6n", "\033[?" + (i + 1) + ";" + (j + 1) + ";1R"); + } + } + } + + /** + * See comments on horizontal tab handling in TerminalEmulator.java. + *

+ * We do not want to color already written cells when tabbing over them. + */ + public void DISABLED_testHorizontalTabColorsBackground() { + withTerminalSized(10, 3).enterString("\033[48;5;15m").enterString("\t"); + assertCursorAt(0, 8); + for (int i = 0; i < 10; i++) { + int expectedColor = i < 8 ? 15 : TextStyle.COLOR_INDEX_BACKGROUND; + assertEquals(expectedColor, TextStyle.decodeBackColor(getStyleAt(0, i))); + } + } + + /** + * Test interactions between the cursor overflow bit and various escape sequences. + *

+ * Adapted from hterm: + * https://chromium.googlesource.com/chromiumos/platform/assets/+/2337afa5c063127d5ce40ec7fec9b602d096df86%5E%21/#F2 + */ + public void testClearingOfAutowrap() { + // Fill a row with the last hyphen wrong, then run a command that + // modifies the screen, then add a hyphen. The wrap bit should be + // cleared, so the extra hyphen can fix the row. + withTerminalSized(15, 6); + + enterString("----- 1 ----X"); + enterString("\033[K-"); // EL + + enterString("----- 2 ----X"); + enterString("\033[J-"); // ED + + enterString("----- 3 ----X"); + enterString("\033[@-"); // ICH + + enterString("----- 4 ----X"); + enterString("\033[P-"); // DCH + + enterString("----- 5 ----X"); + enterString("\033[X-"); // ECH + + // DL will delete the entire line but clear the wrap bit, so we + // expect a hyphen at the end and nothing else. + enterString("XXXXXXXXXXXXXXX"); + enterString("\033[M-"); // DL + + assertLinesAre( + "----- 1 -----", + "----- 2 -----", + "----- 3 -----", + "----- 4 -----", + "----- 5 -----", + " -"); + } + + public void testBackspaceAcrossWrappedLines() { + // Backspace should not go to previous line if not auto-wrapped: + withTerminalSized(3, 3).enterString("hi\r\n\b\byou").assertLinesAre("hi ", "you", " "); + // Backspace should go to previous line if auto-wrapped: + withTerminalSized(3, 3).enterString("hi y").assertLinesAre("hi ", "y ", " ").enterString("\b\b#").assertLinesAre("hi#", "y ", " "); + // Initial backspace should do nothing: + withTerminalSized(3, 3).enterString("\b\b\b\bhi").assertLinesAre("hi ", " ", " "); + } + + public void testCursorSaveRestoreLocation() { + // DEC save/restore + withTerminalSized(4, 2).enterString("t\0337est\r\nme\0338ry ").assertLinesAre("try ", "me "); + // ANSI.SYS save/restore + withTerminalSized(4, 2).enterString("t\033[sest\r\nme\033[ury ").assertLinesAre("try ", "me "); + // Alternate screen enter/exit + withTerminalSized(4, 2).enterString("t\033[?1049h\033[Hest\r\nme").assertLinesAre("est ", "me ").enterString("\033[?1049lry").assertLinesAre("try ", " "); + } + + public void testCursorSaveRestoreTextStyle() { + long s; + + // DEC save/restore + withTerminalSized(4, 2).enterString("\033[31;42;4m..\0337\033[36;47;24m\0338.."); + s = getStyleAt(0, 3); + Assert.assertEquals(1, TextStyle.decodeForeColor(s)); + Assert.assertEquals(2, TextStyle.decodeBackColor(s)); + Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s)); + + // ANSI.SYS save/restore + withTerminalSized(4, 2).enterString("\033[31;42;4m..\033[s\033[36;47;24m\033[u.."); + s = getStyleAt(0, 3); + Assert.assertEquals(1, TextStyle.decodeForeColor(s)); + Assert.assertEquals(2, TextStyle.decodeBackColor(s)); + Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s)); + + // Alternate screen enter/exit + withTerminalSized(4, 2); + enterString("\033[31;42;4m..\033[?1049h\033[H\033[36;47;24m."); + s = getStyleAt(0, 0); + Assert.assertEquals(6, TextStyle.decodeForeColor(s)); + Assert.assertEquals(7, TextStyle.decodeBackColor(s)); + Assert.assertEquals(0, TextStyle.decodeEffect(s)); + enterString("\033[?1049l.."); + s = getStyleAt(0, 3); + Assert.assertEquals(1, TextStyle.decodeForeColor(s)); + Assert.assertEquals(2, TextStyle.decodeBackColor(s)); + Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s)); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/DecSetTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/DecSetTest.java new file mode 100644 index 000000000..f31f1fb83 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/DecSetTest.java @@ -0,0 +1,78 @@ +package com.termux.terminal; + +/** + *

+ * "CSI ? Pm h", DEC Private Mode Set (DECSET)
+ * 
+ *

+ * and + *

+ *

+ * "CSI ? Pm l", DEC Private Mode Reset (DECRST)
+ * 
+ *

+ * controls various aspects of the terminal + */ +public class DecSetTest extends TerminalTestCase { + + /** DECSET 25, DECTCEM, controls visibility of the cursor. */ + public void testEnableDisableCursor() { + withTerminalSized(3, 3); + assertTrue("Initially the cursor should be enabled", mTerminal.isCursorEnabled()); + enterString("\033[?25l"); // Disable Cursor (DECTCEM). + assertFalse(mTerminal.isCursorEnabled()); + enterString("\033[?25h"); // Enable Cursor (DECTCEM). + assertTrue(mTerminal.isCursorEnabled()); + + enterString("\033[?25l"); // Disable Cursor (DECTCEM), again. + assertFalse(mTerminal.isCursorEnabled()); + mTerminal.reset(); + assertTrue("Resetting the terminal should enable the cursor", mTerminal.isCursorEnabled()); + + enterString("\033[?25l"); + assertFalse(mTerminal.isCursorEnabled()); + enterString("\033c"); // RIS resetting should enabled cursor. + assertTrue(mTerminal.isCursorEnabled()); + } + + /** DECSET 2004, controls bracketed paste mode. */ + public void testBracketedPasteMode() { + withTerminalSized(3, 3); + + mTerminal.paste("a"); + assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear()); + + enterString("\033[?2004h"); // Enable bracketed paste mode. + mTerminal.paste("a"); + assertEquals("Pasting when in bracketed paste mode should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear()); + + enterString("\033[?2004l"); // Disable bracketed paste mode. + mTerminal.paste("a"); + assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear()); + + enterString("\033[?2004h"); // Enable bracketed paste mode, again. + mTerminal.paste("a"); + assertEquals("Pasting when in bracketed paste mode again should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear()); + + mTerminal.paste("\033ab\033cd\033"); + assertEquals("Pasting an escape character should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear()); + mTerminal.paste("\u0081ab\u0081cd\u009F"); + assertEquals("Pasting C1 control codes should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear()); + + mTerminal.reset(); + mTerminal.paste("a"); + assertEquals("Terminal reset() should disable bracketed paste mode", "a", mOutput.getOutputAndClear()); + } + + /** DECSET 7, DECAWM, controls wraparound mode. */ + public void testWrapAroundMode() { + // Default with wraparound: + withTerminalSized(3, 3).enterString("abcd").assertLinesAre("abc", "d ", " "); + // With wraparound disabled: + withTerminalSized(3, 3).enterString("\033[?7labcd").assertLinesAre("abd", " ", " "); + enterString("efg").assertLinesAre("abg", " ", " "); + // Re-enabling wraparound: + enterString("\033[?7hhij").assertLinesAre("abh", "ij ", " "); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java new file mode 100644 index 000000000..f8899355f --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java @@ -0,0 +1,53 @@ +package com.termux.terminal; + +/** + * "\033P" is a device control string. + */ +public class DeviceControlStringTest extends TerminalTestCase { + + private static String hexEncode(String s) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < s.length(); i++) + result.append(String.format("%02X", (int) s.charAt(i))); + return result.toString(); + } + + private void assertCapabilityResponse(String cap, String expectedResponse) { + String input = "\033P+q" + hexEncode(cap) + "\033\\"; + assertEnteringStringGivesResponse(input, "\033P1+r" + hexEncode(cap) + "=" + hexEncode(expectedResponse) + "\033\\"); + } + + public void testReportColorsAndName() { + // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in + // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key + // names. + // Two special features are also recognized, which are not key names: Co for termcap colors (or colors + // for terminfo colors), and TN for termcap name (or name for terminfo name). + // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the + // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are + // encoded in hexadecimal (2 digits per character). + withTerminalSized(3, 3).enterString("A"); + assertCapabilityResponse("Co", "256"); + assertCapabilityResponse("colors", "256"); + assertCapabilityResponse("TN", "xterm"); + assertCapabilityResponse("name", "xterm"); + enterString("B").assertLinesAre("AB ", " ", " "); + } + + public void testReportKeys() { + withTerminalSized(3, 3); + assertCapabilityResponse("kB", "\033[Z"); + } + + public void testReallyLongDeviceControlString() { + withTerminalSized(3, 3).enterString("\033P"); + for (int i = 0; i < 10000; i++) { + enterString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + // The terminal should ignore the overlong DCS sequence and continue printing "aaa." and fill at least the first two lines with + // them: + assertLineIs(0, "aaa"); + assertLineIs(1, "aaa"); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/HistoryTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/HistoryTest.java new file mode 100644 index 000000000..a252e1a8c --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/HistoryTest.java @@ -0,0 +1,33 @@ +package com.termux.terminal; + + +public class HistoryTest extends TerminalTestCase { + + public void testHistory() { + final int rows = 3; + final int cols = 3; + withTerminalSized(cols, rows).enterString("111222333444555666777888999"); + assertCursorAt(2, 2); + assertLinesAre("777", "888", "999"); + assertHistoryStartsWith("666", "555"); + + mTerminal.resize(cols, 2); + assertHistoryStartsWith("777", "666", "555"); + + mTerminal.resize(cols, 3); + assertHistoryStartsWith("666", "555"); + } + + public void testHistoryWithScrollRegion() { + // "CSI P_s ; P_s r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM). + withTerminalSized(3, 4).enterString("111222333444"); + assertLinesAre("111", "222", "333", "444"); + enterString("\033[2;3r"); + // NOTE: "DECSTBM moves the cursor to column 1, line 1 of the page." + assertCursorAt(0, 0); + enterString("\nCDEFGH").assertLinesAre("111", "CDE", "FGH", "444"); + enterString("IJK").assertLinesAre("111", "FGH", "IJK", "444").assertHistoryStartsWith("CDE"); + enterString("LMN").assertLinesAre("111", "IJK", "LMN", "444").assertHistoryStartsWith("FGH", "CDE"); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java new file mode 100644 index 000000000..15b23761a --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java @@ -0,0 +1,203 @@ +package com.termux.terminal; + +import android.view.KeyEvent; + +import junit.framework.TestCase; + +public class KeyHandlerTest extends TestCase { + + private static String stringToHex(String s) { + if (s == null) return null; + StringBuilder buffer = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + if (buffer.length() > 0) { + buffer.append(" "); + } + buffer.append("0x"); + buffer.append(Integer.toHexString(s.charAt(i))); + } + return buffer.toString(); + } + + private static void assertKeysEquals(String expected, String actual) { + if (!expected.equals(actual)) { + assertEquals(stringToHex(expected), stringToHex(actual)); + } + } + + /** See http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html */ + public void testTermCaps() { + // Backspace. + assertKeysEquals("\u007f", KeyHandler.getCodeFromTermcap("kb", false, false)); + + // Back tab. + assertKeysEquals("\033[Z", KeyHandler.getCodeFromTermcap("kB", false, false)); + + // Arrow keys (up/down/right/left): + assertKeysEquals("\033[A", KeyHandler.getCodeFromTermcap("ku", false, false)); + assertKeysEquals("\033[B", KeyHandler.getCodeFromTermcap("kd", false, false)); + assertKeysEquals("\033[C", KeyHandler.getCodeFromTermcap("kr", false, false)); + assertKeysEquals("\033[D", KeyHandler.getCodeFromTermcap("kl", false, false)); + // .. shifted: + assertKeysEquals("\033[1;2A", KeyHandler.getCodeFromTermcap("kUP", false, false)); + assertKeysEquals("\033[1;2B", KeyHandler.getCodeFromTermcap("kDN", false, false)); + assertKeysEquals("\033[1;2C", KeyHandler.getCodeFromTermcap("%i", false, false)); + assertKeysEquals("\033[1;2D", KeyHandler.getCodeFromTermcap("#4", false, false)); + + // Home/end keys: + assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("kh", false, false)); + assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("@7", false, false)); + // ... shifted: + assertKeysEquals("\033[1;2H", KeyHandler.getCodeFromTermcap("#2", false, false)); + assertKeysEquals("\033[1;2F", KeyHandler.getCodeFromTermcap("*7", false, false)); + + // The traditional keyboard keypad: + // [Insert] [Home] [Page Up ] + // [Delete] [End] [Page Down] + // + // Termcap names (with xterm response in parenthesis): + // K1=Upper left of keypad (xterm sends same "[H" = Home). + // K2=Center of keypad (xterm sends invalid response). + // K3=Upper right of keypad (xterm sends "[5~" = Page Up). + // K4=Lower left of keypad (xterm sends "[F" = End key). + // K5=Lower right of keypad (xterm sends "[6~" = Page Down). + // + // vim/neovim (runtime/doc/term.txt): + // t_K1 keypad home key + // t_K3 keypad page-up key + // t_K4 keypad end key + // t_K5 keypad page-down key + // + assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("K1", false, false)); + assertKeysEquals("\033OH", KeyHandler.getCodeFromTermcap("K1", true, false)); + assertKeysEquals("\033[5~", KeyHandler.getCodeFromTermcap("K3", false, false)); + assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("K4", false, false)); + assertKeysEquals("\033OF", KeyHandler.getCodeFromTermcap("K4", true, false)); + assertKeysEquals("\033[6~", KeyHandler.getCodeFromTermcap("K5", false, false)); + + // Function keys F1-F12: + assertKeysEquals("\033OP", KeyHandler.getCodeFromTermcap("k1", false, false)); + assertKeysEquals("\033OQ", KeyHandler.getCodeFromTermcap("k2", false, false)); + assertKeysEquals("\033OR", KeyHandler.getCodeFromTermcap("k3", false, false)); + assertKeysEquals("\033OS", KeyHandler.getCodeFromTermcap("k4", false, false)); + assertKeysEquals("\033[15~", KeyHandler.getCodeFromTermcap("k5", false, false)); + assertKeysEquals("\033[17~", KeyHandler.getCodeFromTermcap("k6", false, false)); + assertKeysEquals("\033[18~", KeyHandler.getCodeFromTermcap("k7", false, false)); + assertKeysEquals("\033[19~", KeyHandler.getCodeFromTermcap("k8", false, false)); + assertKeysEquals("\033[20~", KeyHandler.getCodeFromTermcap("k9", false, false)); + assertKeysEquals("\033[21~", KeyHandler.getCodeFromTermcap("k;", false, false)); + assertKeysEquals("\033[23~", KeyHandler.getCodeFromTermcap("F1", false, false)); + assertKeysEquals("\033[24~", KeyHandler.getCodeFromTermcap("F2", false, false)); + // Function keys F13-F24 (same as shifted F1-F12): + assertKeysEquals("\033[1;2P", KeyHandler.getCodeFromTermcap("F3", false, false)); + assertKeysEquals("\033[1;2Q", KeyHandler.getCodeFromTermcap("F4", false, false)); + assertKeysEquals("\033[1;2R", KeyHandler.getCodeFromTermcap("F5", false, false)); + assertKeysEquals("\033[1;2S", KeyHandler.getCodeFromTermcap("F6", false, false)); + assertKeysEquals("\033[15;2~", KeyHandler.getCodeFromTermcap("F7", false, false)); + assertKeysEquals("\033[17;2~", KeyHandler.getCodeFromTermcap("F8", false, false)); + assertKeysEquals("\033[18;2~", KeyHandler.getCodeFromTermcap("F9", false, false)); + assertKeysEquals("\033[19;2~", KeyHandler.getCodeFromTermcap("FA", false, false)); + assertKeysEquals("\033[20;2~", KeyHandler.getCodeFromTermcap("FB", false, false)); + assertKeysEquals("\033[21;2~", KeyHandler.getCodeFromTermcap("FC", false, false)); + assertKeysEquals("\033[23;2~", KeyHandler.getCodeFromTermcap("FD", false, false)); + assertKeysEquals("\033[24;2~", KeyHandler.getCodeFromTermcap("FE", false, false)); + } + + public void testKeyCodes() { + // Return sends carriage return (\r), which normally gets translated by the device driver to newline (\n) unless the ICRNL termios + // flag has been set. + assertKeysEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, 0, false, false)); + + // Backspace. + assertKeysEquals("\u007f", KeyHandler.getCode(KeyEvent.KEYCODE_DEL, 0, false, false)); + + // Space. + assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, 0, false, false)); + assertKeysEquals("\u0000", KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, KeyHandler.KEYMOD_CTRL, false, false)); + + // Back tab. + assertKeysEquals("\033[Z", KeyHandler.getCode(KeyEvent.KEYCODE_TAB, KeyHandler.KEYMOD_SHIFT, false, false)); + + // Arrow keys (up/down/right/left): + assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, false, false)); + assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, 0, false, false)); + assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, 0, false, false)); + assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, 0, false, false)); + // .. shifted: + assertKeysEquals("\033[1;2A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_SHIFT, false, false)); + // .. ctrl:ed: + assertKeysEquals("\033[1;5A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_CTRL, false, false)); + assertKeysEquals("\033[1;5B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_CTRL, false, false)); + assertKeysEquals("\033[1;5C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_CTRL, false, false)); + assertKeysEquals("\033[1;5D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_CTRL, false, false)); + // .. ctrl:ed and shifted: + int mod = KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT; + assertKeysEquals("\033[1;6A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, mod, false, false)); + assertKeysEquals("\033[1;6B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, mod, false, false)); + assertKeysEquals("\033[1;6C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, mod, false, false)); + assertKeysEquals("\033[1;6D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, mod, false, false)); + + // Home/end keys: + assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, 0, false, false)); + assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, 0, false, false)); + // ... shifted: + assertKeysEquals("\033[1;2H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, KeyHandler.KEYMOD_SHIFT, false, false)); + + // Function keys F1-F12: + assertKeysEquals("\033OP", KeyHandler.getCode(KeyEvent.KEYCODE_F1, 0, false, false)); + assertKeysEquals("\033OQ", KeyHandler.getCode(KeyEvent.KEYCODE_F2, 0, false, false)); + assertKeysEquals("\033OR", KeyHandler.getCode(KeyEvent.KEYCODE_F3, 0, false, false)); + assertKeysEquals("\033OS", KeyHandler.getCode(KeyEvent.KEYCODE_F4, 0, false, false)); + assertKeysEquals("\033[15~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, 0, false, false)); + assertKeysEquals("\033[17~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, 0, false, false)); + assertKeysEquals("\033[18~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, 0, false, false)); + assertKeysEquals("\033[19~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, 0, false, false)); + assertKeysEquals("\033[20~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, 0, false, false)); + assertKeysEquals("\033[21~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, 0, false, false)); + assertKeysEquals("\033[23~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, 0, false, false)); + assertKeysEquals("\033[24~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, 0, false, false)); + // Function keys F13-F24 (same as shifted F1-F12): + assertKeysEquals("\033[1;2P", KeyHandler.getCode(KeyEvent.KEYCODE_F1, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2Q", KeyHandler.getCode(KeyEvent.KEYCODE_F2, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2R", KeyHandler.getCode(KeyEvent.KEYCODE_F3, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[1;2S", KeyHandler.getCode(KeyEvent.KEYCODE_F4, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[15;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[17;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[18;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[19;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[20;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[21;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[23;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, KeyHandler.KEYMOD_SHIFT, false, false)); + assertKeysEquals("\033[24;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, KeyHandler.KEYMOD_SHIFT, false, false)); + + assertKeysEquals("0", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("1", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("2", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("3", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("4", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("6", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("7", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("8", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals("9", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals(",", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_COMMA, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + assertKeysEquals(".", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, KeyHandler.KEYMOD_NUM_LOCK, false, false)); + + assertKeysEquals("\033[2~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, 0, false, false)); + assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, 0, false, false)); + assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, 0, false, false)); + assertKeysEquals("\033[6~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, 0, false, false)); + assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, 0, false, false)); + assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, 0, false, false)); + assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, 0, false, false)); + assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, 0, false, false)); + assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, 0, false, false)); + assertKeysEquals("\033[5~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, 0, false, false)); + assertKeysEquals("\033[3~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, 0, false, false)); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java new file mode 100644 index 000000000..13377c087 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java @@ -0,0 +1,196 @@ +package com.termux.terminal; + +import android.util.Base64; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** "ESC ]" is the Operating System Command. */ +public class OperatingSystemControlTest extends TerminalTestCase { + + public void testSetTitle() throws Exception { + List expectedTitleChanges = new ArrayList<>(); + + withTerminalSized(10, 10); + enterString("\033]0;Hello, world\007"); + assertEquals("Hello, world", mTerminal.getTitle()); + expectedTitleChanges.add(new ChangedTitle(null, "Hello, world")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033]0;Goodbye, world\007"); + assertEquals("Goodbye, world", mTerminal.getTitle()); + expectedTitleChanges.add(new ChangedTitle("Hello, world", "Goodbye, world")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033]0;Goodbye, \u00F1 world\007"); + assertEquals("Goodbye, \uu00F1 world", mTerminal.getTitle()); + expectedTitleChanges.add(new ChangedTitle("Goodbye, world", "Goodbye, \uu00F1 world")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + // 2 should work as well (0 sets both title and icon). + enterString("\033]2;Updated\007"); + assertEquals("Updated", mTerminal.getTitle()); + expectedTitleChanges.add(new ChangedTitle("Goodbye, \uu00F1 world", "Updated")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033[22;0t"); + enterString("\033]0;FIRST\007"); + expectedTitleChanges.add(new ChangedTitle("Updated", "FIRST")); + assertEquals("FIRST", mTerminal.getTitle()); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033[22;0t"); + enterString("\033]0;SECOND\007"); + assertEquals("SECOND", mTerminal.getTitle()); + + expectedTitleChanges.add(new ChangedTitle("FIRST", "SECOND")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033[23;0t"); + assertEquals("FIRST", mTerminal.getTitle()); + + expectedTitleChanges.add(new ChangedTitle("SECOND", "FIRST")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033[23;0t"); + expectedTitleChanges.add(new ChangedTitle("FIRST", "Updated")); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + + enterString("\033[22;0t"); + enterString("\033[22;0t"); + enterString("\033[22;0t"); + // Popping to same title should not cause changes. + enterString("\033[23;0t"); + enterString("\033[23;0t"); + enterString("\033[23;0t"); + assertEquals(expectedTitleChanges, mOutput.titleChanges); + } + + public void testTitleStack() throws Exception { + // echo -ne '\e]0;BEFORE\007' # set title + // echo -ne '\e[22t' # push to stack + // echo -ne '\e]0;AFTER\007' # set new title + // echo -ne '\e[23t' # retrieve from stack + + withTerminalSized(10, 10); + enterString("\033]0;InitialTitle\007"); + assertEquals("InitialTitle", mTerminal.getTitle()); + enterString("\033[22t"); + assertEquals("InitialTitle", mTerminal.getTitle()); + enterString("\033]0;UpdatedTitle\007"); + assertEquals("UpdatedTitle", mTerminal.getTitle()); + enterString("\033[23t"); + assertEquals("InitialTitle", mTerminal.getTitle()); + enterString("\033[23t\033[23t\033[23t"); + assertEquals("InitialTitle", mTerminal.getTitle()); + } + + public void testSetColor() throws Exception { + // "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC. + withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007"); + assertEquals(Integer.toHexString(0xFF00FF00), Integer.toHexString(mTerminal.mColors.mCurrentColors[5])); + enterString("\033]4;5;#00FFAB\007"); + assertEquals(mTerminal.mColors.mCurrentColors[5], 0xFF00FFAB); + enterString("\033]4;255;#ABFFAB\007"); + assertEquals(mTerminal.mColors.mCurrentColors[255], 0xFFABFFAB); + // Two indexed colors at once: + enterString("\033]4;7;#00FF00;8;#0000FF\007"); + assertEquals(mTerminal.mColors.mCurrentColors[7], 0xFF00FF00); + assertEquals(mTerminal.mColors.mCurrentColors[8], 0xFF0000FF); + } + + void assertIndexColorsMatch(int[] expected) { + for (int i = 0; i < 255; i++) + assertEquals("index=" + i, expected[i], mTerminal.mColors.mCurrentColors[i]); + } + + public void testResetColor() throws Exception { + withTerminalSized(4, 4); + int[] initialColors = new int[TextStyle.NUM_INDEXED_COLORS]; + System.arraycopy(mTerminal.mColors.mCurrentColors, 0, initialColors, 0, initialColors.length); + int[] expectedColors = new int[initialColors.length]; + System.arraycopy(mTerminal.mColors.mCurrentColors, 0, expectedColors, 0, expectedColors.length); + Random rand = new Random(); + for (int endType = 0; endType < 3; endType++) { + // Both BEL (7) and ST (ESC \) can end an OSC sequence. + String ender = (endType == 0) ? "\007" : "\033\\"; + for (int i = 0; i < 255; i++) { + expectedColors[i] = 0xFF000000 + (rand.nextInt() & 0xFFFFFF); + int r = (expectedColors[i] >> 16) & 0xFF; + int g = (expectedColors[i] >> 8) & 0xFF; + int b = expectedColors[i] & 0xFF; + String rgbHex = String.format("%02x", r) + String.format("%02x", g) + String.format("%02x", b); + enterString("\033]4;" + i + ";#" + rgbHex + ender); + assertEquals(expectedColors[i], mTerminal.mColors.mCurrentColors[i]); + } + } + + enterString("\033]104;0\007"); + expectedColors[0] = TerminalColors.COLOR_SCHEME.mDefaultColors[0]; + assertIndexColorsMatch(expectedColors); + enterString("\033]104;1;2\007"); + expectedColors[1] = TerminalColors.COLOR_SCHEME.mDefaultColors[1]; + expectedColors[2] = TerminalColors.COLOR_SCHEME.mDefaultColors[2]; + assertIndexColorsMatch(expectedColors); + enterString("\033]104\007"); // Reset all colors. + assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors); + } + + public void disabledTestSetClipboard() { + // Cannot run this as a unit test since Base64 is a android.util class. + enterString("\033]52;c;" + Base64.encodeToString("Hello, world".getBytes(), 0) + "\007"); + } + + public void testResettingTerminalResetsColor() throws Exception { + // "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC. + withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007"); + enterString("\033]4;5;#00FFAB\007").assertColor(5, 0xFF00FFAB); + enterString("\033]4;255;#ABFFAB\007").assertColor(255, 0xFFABFFAB); + mTerminal.reset(); + assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors); + } + + public void testSettingDynamicColors() { + // "${OSC}${DYNAMIC};${COLORSPEC}${BEL_OR_STRINGTERMINATOR}" => Change ${DYNAMIC} color to the color specified by $COLORSPEC where: + // DYNAMIC=10: Text foreground color. + // DYNAMIC=11: Text background color. + // DYNAMIC=12: Text cursor color. + withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00); + enterString("\033]11;#0ABCD0\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF0ABCD0); + enterString("\033]12;#00ABCD\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00ABCD); + // Two special colors at once + // ("Each successive parameter changes the next color in the list. The value of P s tells the starting point in the list"): + enterString("\033]10;#FF0000;#00FF00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000); + assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00); + // Three at once: + enterString("\033]10;#0000FF;#00FF00;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFF0000FF); + assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFFFF0000); + + // Without ending semicolon: + enterString("\033]10;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000); + // For background and cursor: + enterString("\033]11;#FFFF00;\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00); + enterString("\033]12;#00FFFF;\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF); + + // Using string terminator: + String stringTerminator = "\033\\"; + enterString("\033]10;#FF0000" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000); + // For background and cursor: + enterString("\033]11;#FFFF00;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00); + enterString("\033]12;#00FFFF;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF); + } + + public void testReportSpecialColors() { + // "${OSC}${DYNAMIC};?${BEL}" => Terminal responds with the control sequence which would set the current color. + // Both xterm and libvte (gnome-terminal and others) use the longest color representation, which means that + // the response is "${OSC}rgb:RRRR/GGGG/BBBB" + withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00); + assertEnteringStringGivesResponse("\033]10;?\007", "\033]10;rgb:abab/cdcd/0000\007"); + // Same as above but with string terminator. xterm uses the same string terminator in the response, which + // e.g. script posted at http://superuser.com/questions/157563/programmatic-access-to-current-xterm-background-color + // relies on: + assertEnteringStringGivesResponse("\033]10;?\033\\", "\033]10;rgb:abab/cdcd/0000\033\\"); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java new file mode 100644 index 000000000..8709c2c38 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java @@ -0,0 +1,117 @@ +package com.termux.terminal; + +public class RectangularAreasTest extends TerminalTestCase { + + /** http://www.vt100.net/docs/vt510-rm/DECFRA */ + public void testFillRectangularArea() { + withTerminalSized(3, 3).enterString("\033[88$x").assertLinesAre("XXX", "XXX", "XXX"); + withTerminalSized(3, 3).enterString("\033[88;1;1;2;10$x").assertLinesAre("XXX", "XXX", " "); + withTerminalSized(3, 3).enterString("\033[88;2;1;3;10$x").assertLinesAre(" ", "XXX", "XXX"); + withTerminalSized(3, 3).enterString("\033[88;1;1;100;1$x").assertLinesAre("X ", "X ", "X "); + withTerminalSized(3, 3).enterString("\033[88;1;1;100;2$x").assertLinesAre("XX ", "XX ", "XX "); + withTerminalSized(3, 3).enterString("\033[88;100;1;100;2$x").assertLinesAre(" ", " ", " "); + } + + /** http://www.vt100.net/docs/vt510-rm/DECERA */ + public void testEraseRectangularArea() { + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[$z").assertLinesAre(" ", " ", " "); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10$z").assertLinesAre(" ", " ", "GHI"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10$z").assertLinesAre("ABC", " ", " "); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1$z").assertLinesAre(" BC", " EF", " HI"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2$z").assertLinesAre(" C", " F", " I"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2$z").assertLinesAre("ABC", "DEF", "GHI"); + + withTerminalSized(3, 3).enterString("A\033[$zBC").assertLinesAre(" BC", " ", " "); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSED */ + public void testSelectiveEraseInDisplay() { + // ${CSI}1"q enables protection, ${CSI}0"q disables it. + // ${CSI}?${0,1,2}J" erases (0=cursor to end, 1=start to cursor, 2=complete display). + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2J").assertLinesAre(" ", " ", " "); + withTerminalSized(3, 3).enterString("ABC\033[1\"qDE\033[0\"qFGHI\033[?2J").assertLinesAre(" ", "DE ", " "); + withTerminalSized(3, 3).enterString("\033[1\"qABCDE\033[0\"qFGHI\033[?2J").assertLinesAre("ABC", "DE ", " "); + } + + /** http://vt100.net/docs/vt510-rm/DECSEL */ + public void testSelectiveEraseInLine() { + // ${CSI}1"q enables protection, ${CSI}0"q disables it. + // ${CSI}?${0,1,2}K" erases (0=cursor to end, 1=start to cursor, 2=complete line). + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2K").assertLinesAre("ABC", "DEF", " "); + withTerminalSized(3, 3).enterString("ABCDE\033[?0KFGHI").assertLinesAre("ABC", "DEF", "GHI"); + withTerminalSized(3, 3).enterString("ABCDE\033[?1KFGHI").assertLinesAre("ABC", " F", "GHI"); + withTerminalSized(3, 3).enterString("ABCDE\033[?2KFGHI").assertLinesAre("ABC", " F", "GHI"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;2H\033[?0K").assertLinesAre("ABC", "D ", "GHI"); + withTerminalSized(3, 3).enterString("ABC\033[1\"qD\033[0\"qE\033[?2KFGHI").assertLinesAre("ABC", "D F", "GHI"); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSERA */ + public void testSelectiveEraseInRectangle() { + // ${CSI}1"q enables protection, ${CSI}0"q disables it. + // ${CSI}?${TOP};${LEFT};${BOTTOM};${RIGHT}${" erases. + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[${").assertLinesAre(" ", " ", " "); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10${").assertLinesAre(" ", " ", "GHI"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10${").assertLinesAre("ABC", " ", " "); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1${").assertLinesAre(" BC", " EF", " HI"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2${").assertLinesAre(" C", " F", " I"); + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2${").assertLinesAre("ABC", "DEF", "GHI"); + + withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[${").assertLinesAre(" ", " E ", " "); + withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[1;1;2;10${").assertLinesAre(" ", " E ", "GHI"); + } + + /** http://vt100.net/docs/vt510-rm/DECCRA */ + public void testRectangularCopy() { + // "${CSI}${SRC_TOP};${SRC_LEFT};${SRC_BOTTOM};${SRC_RIGHT};${SRC_PAGE};${DST_TOP};${DST_LEFT};${DST_PAGE}\$v" + withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;5;1$v").assertLinesAre("ABC ", "DEF AB ", "GHI DE "); + withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;4;1$v").assertLinesAre("ABCABC ", "DEFDEF ", "GHIGHI "); + withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;3;1$v").assertLinesAre("ABABC ", "DEDEF ", "GHGHI "); + withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;1;1$v").assertLinesAre("ABCABC ", "DEFDEF ", + "GHIGHI "); + withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;2;1$v").assertLinesAre(" ABCBC ", " DEFEF ", + " GHIHI "); + withTerminalSized(3, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;2;1$v").assertLinesAre("ABC", "DAB", "GDE"); + + // Enable ${CSI}?6h origin mode (DECOM) and ${CSI}?69h for left/right margin (DECLRMM) enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s + // for DECSLRM margin setting. + withTerminalSized(5, 5).enterString("\033[?6h\033[?69h\033[2;4s"); + enterString("ABCDEFGHIJK").assertLinesAre(" ABC ", " DEF ", " GHI ", " JK ", " "); + enterString("\033[1;1;2;2;1;2;2;1$v").assertLinesAre(" ABC ", " DAB ", " GDE ", " JK ", " "); + } + + /** http://vt100.net/docs/vt510-rm/DECCARA */ + public void testChangeAttributesInRectangularArea() { + final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + // "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$r" + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI"); + assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0)); + + // Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle: + withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI"); + assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0)); + } + + /** http://vt100.net/docs/vt510-rm/DECCARA */ + public void testReverseAttributesInRectangularArea() { + final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + final int u = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + final int bu = TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + // "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$t" + withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI"); + assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0)); + + // Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle: + withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI"); + assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0)); + + // Check reversal by initially bolding the B: + withTerminalSized(3, 3).enterString("\033[2*xA\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI"); + assertEffectAttributesSet(effectLine(b, 0, 0), effectLine(b, b, 0), effectLine(0, 0, 0)); + + // Check reversal by initially underlining A, bolding B, then reversing both bold and underline: + withTerminalSized(3, 3).enterString("\033[2*x\033[4mA\033[0m\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1;4$t").assertLinesAre("ABC", "DEF", + "GHI"); + assertEffectAttributesSet(effectLine(b, u, 0), effectLine(bu, bu, 0), effectLine(0, 0, 0)); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/ResizeTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/ResizeTest.java new file mode 100644 index 000000000..fa4a70247 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/ResizeTest.java @@ -0,0 +1,212 @@ +package com.termux.terminal; + +public class ResizeTest extends TerminalTestCase { + + public void testResizeWhenHasHistory() { + final int cols = 3; + withTerminalSized(cols, 3).enterString("111222333444555666777888999").assertCursorAt(2, 2).assertLinesAre("777", "888", "999"); + resize(cols, 5).assertCursorAt(4, 2).assertLinesAre("555", "666", "777", "888", "999"); + resize(cols, 3).assertCursorAt(2, 2).assertLinesAre("777", "888", "999"); + } + + public void testResizeWhenInAltBuffer() { + final int rows = 3, cols = 3; + withTerminalSized(cols, rows).enterString("a\r\ndef$").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1); + + // Resize and back again while in main buffer: + resize(cols, 5).assertLinesAre("a ", "def", "$ ", " ", " ").assertCursorAt(2, 1); + resize(cols, rows).assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1); + + // Switch to alt buffer: + enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 1); + enterString("h").assertLinesAre(" ", " ", " h ").assertCursorAt(2, 2); + + resize(cols, 5).resize(cols, rows); + + // Switch from alt buffer: + enterString("\033[?1049l").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1); + } + + public void testShrinkingInAltBuffer() { + final int rows = 5; + final int cols = 3; + withTerminalSized(cols, rows).enterString("A\r\nB\r\nC\r\nD\r\nE").assertLinesAre("A ", "B ", "C ", "D ", "E "); + enterString("\033[?1049h").assertLinesAre(" ", " ", " ", " ", " "); + resize(3, 3).enterString("\033[?1049lF").assertLinesAre("C ", "D ", "EF "); + } + + public void testResizeAfterNewlineWhenInAltBuffer() { + final int rows = 3; + final int cols = 3; + withTerminalSized(cols, rows); + enterString("a\r\nb\r\nc\r\nd\r\ne\r\nf\r\n").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0); + assertLineWraps(false, false, false); + + // Switch to alt buffer: + enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 0); + enterString("h").assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1); + + // Grow by two rows: + resize(cols, 5).assertLinesAre(" ", " ", "h ", " ", " ").assertCursorAt(2, 1); + resize(cols, rows).assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1); + + // Switch from alt buffer: + enterString("\033[?1049l").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0); + } + + public void testResizeAfterHistoryWraparound() { + final int rows = 3; + final int cols = 10; + withTerminalSized(cols, rows); + StringBuilder buffer = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + String s = Integer.toString(i); + enterString(s); + buffer.setLength(0); + buffer.append(s); + while (buffer.length() < cols) + buffer.append(' '); + if (i > rows) { + assertLineIs(rows - 1, buffer.toString()); + } + enterString("\r\n"); + } + assertLinesAre("998 ", "999 ", " "); + mTerminal.resize(cols, 2); + assertLinesAre("999 ", " "); + mTerminal.resize(cols, 5); + assertLinesAre("996 ", "997 ", "998 ", "999 ", " "); + mTerminal.resize(cols, rows); + assertLinesAre("998 ", "999 ", " "); + } + + public void testVerticalResize() { + final int rows = 5; + final int cols = 3; + + withTerminalSized(cols, rows); + // Foreground color to 119: + enterString("\033[38;5;119m"); + // Background color to 129: + enterString("\033[48;5;129m"); + // Clear with ED, Erase in Display: + enterString("\033[2J"); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + long style = getStyleAt(r, c); + assertEquals(119, TextStyle.decodeForeColor(style)); + assertEquals(129, TextStyle.decodeBackColor(style)); + } + } + enterString("11\r\n22"); + assertLinesAre("11 ", "22 ", " ", " ", " ").assertLineWraps(false, false, false, false, false); + resize(cols, rows - 2).assertLinesAre("11 ", "22 ", " "); + + // After resize, screen should still be same color: + for (int r = 0; r < rows - 2; r++) { + for (int c = 0; c < cols; c++) { + long style = getStyleAt(r, c); + assertEquals(119, TextStyle.decodeForeColor(style)); + assertEquals(129, TextStyle.decodeBackColor(style)); + } + } + + // Background color to 200 and grow back size (which should be cleared to the new background color): + enterString("\033[48;5;200m"); + resize(cols, rows); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + long style = getStyleAt(r, c); + assertEquals(119, TextStyle.decodeForeColor(style)); + assertEquals("wrong at row=" + r, r >= 3 ? 200 : 129, TextStyle.decodeBackColor(style)); + } + } + } + + public void testHorizontalResize() { + final int rows = 5; + final int cols = 5; + + withTerminalSized(cols, rows); + // Background color to 129: + // enterString("\033[48;5;129m").assertLinesAre(" ", " ", " ", " ", " "); + enterString("1111\r\n2222\r\n3333\r\n4444\r\n5555").assertCursorAt(4, 4); + // assertEquals(129, TextStyle.decodeBackColor(getStyleAt(2, 2))); + assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertLineWraps(false, false, false, false, false); + resize(cols + 2, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4); + assertLineWraps(false, false, false, false, false); + resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4); + assertLineWraps(false, false, false, false, false); + resize(cols - 1, rows).assertLinesAre("2222", "3333", "4444", "5555", " ").assertCursorAt(4, 0); + assertLineWraps(false, false, false, true, false); + resize(cols - 2, rows).assertLinesAre("3 ", "444", "4 ", "555", "5 ").assertCursorAt(4, 1); + assertLineWraps(false, true, false, true, false); + // Back to original size: + resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4); + assertLineWraps(false, false, false, false, false); + } + + public void testLineWrap() { + final int rows = 3, cols = 5; + withTerminalSized(cols, rows).enterString("111111").assertLinesAre("11111", "1 ", " "); + assertCursorAt(1, 1).assertLineWraps(true, false, false); + + resize(7, rows).assertCursorAt(0, 6).assertLinesAre("111111 ", " ", " ").assertLineWraps(false, false, false); + resize(cols, rows).assertCursorAt(1, 1).assertLinesAre("11111", "1 ", " ").assertLineWraps(true, false, false); + + enterString("2").assertLinesAre("11111", "12 ", " ").assertLineWraps(true, false, false); + enterString("123").assertLinesAre("11111", "12123", " ").assertLineWraps(true, false, false); + enterString("W").assertLinesAre("11111", "12123", "W ").assertLineWraps(true, true, false); + + withTerminalSized(cols, rows).enterString("1234512345"); + assertLinesAre("12345", "12345", " ").assertLineWraps(true, false, false); + enterString("W").assertLinesAre("12345", "12345", "W ").assertLineWraps(true, true, false); + } + + public void testCursorPositionWhenShrinking() { + final int rows = 5, cols = 3; + withTerminalSized(cols, rows).enterString("$ ").assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2); + resize(3, 3).assertLinesAre("$ ", " ", " ").assertCursorAt(0, 2); + resize(cols, rows).assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2); + } + + public void testResizeWithCombiningCharInLastColumn() { + withTerminalSized(3, 3).enterString("ABC\u0302DEF").assertLinesAre("ABC\u0302", "DEF", " "); + resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " "); + + // Same as above but with colors: + withTerminalSized(3, 3).enterString("\033[37mA\033[35mB\033[33mC\u0302\033[32mD\033[31mE\033[34mF").assertLinesAre("ABC\u0302", + "DEF", " "); + resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " "); + assertForegroundIndices(effectLine(7, 5, 3, 2), effectLine(1, 4, 4, 4), effectLine(4, 4, 4, 4)); + } + + public void testResizeWithLineWrappingContinuing() { + withTerminalSized(5, 3).enterString("\r\nAB DE").assertLinesAre(" ", "AB DE", " "); + resize(4, 3).assertLinesAre("AB D", "E ", " "); + resize(3, 3).assertLinesAre("AB ", "DE ", " "); + resize(5, 3).assertLinesAre(" ", "AB DE", " "); + } + + public void testResizeWithWideChars() { + final int rows = 3, cols = 4; + String twoCharsWidthOne = new String(Character.toChars(TerminalRowTest.TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1)); + withTerminalSized(cols, rows).enterString(twoCharsWidthOne).enterString("\r\n"); + enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " "); + resize(3, 3).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " "); + enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + twoCharsWidthOne + " ", " "); + } + + public void testResizeWithMoreWideChars() { + final int rows = 4, cols = 5; + + withTerminalSized(cols, rows).enterString("qqrr").assertLinesAre("qqrr ", " ", " ", " "); + resize(2, rows).assertLinesAre("qq", "rr", " ", " "); + resize(5, rows).assertLinesAre("qqrr ", " ", " ", " "); + + withTerminalSized(cols, rows).enterString("QR").assertLinesAre("QR ", " ", " ", " "); + resize(2, rows).assertLinesAre("Q", "R", " ", " "); + resize(5, rows).assertLinesAre("QR ", " ", " ", " "); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java new file mode 100644 index 000000000..9a8f115ef --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java @@ -0,0 +1,65 @@ +package com.termux.terminal; + +public class ScreenBufferTest extends TerminalTestCase { + + public void testBasics() { + TerminalBuffer screen = new TerminalBuffer(5, 3, 3); + assertEquals("", screen.getTranscriptText()); + screen.setChar(0, 0, 'a', 0); + assertEquals("a", screen.getTranscriptText()); + screen.setChar(0, 0, 'b', 0); + assertEquals("b", screen.getTranscriptText()); + screen.setChar(2, 0, 'c', 0); + assertEquals("b c", screen.getTranscriptText()); + screen.setChar(2, 2, 'f', 0); + assertEquals("b c\n\n f", screen.getTranscriptText()); + screen.blockSet(0, 0, 2, 2, 'X', 0); + } + + public void testBlockSet() { + TerminalBuffer screen = new TerminalBuffer(5, 3, 3); + screen.blockSet(0, 0, 2, 2, 'X', 0); + assertEquals("XX\nXX", screen.getTranscriptText()); + screen.blockSet(1, 1, 2, 2, 'Y', 0); + assertEquals("XX\nXYY\n YY", screen.getTranscriptText()); + } + + public void testGetSelectedText() { + withTerminalSized(5, 3).enterString("ABCDEFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " "); + assertEquals("AB", mTerminal.getSelectedText(0, 0, 1, 0)); + assertEquals("BC", mTerminal.getSelectedText(1, 0, 2, 0)); + assertEquals("CDE", mTerminal.getSelectedText(2, 0, 4, 0)); + assertEquals("FG", mTerminal.getSelectedText(0, 1, 1, 1)); + assertEquals("GH", mTerminal.getSelectedText(1, 1, 2, 1)); + assertEquals("HIJ", mTerminal.getSelectedText(2, 1, 4, 1)); + + assertEquals("ABCDEFG", mTerminal.getSelectedText(0, 0, 1, 1)); + withTerminalSized(5, 3).enterString("ABCDE\r\nFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " "); + assertEquals("ABCDE\nFG", mTerminal.getSelectedText(0, 0, 1, 1)); + } + + public void testGetSelectedTextJoinFullLines() { + withTerminalSized(5, 3).enterString("ABCDE\r\nFG"); + assertEquals("ABCDEFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true)); + + withTerminalSized(5, 3).enterString("ABC\r\nFG"); + assertEquals("ABC\nFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true)); + } + + public void testGetWordAtLocation() { + withTerminalSized(5, 3).enterString("ABCDEFGHIJ\r\nKLMNO"); + assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(0, 0)); + assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 1)); + assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 2)); + + withTerminalSized(5, 3).enterString("ABC DEF GHI "); + assertEquals("ABC", mTerminal.getScreen().getWordAtLocation(0, 0)); + assertEquals("", mTerminal.getScreen().getWordAtLocation(3, 0)); + assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(4, 0)); + assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(0, 1)); + assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(1, 1)); + assertEquals("GHI", mTerminal.getScreen().getWordAtLocation(0, 2)); + assertEquals("", mTerminal.getScreen().getWordAtLocation(1, 2)); + assertEquals("", mTerminal.getScreen().getWordAtLocation(2, 2)); + } +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java new file mode 100644 index 000000000..cc01c0454 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java @@ -0,0 +1,130 @@ +package com.termux.terminal; + +/** + * ${CSI}${top};${bottom}r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM). + *

+ * "DECSTBM moves the cursor to column 1, line 1 of the page" (http://www.vt100.net/docs/vt510-rm/DECSTBM). + */ +public class ScrollRegionTest extends TerminalTestCase { + + public void testScrollRegionTop() { + withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444"); + enterString("\033[2r").assertCursorAt(0, 0); + enterString("\r\n\r\n\r\n\r\nCDEFGH").assertLinesAre("111", "444", "CDE", "FGH").assertHistoryStartsWith("333"); + enterString("IJK").assertLinesAre("111", "CDE", "FGH", "IJK").assertHistoryStartsWith("444"); + // Reset scroll region and enter line: + enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("LMNOPQ").assertLinesAre("CDE", "FGH", "LMN", "OPQ"); + } + + public void testScrollRegionBottom() { + withTerminalSized(3, 4).enterString("111222333444"); + assertLinesAre("111", "222", "333", "444"); + enterString("\033[1;3r").assertCursorAt(0, 0); + enterString("\r\n\r\nCDEFGH").assertLinesAre("222", "CDE", "FGH", "444").assertHistoryStartsWith("111"); + // Reset scroll region and enter line: + enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("IJKLMN").assertLinesAre("CDE", "FGH", "IJK", "LMN"); + } + + public void testScrollRegionResetWithOriginMode() { + withTerminalSized(3, 4).enterString("111222333444"); + assertLinesAre("111", "222", "333", "444"); + // "\033[?6h" sets origin mode, so that the later DECSTBM resets cursor to below margin: + enterString("\033[?6h\033[2r").assertCursorAt(1, 0); + } + + public void testScrollRegionLeft() { + // ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting. + withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEFG").assertLinesAre("ABC", " DE", " FG"); + enterString("HI").assertLinesAre("ADE", " FG", " HI").enterString("JK").assertLinesAre("AFG", " HI", " JK"); + enterString("\n").assertLinesAre("AHI", " JK", " "); + } + + public void testScrollRegionRight() { + // ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting. + withTerminalSized(3, 3).enterString("YYY\033[?69h\033[1;2sABCDEF").assertLinesAre("ABY", "CD ", "EF "); + enterString("GH").assertLinesAre("CDY", "EF ", "GH ").enterString("IJ").assertLinesAre("EFY", "GH ", "IJ "); + enterString("\n").assertLinesAre("GHY", "IJ ", " "); + } + + public void testScrollRegionOnAllSides() { + // ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting. + withTerminalSized(4, 4).enterString("ABCDEFGHIJKLMNOP").assertLinesAre("ABCD", "EFGH", "IJKL", "MNOP"); + // http://www.vt100.net/docs/vt510-rm/DECOM + enterString("\033[?6h\033[2;3r").assertCursorAt(1, 0); + enterString("\033[?69h\033[2;3s").assertCursorAt(1, 1); + enterString("QRST").assertLinesAre("ABCD", "EQRH", "ISTL", "MNOP"); + enterString("UV").assertLinesAre("ABCD", "ESTH", "IUVL", "MNOP"); + } + + public void testDECCOLMResetsScrollMargin() { + // DECCOLM — Select 80 or 132 Columns per Page (http://www.vt100.net/docs/vt510-rm/DECCOLM) has the important + // side effect to clear scroll margins, which is useful for e.g. the "reset" utility to clear scroll margins. + withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444"); + enterString("\033[2r\033[?3h\r\nABCDEFGHIJKL").assertLinesAre("ABC", "DEF", "GHI", "JKL"); + } + + public void testScrollOutsideVerticalRegion() { + withTerminalSized(3, 4).enterString("\033[0;2rhi\033[4;0Hyou").assertLinesAre("hi ", " ", " ", "you"); + //enterString("see").assertLinesAre("hi ", " ", " ", "see"); + } + + public void testNELRespectsLeftMargin() { + // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", select "10. Test other movement (CR/HT/LF/FF) within margins". + // The NEL (ESC E) sequence moves cursor to first position on next line, where first position depends on origin mode and margin. + withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033ED").assertLinesAre("ABC", "D ", " "); + withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033[?6h\033ED").assertLinesAre("ABC", " D ", " "); + } + + public void testBackwardIndex() { + // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 7. + // Without margins: + withTerminalSized(3, 3).enterString("ABCDEF\0336H").assertLinesAre("ABC", "DHF", " "); + enterString("\0336\0336I").assertLinesAre("ABC", "IHF", " "); + enterString("\0336\0336").assertLinesAre(" AB", " IH", " "); + // With left margin: + withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEF\0336\0336").assertLinesAre("A B", " D", " F"); + } + + public void testForwardIndex() { + // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 8. + // Without margins: + withTerminalSized(3, 3).enterString("ABCD\0339E").assertLinesAre("ABC", "D E", " "); + enterString("\0339").assertLinesAre("BC ", " E ", " "); + // With right margin: + withTerminalSized(3, 3).enterString("\033[?69h\033[0;2sABCD\0339").assertLinesAre("B ", "D ", " "); + } + + public void testScrollDownWithScrollRegion() { + withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 "); + enterString("\033[3r").enterString("\033[2T").assertLinesAre("1 ", "2 ", " ", " ", "3 "); + } + + public void testScrollDownBelowScrollRegion() { + withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 "); + enterString("\033[1;3r"); // DECSTBM margins. + enterString("\033[4;1H"); // Place cursor just below bottom margin. + enterString("QQ\r\nRR\r\n\r\n\r\nYY"); + assertLinesAre("1 ", "2 ", "3 ", "QQ", "YY"); + } + + /** See https://github.com/termux/termux-app/issues/1340 */ + public void testScrollRegionDoesNotLimitCursorMovement() { + withTerminalSized(6, 4) + .enterString("\033[4;7r\033[3;1Haaa\033[Axxx") + .assertLinesAre( + " ", + " xxx", + "aaa ", + " " + ); + + withTerminalSized(6, 4) + .enterString("\033[1;3r\033[3;1Haaa\033[Bxxx") + .assertLinesAre( + " ", + " ", + "aaa ", + " xxx" + ); + } +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java new file mode 100644 index 000000000..043d60876 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java @@ -0,0 +1,432 @@ +package com.termux.terminal; + +import junit.framework.TestCase; + +import java.util.Arrays; +import java.util.Random; + +public class TerminalRowTest extends TestCase { + + /** The properties of these code points are validated in {@link #testStaticConstants()}. */ + private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 = 0x679C; + private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2 = 0x679D; + private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1 = 0x2070E; + private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2 = 0x20731; + + /** Unicode Character 'MUSICAL SYMBOL G CLEF' (U+1D11E). Two java chars required for this. */ + static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1 = 0x1D11E; + /** Unicode Character 'MUSICAL SYMBOL G CLEF OTTAVA ALTA' (U+1D11F). Two java chars required for this. */ + private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2 = 0x1D11F; + + private final int COLUMNS = 80; + + /** A combining character. */ + private static final int DIARESIS_CODEPOINT = 0x0308; + + private TerminalRow row; + + @Override + protected void setUp() throws Exception { + super.setUp(); + row = new TerminalRow(COLUMNS, TextStyle.NORMAL); + } + + private void assertLineStartsWith(int... codePoints) { + char[] chars = row.mText; + int charIndex = 0; + for (int i = 0; i < codePoints.length; i++) { + int lineCodePoint = chars[charIndex++]; + if (Character.isHighSurrogate((char) lineCodePoint)) { + lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]); + } + assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint); + } + } + + private void assertColumnCharIndicesStartsWith(int... indices) { + for (int i = 0; i < indices.length; i++) { + int expected = indices[i]; + int actual = row.findStartOfColumn(i); + assertEquals("At index=" + i, expected, actual); + } + } + + public void testSimpleDiaresis() { + row.setChar(0, DIARESIS_CODEPOINT, 0); + assertEquals(81, row.getSpaceUsed()); + row.setChar(0, DIARESIS_CODEPOINT, 0); + assertEquals(82, row.getSpaceUsed()); + assertLineStartsWith(' ', DIARESIS_CODEPOINT, DIARESIS_CODEPOINT, ' '); + } + + public void testStaticConstants() { + assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1)); + assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2)); + assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1)); + assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2)); + + assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1)); + assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2)); + assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1)); + assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2)); + + assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1)); + assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2)); + assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1)); + assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2)); + + assertEquals(1, Character.charCount(DIARESIS_CODEPOINT)); + assertEquals(0, WcWidth.width(DIARESIS_CODEPOINT)); + } + + public void testOneColumn() { + assertEquals(0, row.findStartOfColumn(0)); + row.setChar(0, 'a', 0); + assertEquals(0, row.findStartOfColumn(0)); + } + + public void testAscii() { + assertEquals(0, row.findStartOfColumn(0)); + row.setChar(0, 'a', 0); + assertLineStartsWith('a', ' ', ' '); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(80, row.getSpaceUsed()); + row.setChar(0, 'b', 0); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(2, row.findStartOfColumn(2)); + assertEquals(80, row.getSpaceUsed()); + assertColumnCharIndicesStartsWith(0, 1, 2, 3); + + char[] someChars = new char[]{'a', 'c', 'e', '4', '5', '6', '7', '8'}; + + char[] rawLine = new char[80]; + Arrays.fill(rawLine, ' '); + Random random = new Random(); + for (int i = 0; i < 1000; i++) { + int lineIndex = random.nextInt(rawLine.length); + int charIndex = random.nextInt(someChars.length); + rawLine[lineIndex] = someChars[charIndex]; + row.setChar(lineIndex, someChars[charIndex], 0); + } + char[] lineChars = row.mText; + for (int i = 0; i < rawLine.length; i++) { + assertEquals(rawLine[i], lineChars[i]); + } + } + + public void testUnicode() { + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(80, row.getSpaceUsed()); + + row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0); + assertEquals(81, row.getSpaceUsed()); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(2, row.findStartOfColumn(1)); + assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, ' ', ' '); + assertColumnCharIndicesStartsWith(0, 2, 3, 4); + + row.setChar(0, 'a', 0); + assertEquals(80, row.getSpaceUsed()); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(1, row.findStartOfColumn(1)); + assertLineStartsWith('a', ' ', ' '); + assertColumnCharIndicesStartsWith(0, 1, 2, 3); + + row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0); + row.setChar(1, 'a', 0); + assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 'a', ' '); + + row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0); + row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, 0); + assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, ' '); + assertColumnCharIndicesStartsWith(0, 2, 4, 5); + assertEquals(82, row.getSpaceUsed()); + } + + public void testDoubleWidth() { + row.setChar(0, 'a', 0); + row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0); + assertLineStartsWith('a', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' '); + assertColumnCharIndicesStartsWith(0, 1, 1, 2); + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ' ', ' '); + assertColumnCharIndicesStartsWith(0, 0, 1, 2); + + row.setChar(0, ' ', 0); + assertLineStartsWith(' ', ' ', ' ', ' '); + assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4); + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0); + assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2); + assertColumnCharIndicesStartsWith(0, 0, 1, 1, 2); + row.setChar(0, 'a', 0); + assertLineStartsWith('a', ' ', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' '); + } + + /** Just as {@link #testDoubleWidth()} but requires a surrogate pair. */ + public void testDoubleWidthSurrogage() { + row.setChar(0, 'a', 0); + assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4); + + row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0); + assertColumnCharIndicesStartsWith(0, 1, 1, 3, 4); + assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' '); + row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0); + assertColumnCharIndicesStartsWith(0, 0, 2, 3, 4); + assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, ' ', ' ', ' '); + + row.setChar(0, ' ', 0); + assertLineStartsWith(' ', ' ', ' ', ' '); + row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0); + row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0); + assertLineStartsWith(' ', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' '); + row.setChar(0, 'a', 0); + assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' '); + } + + public void testReplacementChar() { + row.setChar(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 0); + row.setChar(1, 'Y', 0); + assertLineStartsWith(TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'Y', ' ', ' '); + } + + public void testSurrogateCharsWithNormalDisplayWidth() { + // These requires a UTF-16 surrogate pair, and has a display width of one. + int first = 0x1D306; + int second = 0x1D307; + // Assert the above statement: + assertEquals(2, Character.toChars(first).length); + assertEquals(2, Character.toChars(second).length); + + row.setChar(0, second, 0); + assertEquals(second, Character.toCodePoint(row.mText[0], row.mText[1])); + assertEquals(' ', row.mText[2]); + assertEquals(2, row.findStartOfColumn(1)); + + row.setChar(0, first, 0); + assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1])); + assertEquals(' ', row.mText[2]); + assertEquals(2, row.findStartOfColumn(1)); + + row.setChar(1, second, 0); + row.setChar(2, 'a', 0); + assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1])); + assertEquals(second, Character.toCodePoint(row.mText[2], row.mText[3])); + assertEquals('a', row.mText[4]); + assertEquals(' ', row.mText[5]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(2, row.findStartOfColumn(1)); + assertEquals(4, row.findStartOfColumn(2)); + assertEquals(5, row.findStartOfColumn(3)); + assertEquals(6, row.findStartOfColumn(4)); + + row.setChar(0, ' ', 0); + assertEquals(' ', row.mText[0]); + assertEquals(second, Character.toCodePoint(row.mText[1], row.mText[2])); + assertEquals('a', row.mText[3]); + assertEquals(' ', row.mText[4]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(3, row.findStartOfColumn(2)); + assertEquals(4, row.findStartOfColumn(3)); + assertEquals(5, row.findStartOfColumn(4)); + + for (int i = 0; i < 80; i++) { + row.setChar(i, i % 2 == 0 ? first : second, 0); + } + for (int i = 0; i < 80; i++) { + int idx = row.findStartOfColumn(i); + assertEquals(i % 2 == 0 ? first : second, Character.toCodePoint(row.mText[idx], row.mText[idx + 1])); + } + for (int i = 0; i < 80; i++) { + row.setChar(i, i % 2 == 0 ? 'a' : 'b', 0); + } + for (int i = 0; i < 80; i++) { + int idx = row.findStartOfColumn(i); + assertEquals(i, idx); + assertEquals(i % 2 == 0 ? 'a' : 'b', row.mText[i]); + } + } + + public void testOverwritingDoubleDisplayWidthWithNormalDisplayWidth() { + // Initial "OO " + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + assertEquals(' ', row.mText[1]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(0, row.findStartOfColumn(1)); + assertEquals(1, row.findStartOfColumn(2)); + + // Setting first column to a clears second: "a " + row.setChar(0, 'a', 0); + assertEquals('a', row.mText[0]); + assertEquals(' ', row.mText[1]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(2, row.findStartOfColumn(2)); + + // Back to initial "OO " + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + assertEquals(' ', row.mText[1]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(0, row.findStartOfColumn(1)); + assertEquals(1, row.findStartOfColumn(2)); + + // Setting first column to a clears first: " a " + row.setChar(1, 'a', 0); + assertEquals(' ', row.mText[0]); + assertEquals('a', row.mText[1]); + assertEquals(' ', row.mText[2]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(2, row.findStartOfColumn(2)); + } + + public void testOverwritingDoubleDisplayWidthWithSelf() { + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + assertEquals(' ', row.mText[1]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(0, row.findStartOfColumn(1)); + assertEquals(1, row.findStartOfColumn(2)); + } + + public void testNormalCharsWithDoubleDisplayWidth() { + // These fit in one java char, and has a display width of two. + assertTrue(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 != ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2); + assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1)); + assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2)); + assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1)); + assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2)); + + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + assertEquals(0, row.findStartOfColumn(1)); + assertEquals(' ', row.mText[1]); + + row.setChar(0, 'a', 0); + assertEquals('a', row.mText[0]); + assertEquals(' ', row.mText[1]); + assertEquals(1, row.findStartOfColumn(1)); + + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + // The first character fills both first columns. + assertEquals(0, row.findStartOfColumn(1)); + row.setChar(2, 'a', 0); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + assertEquals('a', row.mText[1]); + assertEquals(1, row.findStartOfColumn(2)); + + row.setChar(0, 'c', 0); + assertEquals('c', row.mText[0]); + assertEquals(' ', row.mText[1]); + assertEquals('a', row.mText[2]); + assertEquals(' ', row.mText[3]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(2, row.findStartOfColumn(2)); + } + + public void testNormalCharsWithDoubleDisplayWidthOverlapping() { + // These fit in one java char, and has a display width of two. + row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0); + row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0); + row.setChar(4, 'a', 0); + // O = ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO + // A = ANOTHER_JAVA_CHAR_DISPLAY_WIDTH_TWO + // "OOAAa " + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(0, row.findStartOfColumn(1)); + assertEquals(1, row.findStartOfColumn(2)); + assertEquals(1, row.findStartOfColumn(3)); + assertEquals(2, row.findStartOfColumn(4)); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]); + assertEquals('a', row.mText[2]); + assertEquals(' ', row.mText[3]); + + row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0); + // " AA a " + assertEquals(' ', row.mText[0]); + assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]); + assertEquals(' ', row.mText[2]); + assertEquals('a', row.mText[3]); + assertEquals(' ', row.mText[4]); + assertEquals(0, row.findStartOfColumn(0)); + assertEquals(1, row.findStartOfColumn(1)); + assertEquals(1, row.findStartOfColumn(2)); + assertEquals(2, row.findStartOfColumn(3)); + assertEquals(3, row.findStartOfColumn(4)); + } + + // https://github.com/jackpal/Android-Terminal-Emulator/issues/145 + public void testCrashATE145() { + // 0xC2541 is unassigned, use display width 1 for UNICODE_REPLACEMENT_CHAR. + // assertEquals(1, WcWidth.width(0xC2541)); + assertEquals(2, Character.charCount(0xC2541)); + + assertEquals(2, WcWidth.width(0x73EE)); + assertEquals(1, Character.charCount(0x73EE)); + + assertEquals(0, WcWidth.width(0x009F)); + assertEquals(1, Character.charCount(0x009F)); + + int[] points = new int[]{0xC2541, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD, 'B', 0x009B, 0x61C9, 'Z'}; + // int[] expected = new int[] { TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD, + // 'B', 0x009B, 0x61C9, 'Z' }; + int currentColumn = 0; + for (int point : points) { + row.setChar(currentColumn, point, 0); + currentColumn += WcWidth.width(point); + } + // assertLineStartsWith(points); + // assertEquals(Character.highSurrogate(0xC2541), line.mText[0]); + // assertEquals(Character.lowSurrogate(0xC2541), line.mText[1]); + // assertEquals('a', line.mText[2]); + // assertEquals('8', line.mText[3]); + // assertEquals(Character.highSurrogate(0x73EE), line.mText[4]); + // assertEquals(Character.lowSurrogate(0x73EE), line.mText[5]); + // + // char[] chars = line.mText; + // int charIndex = 0; + // for (int i = 0; i < points.length; i++) { + // char c = chars[charIndex]; + // charIndex++; + // int thisPoint = (int) c; + // if (Character.isHighSurrogate(c)) { + // thisPoint = Character.toCodePoint(c, chars[charIndex]); + // charIndex++; + // } + // assertEquals("At index=" + i + ", charIndex=" + charIndex + ", char=" + (char) thisPoint, points[i], thisPoint); + // } + } + + public void testNormalization() { + // int lowerCaseN = 0x006E; + // int combiningTilde = 0x0303; + // int combined = 0x00F1; + row.setChar(0, 0x006E, 0); + assertEquals(80, row.getSpaceUsed()); + row.setChar(0, 0x0303, 0); + assertEquals(81, row.getSpaceUsed()); + // assertEquals("\u00F1 ", new String(term.getScreen().getLine(0))); + assertLineStartsWith(0x006E, 0x0303, ' '); + } + + public void testInsertWideAtLastColumn() { + row.setChar(COLUMNS - 2, 'Z', 0); + row.setChar(COLUMNS - 1, 'a', 0); + assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]); + assertEquals('a', row.mText[row.findStartOfColumn(COLUMNS - 1)]); + row.setChar(COLUMNS - 1, 'ö', 0); + assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]); + assertEquals('ö', row.mText[row.findStartOfColumn(COLUMNS - 1)]); + // line.setChar(COLUMNS - 1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1); + // assertEquals('Z', line.mText[line.findStartOfColumn(COLUMNS - 2)]); + // assertEquals(' ', line.mText[line.findStartOfColumn(COLUMNS - 1)]); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalTest.java new file mode 100644 index 000000000..89ad9eca6 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalTest.java @@ -0,0 +1,310 @@ +package com.termux.terminal; + +import java.io.UnsupportedEncodingException; + +public class TerminalTest extends TerminalTestCase { + + public void testCursorPositioning() throws Exception { + withTerminalSized(10, 10).placeCursorAndAssert(1, 2).placeCursorAndAssert(3, 5).placeCursorAndAssert(2, 2).enterString("A") + .assertCursorAt(2, 3); + } + + public void testScreen() throws UnsupportedEncodingException { + withTerminalSized(3, 3); + assertLinesAre(" ", " ", " "); + + assertEquals("", mTerminal.getScreen().getTranscriptText()); + enterString("hi").assertLinesAre("hi ", " ", " "); + assertEquals("hi", mTerminal.getScreen().getTranscriptText()); + enterString("\r\nu"); + assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText()); + mTerminal.reset(); + assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText()); + + withTerminalSized(3, 3).enterString("hello"); + assertEquals("hello", mTerminal.getScreen().getTranscriptText()); + enterString("\r\nworld"); + assertEquals("hello\nworld", mTerminal.getScreen().getTranscriptText()); + } + + public void testScrollDownInAltBuffer() { + withTerminalSized(3, 3).enterString("\033[?1049h"); + enterString("\033[38;5;111m1\r\n"); + enterString("\033[38;5;112m2\r\n"); + enterString("\033[38;5;113m3\r\n"); + enterString("\033[38;5;114m4\r\n"); + enterString("\033[38;5;115m5"); + assertLinesAre("3 ", "4 ", "5 "); + assertForegroundColorAt(0, 0, 113); + assertForegroundColorAt(1, 0, 114); + assertForegroundColorAt(2, 0, 115); + } + + public void testMouseClick() throws Exception { + withTerminalSized(10, 10); + assertFalse(mTerminal.isMouseTrackingActive()); + enterString("\033[?1000h"); + assertTrue(mTerminal.isMouseTrackingActive()); + enterString("\033[?1000l"); + assertFalse(mTerminal.isMouseTrackingActive()); + enterString("\033[?1000h"); + assertTrue(mTerminal.isMouseTrackingActive()); + + enterString("\033[?1006h"); + mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, true); + assertEquals("\033[<0;3;4M", mOutput.getOutputAndClear()); + mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, false); + assertEquals("\033[<0;3;4m", mOutput.getOutputAndClear()); + + // When the client says that a click is outside (which could happen when pixels are outside + // the terminal area, see https://github.com/termux/termux-app/issues/501) the terminal + // sends a click at the edge. + mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 0, 0, true); + assertEquals("\033[<0;1;1M", mOutput.getOutputAndClear()); + mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 11, 11, false); + assertEquals("\033[<0;10;10m", mOutput.getOutputAndClear()); + } + + public void testNormalization() throws UnsupportedEncodingException { + // int lowerCaseN = 0x006E; + // int combiningTilde = 0x0303; + // int combined = 0x00F1; + withTerminalSized(3, 3).assertLinesAre(" ", " ", " "); + enterString("\u006E\u0303"); + assertEquals(1, WcWidth.width("\u006E\u0303".toCharArray(), 0)); + // assertEquals("\u00F1 ", new String(mTerminal.getScreen().getLine(0))); + assertLinesAre("\u006E\u0303 ", " ", " "); + } + + /** On "\e[18t" xterm replies with "\e[8;${HEIGHT};${WIDTH}t" */ + public void testReportTerminalSize() throws Exception { + withTerminalSized(5, 5); + assertEnteringStringGivesResponse("\033[18t", "\033[8;5;5t"); + for (int width = 3; width < 12; width++) { + for (int height = 3; height < 12; height++) { + mTerminal.resize(width, height); + assertEnteringStringGivesResponse("\033[18t", "\033[8;" + height + ";" + width + "t"); + } + } + } + + /** Device Status Report (DSR) and Report Cursor Position (CPR). */ + public void testDeviceStatusReport() throws Exception { + withTerminalSized(5, 5); + assertEnteringStringGivesResponse("\033[5n", "\033[0n"); + + assertEnteringStringGivesResponse("\033[6n", "\033[1;1R"); + enterString("AB"); + assertEnteringStringGivesResponse("\033[6n", "\033[1;3R"); + enterString("\r\n"); + assertEnteringStringGivesResponse("\033[6n", "\033[2;1R"); + } + + /** Test the cursor shape changes using DECSCUSR. */ + public void testSetCursorStyle() throws Exception { + withTerminalSized(5, 5); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle()); + enterString("\033[3 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle()); + enterString("\033[5 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle()); + enterString("\033[0 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle()); + enterString("\033[6 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle()); + enterString("\033[4 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle()); + enterString("\033[1 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle()); + enterString("\033[4 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle()); + enterString("\033[2 q"); + assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle()); + } + + public void testPaste() { + withTerminalSized(5, 5); + mTerminal.paste("hi"); + assertEquals("hi", mOutput.getOutputAndClear()); + + enterString("\033[?2004h"); + mTerminal.paste("hi"); + assertEquals("\033[200~" + "hi" + "\033[201~", mOutput.getOutputAndClear()); + + enterString("\033[?2004l"); + mTerminal.paste("hi"); + assertEquals("hi", mOutput.getOutputAndClear()); + } + + public void testSelectGraphics() { + withTerminalSized(5, 5); + enterString("\033[31m"); + assertEquals(mTerminal.mForeColor, 1); + enterString("\033[32m"); + assertEquals(mTerminal.mForeColor, 2); + enterString("\033[43m"); + assertEquals(2, mTerminal.mForeColor); + assertEquals(3, mTerminal.mBackColor); + + // SGR 0 should reset both foreground and background color. + enterString("\033[0m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); + + // Test CSI resetting to default if sequence starts with ; or has sequential ;; + // Check TerminalEmulator.parseArg() + enterString("\033[31m\033[m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + enterString("\033[31m\033[;m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + enterString("\033[31m\033[0m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + enterString("\033[31m\033[0;m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + enterString("\033[31;;m"); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + + // 256 colors: + enterString("\033[38;5;119m"); + assertEquals(119, mTerminal.mForeColor); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); + enterString("\033[48;5;129m"); + assertEquals(119, mTerminal.mForeColor); + assertEquals(129, mTerminal.mBackColor); + + // Invalid parameter: + enterString("\033[48;8;129m"); + assertEquals(119, mTerminal.mForeColor); + assertEquals(129, mTerminal.mBackColor); + + // Multiple parameters at once: + enterString("\033[38;5;178;48;5;179;m"); + assertEquals(178, mTerminal.mForeColor); + assertEquals(179, mTerminal.mBackColor); + + // 24 bit colors: + enterString(("\033[0m")); // Reset fg and bg colors. + enterString("\033[38;2;255;127;2m"); + int expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2; + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); + enterString("\033[48;2;1;2;254m"); + int expectedBackground = 0xff000000 | (1 << 16) | (2 << 8) | 254; + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(expectedBackground, mTerminal.mBackColor); + + // 24 bit colors, set fg and bg at once: + enterString(("\033[0m")); // Reset fg and bg colors. + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); + enterString("\033[38;2;255;127;2;48;2;1;2;254m"); + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(expectedBackground, mTerminal.mBackColor); + + // 24 bit colors, invalid input: + enterString("\033[38;2;300;127;2;48;2;1;300;254m"); + assertEquals(expectedForeground, mTerminal.mForeColor); + assertEquals(expectedBackground, mTerminal.mBackColor); + } + + public void testBackgroundColorErase() { + final int rows = 3; + final int cols = 3; + withTerminalSized(cols, rows); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + long style = getStyleAt(r, c); + assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.decodeForeColor(style)); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(style)); + } + } + // Foreground color to 119: + enterString("\033[38;5;119m"); + // Background color to 129: + enterString("\033[48;5;129m"); + // Clear with ED, Erase in Display: + enterString("\033[2J"); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + long style = getStyleAt(r, c); + assertEquals(119, TextStyle.decodeForeColor(style)); + assertEquals(129, TextStyle.decodeBackColor(style)); + } + } + // Background color to 139: + enterString("\033[48;5;139m"); + // Insert two blank lines. + enterString("\033[2L"); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + long style = getStyleAt(r, c); + assertEquals((r == 0 || r == 1) ? 139 : 129, TextStyle.decodeBackColor(style)); + } + } + + withTerminalSized(cols, rows); + // Background color to 129: + enterString("\033[48;5;129m"); + // Erase two characters, filling them with background color: + enterString("\033[2X"); + assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 0))); + assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 1))); + assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(getStyleAt(0, 2))); + } + + public void testParseColor() { + assertEquals(0xFF0000FA, TerminalColors.parse("#0000FA")); + assertEquals(0xFF000000, TerminalColors.parse("#000000")); + assertEquals(0xFF000000, TerminalColors.parse("#000")); + assertEquals(0xFF000000, TerminalColors.parse("#000000000")); + assertEquals(0xFF53186f, TerminalColors.parse("#53186f")); + + assertEquals(0xFFFF00FF, TerminalColors.parse("rgb:F/0/F")); + assertEquals(0xFF0000FA, TerminalColors.parse("rgb:00/00/FA")); + assertEquals(0xFF53186f, TerminalColors.parse("rgb:53/18/6f")); + + assertEquals(0, TerminalColors.parse("invalid_0000FA")); + assertEquals(0, TerminalColors.parse("#3456")); + } + + /** The ncurses library still uses this. */ + public void testLineDrawing() { + // 016 - shift out / G1. 017 - shift in / G0. "ESC ) 0" - use line drawing for G1 + withTerminalSized(4, 2).enterString("q\033)0q\016q\017q").assertLinesAre("qq─q", " "); + // "\0337", saving cursor should save G0, G1 and invoked charset and "ESC 8" should restore. + withTerminalSized(4, 2).enterString("\033)0\016qqq\0337\017\0338q").assertLinesAre("────", " "); + } + + public void testSoftTerminalReset() { + // See http://vt100.net/docs/vt510-rm/DECSTR and https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=650304 + // "\033[?7l" is DECRST to disable wrap-around, and DECSTR ("\033[!p") should reset it. + withTerminalSized(3, 3).enterString("\033[?7lABCD").assertLinesAre("ABD", " ", " "); + enterString("\033[!pEF").assertLinesAre("ABE", "F ", " "); + } + + public void testBel() { + withTerminalSized(3, 3); + assertEquals(0, mOutput.bellsRung); + enterString("\07"); + assertEquals(1, mOutput.bellsRung); + enterString("hello\07"); + assertEquals(2, mOutput.bellsRung); + enterString("\07hello"); + assertEquals(3, mOutput.bellsRung); + enterString("hello\07world"); + assertEquals(4, mOutput.bellsRung); + } + + public void testAutomargins() throws UnsupportedEncodingException { + withTerminalSized(3, 3).enterString("abc").assertLinesAre("abc", " ", " ").assertCursorAt(0, 2); + enterString("d").assertLinesAre("abc", "d ", " ").assertCursorAt(1, 1); + + withTerminalSized(3, 3).enterString("abc\r ").assertLinesAre(" bc", " ", " ").assertCursorAt(0, 1); + } + + public void testTab() { + withTerminalSized(11, 2).enterString("01234567890\r\tXX").assertLinesAre("01234567XX0", " "); + withTerminalSized(11, 2).enterString("01234567890\033[44m\r\tXX").assertLinesAre("01234567XX0", " "); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java b/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java new file mode 100644 index 000000000..eb1da8c09 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java @@ -0,0 +1,311 @@ +package com.termux.terminal; + +import junit.framework.AssertionFailedError; +import junit.framework.TestCase; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public abstract class TerminalTestCase extends TestCase { + + public static class MockTerminalOutput extends TerminalOutput { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + public final List titleChanges = new ArrayList<>(); + public final List clipboardPuts = new ArrayList<>(); + public int bellsRung = 0; + public int colorsChanged = 0; + + @Override + public void write(byte[] data, int offset, int count) { + baos.write(data, offset, count); + } + + public String getOutputAndClear() { + String result = new String(baos.toByteArray(), StandardCharsets.UTF_8); + baos.reset(); + return result; + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + titleChanges.add(new ChangedTitle(oldTitle, newTitle)); + } + + @Override + public void onCopyTextToClipboard(String text) { + clipboardPuts.add(text); + } + + @Override + public void onPasteTextFromClipboard() { + } + + @Override + public void onBell() { + bellsRung++; + } + + @Override + public void onColorsChanged() { + colorsChanged++; + } + } + + public TerminalEmulator mTerminal; + public MockTerminalOutput mOutput; + + public static final class ChangedTitle { + final String oldTitle; + final String newTitle; + + public ChangedTitle(String oldTitle, String newTitle) { + this.oldTitle = oldTitle; + this.newTitle = newTitle; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ChangedTitle)) return false; + ChangedTitle other = (ChangedTitle) o; + return Objects.equals(oldTitle, other.oldTitle) && Objects.equals(newTitle, other.newTitle); + } + + @Override + public int hashCode() { + return Objects.hash(oldTitle, newTitle); + } + + @Override + public String toString() { + return "ChangedTitle[oldTitle=" + oldTitle + ", newTitle=" + newTitle + "]"; + } + + } + + public TerminalTestCase enterString(String s) { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + mTerminal.append(bytes, bytes.length); + assertInvariants(); + return this; + } + + public void assertEnteringStringGivesResponse(String input, String expectedResponse) { + enterString(input); + String response = mOutput.getOutputAndClear(); + assertEquals(expectedResponse, response); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + mOutput = new MockTerminalOutput(); + } + + protected TerminalTestCase withTerminalSized(int columns, int rows) { + // The tests aren't currently using the client, so a null client will suffice, a dummy client should be implemented if needed + mTerminal = new TerminalEmulator(mOutput, columns, rows, rows * 2, null); + return this; + } + + public void assertHistoryStartsWith(String... rows) { + assertTrue("About to check " + rows.length + " lines, but only " + mTerminal.getScreen().getActiveTranscriptRows() + " in history", + mTerminal.getScreen().getActiveTranscriptRows() >= rows.length); + for (int i = 0; i < rows.length; i++) { + assertLineIs(-i - 1, rows[i]); + } + } + + private static final class LineWrapper { + final TerminalRow mLine; + + public LineWrapper(TerminalRow line) { + mLine = line; + } + + @Override + public int hashCode() { + return System.identityHashCode(mLine); + } + + @Override + public boolean equals(Object o) { + return o instanceof LineWrapper && ((LineWrapper) o).mLine == mLine; + } + } + + protected TerminalTestCase assertInvariants() { + TerminalBuffer screen = mTerminal.getScreen(); + TerminalRow[] lines = screen.mLines; + + Set linesSet = new HashSet<>(); + for (int i = 0; i < lines.length; i++) { + if (lines[i] == null) continue; + assertTrue("Line exists at multiple places: " + i, linesSet.add(new LineWrapper(lines[i]))); + char[] text = lines[i].mText; + int usedChars = lines[i].getSpaceUsed(); + int currentColumn = 0; + for (int j = 0; j < usedChars; j++) { + char c = text[j]; + int codePoint; + if (Character.isHighSurrogate(c)) { + char lowSurrogate = text[++j]; + assertTrue("High surrogate without following low surrogate", Character.isLowSurrogate(lowSurrogate)); + codePoint = Character.toCodePoint(c, lowSurrogate); + } else { + assertFalse("Low surrogate without preceding high surrogate", Character.isLowSurrogate(c)); + codePoint = c; + } + assertFalse("Screen should never contain unassigned characters", Character.getType(codePoint) == Character.UNASSIGNED); + int width = WcWidth.width(codePoint); + assertFalse("The first column should not start with combining character", currentColumn == 0 && width < 0); + if (width > 0) currentColumn += width; + } + assertEquals("Line whose width does not match screens. line=" + new String(lines[i].mText, 0, lines[i].getSpaceUsed()), + screen.mColumns, currentColumn); + } + + assertEquals("The alt buffer should have have no history", mTerminal.mAltBuffer.mTotalRows, mTerminal.mAltBuffer.mScreenRows); + if (mTerminal.isAlternateBufferActive()) { + assertEquals("The alt buffer should be the same size as the screen", mTerminal.mRows, mTerminal.mAltBuffer.mTotalRows); + } + + return this; + } + + protected void assertLineIs(int line, String expected) { + TerminalRow l = mTerminal.getScreen().allocateFullLineIfNecessary(mTerminal.getScreen().externalToInternalRow(line)); + char[] chars = l.mText; + int textLen = l.getSpaceUsed(); + if (textLen != expected.length()) fail("Expected '" + expected + "' (len=" + expected.length() + "), was='" + + new String(chars, 0, textLen) + "' (len=" + textLen + ")"); + for (int i = 0; i < textLen; i++) { + if (expected.charAt(i) != chars[i]) + fail("Expected '" + expected + "', was='" + new String(chars, 0, textLen) + "' - first different at index=" + i); + } + } + + public TerminalTestCase assertLinesAre(String... lines) { + assertEquals(lines.length, mTerminal.getScreen().mScreenRows); + for (int i = 0; i < lines.length; i++) + try { + assertLineIs(i, lines[i]); + } catch (AssertionFailedError e) { + throw new AssertionFailedError("Line: " + i + " - " + e.getMessage()); + } + return this; + } + + public TerminalTestCase resize(int cols, int rows) { + mTerminal.resize(cols, rows); + assertInvariants(); + return this; + } + + public TerminalTestCase assertLineWraps(boolean... lines) { + for (int i = 0; i < lines.length; i++) + assertEquals("line=" + i, lines[i], mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(i)].mLineWrap); + return this; + } + + protected TerminalTestCase assertLineStartsWith(int line, int... codePoints) { + char[] chars = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(line)].mText; + int charIndex = 0; + for (int i = 0; i < codePoints.length; i++) { + int lineCodePoint = chars[charIndex++]; + if (Character.isHighSurrogate((char) lineCodePoint)) { + lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]); + } + assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint); + } + return this; + } + + protected TerminalTestCase placeCursorAndAssert(int row, int col) { + // +1 due to escape sequence being one based. + enterString("\033[" + (row + 1) + ";" + (col + 1) + "H"); + assertCursorAt(row, col); + return this; + } + + public TerminalTestCase assertCursorAt(int row, int col) { + int actualRow = mTerminal.getCursorRow(); + int actualCol = mTerminal.getCursorCol(); + if (!(row == actualRow && col == actualCol)) + fail("Expected cursor at (row,col)=(" + row + ", " + col + ") but was (" + actualRow + ", " + actualCol + ")"); + return this; + } + + /** For testing only. Encoded style according to {@link TextStyle}. */ + public long getStyleAt(int externalRow, int column) { + return mTerminal.getScreen().getStyleAt(externalRow, column); + } + + public static class EffectLine { + final int[] styles; + + public EffectLine(int[] styles) { + this.styles = styles; + } + } + + protected EffectLine effectLine(int... bits) { + return new EffectLine(bits); + } + + public TerminalTestCase assertEffectAttributesSet(EffectLine... lines) { + assertEquals(lines.length, mTerminal.getScreen().mScreenRows); + for (int i = 0; i < lines.length; i++) { + int[] line = lines[i].styles; + for (int j = 0; j < line.length; j++) { + int effectsAtCell = TextStyle.decodeEffect(getStyleAt(i, j)); + int attributes = line[j]; + if ((effectsAtCell & attributes) != attributes) fail("Line=" + i + ", column=" + j + ", expected " + + describeStyle(attributes) + " set, was " + describeStyle(effectsAtCell)); + } + } + return this; + } + + public TerminalTestCase assertForegroundIndices(EffectLine... lines) { + assertEquals(lines.length, mTerminal.getScreen().mScreenRows); + for (int i = 0; i < lines.length; i++) { + int[] line = lines[i].styles; + for (int j = 0; j < line.length; j++) { + int actualColor = TextStyle.decodeForeColor(getStyleAt(i, j)); + int expectedColor = line[j]; + if (actualColor != expectedColor) fail("Line=" + i + ", column=" + j + ", expected color " + + Integer.toHexString(expectedColor) + " set, was " + Integer.toHexString(actualColor)); + } + } + return this; + } + + private static String describeStyle(int styleBits) { + return "'" + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BLINK) != 0 ? ":BLINK:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BOLD) != 0 ? ":BOLD:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0 ? ":INVERSE:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) != 0 ? ":INVISIBLE:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0 ? ":ITALIC:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0 ? ":PROTECTED:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0 ? ":STRIKETHROUGH:" : "") + + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0 ? ":UNDERLINE:" : "") + "'"; + } + + public void assertForegroundColorAt(int externalRow, int column, int color) { + long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column); + assertEquals(color, TextStyle.decodeForeColor(style)); + } + + public TerminalTestCase assertColor(int colorIndex, int expected) { + int actual = mTerminal.mColors.mCurrentColors[colorIndex]; + if (expected != actual) { + fail("Color index=" + colorIndex + ", expected=" + Integer.toHexString(expected) + ", was=" + Integer.toHexString(actual)); + } + return this; + } +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/TextStyleTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/TextStyleTest.java new file mode 100644 index 000000000..6ec7a34e3 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/TextStyleTest.java @@ -0,0 +1,65 @@ +package com.termux.terminal; + +import junit.framework.TestCase; + +public class TextStyleTest extends TestCase { + + private static final int[] ALL_EFFECTS = new int[]{0, TextStyle.CHARACTER_ATTRIBUTE_BOLD, TextStyle.CHARACTER_ATTRIBUTE_ITALIC, + TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.CHARACTER_ATTRIBUTE_BLINK, TextStyle.CHARACTER_ATTRIBUTE_INVERSE, + TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE, TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH, TextStyle.CHARACTER_ATTRIBUTE_PROTECTED, + TextStyle.CHARACTER_ATTRIBUTE_DIM}; + + public void testEncodingSingle() { + for (int fx : ALL_EFFECTS) { + for (int fg = 0; fg < TextStyle.NUM_INDEXED_COLORS; fg++) { + for (int bg = 0; bg < TextStyle.NUM_INDEXED_COLORS; bg++) { + long encoded = TextStyle.encode(fg, bg, fx); + assertEquals(fg, TextStyle.decodeForeColor(encoded)); + assertEquals(bg, TextStyle.decodeBackColor(encoded)); + assertEquals(fx, TextStyle.decodeEffect(encoded)); + } + } + } + } + + public void testEncoding24Bit() { + int[] values = {255, 240, 127, 1, 0}; + for (int red : values) { + for (int green : values) { + for (int blue : values) { + int argb = 0xFF000000 | (red << 16) | (green << 8) | blue; + long encoded = TextStyle.encode(argb, 0, 0); + assertEquals(argb, TextStyle.decodeForeColor(encoded)); + encoded = TextStyle.encode(0, argb, 0); + assertEquals(argb, TextStyle.decodeBackColor(encoded)); + } + } + } + } + + + public void testEncodingCombinations() { + for (int f1 : ALL_EFFECTS) { + for (int f2 : ALL_EFFECTS) { + int combined = f1 | f2; + assertEquals(combined, TextStyle.decodeEffect(TextStyle.encode(0, 0, combined))); + } + } + } + + public void testEncodingStrikeThrough() { + long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND, + TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH); + assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0); + } + + public void testEncodingProtected() { + long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND, + TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH); + assertEquals(0, (TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED)); + encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND, + TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH | TextStyle.CHARACTER_ATTRIBUTE_PROTECTED); + assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java new file mode 100644 index 000000000..2733190a8 --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java @@ -0,0 +1,136 @@ +package com.termux.terminal; + +import java.io.UnsupportedEncodingException; + +public class UnicodeInputTest extends TerminalTestCase { + + public void testIllFormedUtf8SuccessorByteNotConsumed() throws Exception { + // The Unicode Standard Version 6.2 – Core Specification (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf): + // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first byte, but which does not + // continue with valid successor bytes (see Table 3-7), it must not consume the successor bytes as part of the ill-formed + // subsequence whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit subsequence." + withTerminalSized(5, 5); + mTerminal.append(new byte[]{(byte) 0b11101111, (byte) 'a'}, 2); + assertLineIs(0, ((char) TerminalEmulator.UNICODE_REPLACEMENT_CHAR) + "a "); + + // https://code.google.com/p/chromium/issues/detail?id=212704 + byte[] input = new byte[]{ + (byte) 0x61, (byte) 0xF1, + (byte) 0x80, (byte) 0x80, + (byte) 0xe1, (byte) 0x80, + (byte) 0xc2, (byte) 0x62, + (byte) 0x80, (byte) 0x63, + (byte) 0x80, (byte) 0xbf, + (byte) 0x64 + }; + withTerminalSized(10, 2); + mTerminal.append(input, input.length); + assertLinesAre("a\uFFFD\uFFFD\uFFFDb\uFFFDc\uFFFD\uFFFDd", " "); + + // Surrogate pairs. + withTerminalSized(5, 2); + input = new byte[]{ + (byte) 0xed, (byte) 0xa0, + (byte) 0x80, (byte) 0xed, + (byte) 0xad, (byte) 0xbf, + (byte) 0xed, (byte) 0xae, + (byte) 0x80, (byte) 0xed, + (byte) 0xbf, (byte) 0xbf + }; + mTerminal.append(input, input.length); + assertLinesAre("\uFFFD\uFFFD\uFFFD\uFFFD ", " "); + + // https://bugzilla.mozilla.org/show_bug.cgi?id=746900: "with this patch 0xe0 0x80 is decoded as two U+FFFDs, + // but 0xe0 0xa0 is decoded as a single U+FFFD, and this is correct according to the "Best Practices", but IE + // and Chrome (Version 22.0.1229.94) decode both of them as two U+FFFDs. Opera 12.11 decodes both of them as + // one U+FFFD". + withTerminalSized(5, 2); + input = new byte[]{(byte) 0xe0, (byte) 0xa0, ' '}; + mTerminal.append(input, input.length); + assertLinesAre("\uFFFD ", " "); + + // withTerminalSized(5, 2); + // input = new byte[]{(byte) 0xe0, (byte) 0x80, 'a'}; + // mTerminal.append(input, input.length); + // assertLinesAre("\uFFFD\uFFFDa ", " "); + } + + public void testUnassignedCodePoint() throws UnsupportedEncodingException { + withTerminalSized(3, 3); + // UTF-8 for U+C2541, an unassigned code point: + byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81}; + mTerminal.append(b, b.length); + enterString("Y"); + assertEquals(1, Character.charCount(TerminalEmulator.UNICODE_REPLACEMENT_CHAR)); + assertLineStartsWith(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, (int) 'Y', ' '); + } + + public void testStuff() { + withTerminalSized(80, 24); + byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81, (byte) 0x61, (byte) 0x38, (byte) 0xe7, (byte) 0x8f, + (byte) 0xae, (byte) 0xc2, (byte) 0x9f, (byte) 0xe8, (byte) 0xa0, (byte) 0x9f, (byte) 0xe8, (byte) 0x8c, (byte) 0xa4, + (byte) 0xed, (byte) 0x93, (byte) 0x89, (byte) 0xef, (byte) 0xbf, (byte) 0xbd, (byte) 0x42, (byte) 0xc2, (byte) 0x9b, + (byte) 0xe6, (byte) 0x87, (byte) 0x89, (byte) 0x5a}; + mTerminal.append(b, b.length); + } + + public void testSimpleCombining() throws Exception { + withTerminalSized(3, 2).enterString(" a\u0302 ").assertLinesAre(" a\u0302 ", " "); + } + + public void testCombiningCharacterInFirstColumn() throws Exception { + withTerminalSized(5, 3).enterString("test\r\nhi\r\n").assertLinesAre("test ", "hi ", " "); + + // U+0302 is COMBINING CIRCUMFLEX ACCENT. Test case from mosh (http://mosh.mit.edu/). + withTerminalSized(5, 5).enterString("test\r\nabc\r\n\u0302\r\ndef\r\n"); + assertLinesAre("test ", "abc ", " \u0302 ", "def ", " "); + } + + public void testCombiningCharacterInLastColumn() throws Exception { + withTerminalSized(3, 2).enterString(" a\u0302").assertLinesAre(" a\u0302", " "); + withTerminalSized(3, 2).enterString(" à̲").assertLinesAre(" à̲", " "); + withTerminalSized(3, 2).enterString("Aà̲F").assertLinesAre("Aà̲F", " "); + } + + public void testWideCharacterInLastColumn() throws Exception { + withTerminalSized(3, 2).enterString(" 枝\u0302").assertLinesAre(" ", "枝\u0302 "); + + withTerminalSized(3, 2).enterString(" 枝").assertLinesAre(" 枝", " ").assertCursorAt(0, 2); + enterString("a").assertLinesAre(" 枝", "a "); + } + + public void testWideCharacterDeletion() throws Exception { + // CSI Ps D Cursor Backward Ps Times + withTerminalSized(3, 2).enterString("枝\033[Da").assertLinesAre(" a ", " "); + withTerminalSized(3, 2).enterString("枝\033[2Da").assertLinesAre("a ", " "); + withTerminalSized(3, 2).enterString("枝\033[2D枝").assertLinesAre("枝 ", " "); + withTerminalSized(3, 2).enterString("枝\033[1D枝").assertLinesAre(" 枝", " "); + withTerminalSized(5, 2).enterString(" 枝 \033[Da").assertLinesAre(" 枝a ", " "); + withTerminalSized(5, 2).enterString("a \033[D\u0302").assertLinesAre("a\u0302 ", " "); + withTerminalSized(5, 2).enterString("枝 \033[D\u0302").assertLinesAre("枝\u0302 ", " "); + enterString("Z").assertLinesAre("枝\u0302Z ", " "); + enterString("\033[D ").assertLinesAre("枝\u0302 ", " "); + // Go back two columns, standing at the second half of the wide character: + enterString("\033[2DU").assertLinesAre(" U ", " "); + } + + public void testWideCharOverwriting() { + withTerminalSized(3, 2).enterString("abc\033[3D枝").assertLinesAre("枝c", " "); + } + + public void testOverlongUtf8Encoding() throws Exception { + // U+0020 should be encoded as 0x20, 0xc0 0xa0 is an overlong encoding + // so should be replaced with the replacement char U+FFFD. + withTerminalSized(5, 5).mTerminal.append(new byte[]{(byte) 0xc0, (byte) 0xa0, 'Y'}, 3); + assertLineIs(0, "\uFFFDY "); + } + + public void testWideCharacterWithoutWrapping() throws Exception { + // With wraparound disabled. The behaviour when a wide character is output with cursor in + // the last column when autowrap is disabled is not obvious, but we expect the wide + // character to be ignored here. + withTerminalSized(3, 3).enterString("\033[?7l").enterString("枝枝枝").assertLinesAre("枝 ", " ", " "); + enterString("a枝").assertLinesAre("枝a", " ", " "); + } + +} diff --git a/termux/termux-emulator/src/test/java/com/termux/terminal/WcWidthTest.java b/termux/termux-emulator/src/test/java/com/termux/terminal/WcWidthTest.java new file mode 100644 index 000000000..c0d9a0bca --- /dev/null +++ b/termux/termux-emulator/src/test/java/com/termux/terminal/WcWidthTest.java @@ -0,0 +1,81 @@ +package com.termux.terminal; + +import junit.framework.TestCase; + +public class WcWidthTest extends TestCase { + + private static void assertWidthIs(int expectedWidth, int codePoint) { + int wcWidth = WcWidth.width(codePoint); + assertEquals(expectedWidth, wcWidth); + } + + public void testPrintableAscii() { + for (int i = 0x20; i <= 0x7E; i++) { + assertWidthIs(1, i); + } + } + + public void testSomeWidthOne() { + assertWidthIs(1, 'å'); + assertWidthIs(1, 'ä'); + assertWidthIs(1, 'ö'); + assertWidthIs(1, 0x23F2); + } + + public void testSomeWide() { + assertWidthIs(2, 'A'); + assertWidthIs(2, 'B'); + assertWidthIs(2, 'C'); + assertWidthIs(2, '中'); + assertWidthIs(2, '文'); + + assertWidthIs(2, 0x679C); + assertWidthIs(2, 0x679D); + + assertWidthIs(2, 0x2070E); + assertWidthIs(2, 0x20731); + + assertWidthIs(1, 0x1F781); + } + + public void testSomeNonWide() { + assertWidthIs(1, 0x1D11E); + assertWidthIs(1, 0x1D11F); + } + + public void testCombining() { + assertWidthIs(0, 0x0302); + assertWidthIs(0, 0x0308); + assertWidthIs(0, 0xFE0F); + } + + public void testWordJoiner() { + // https://en.wikipedia.org/wiki/Word_joiner + // The word joiner (WJ) is a code point in Unicode used to separate words when using scripts + // that do not use explicit spacing. It is encoded since Unicode version 3.2 + // (released in 2002) as U+2060 WORD JOINER (HTML ⁠). + // The word joiner does not produce any space, and prohibits a line break at its position. + assertWidthIs(0, 0x2060); + } + + public void testSofthyphen() { + // http://osdir.com/ml/internationalization.linux/2003-05/msg00006.html: + // "Existing implementation practice in terminals is that the SOFT HYPHEN is + // a spacing graphical character, and the purpose of my wcwidth() was to + // predict the advancement of the cursor position after a string is sent to + // a terminal. Hence, I have no choice but to keep wcwidth(SOFT HYPHEN) = 1. + // VT100-style terminals do not hyphenate." + assertWidthIs(1, 0x00AD); + } + + public void testHangul() { + assertWidthIs(1, 0x11A3); + } + + public void testEmojis() { + assertWidthIs(2, 0x1F428); // KOALA. + assertWidthIs(2, 0x231a); // WATCH. + assertWidthIs(2, 0x1F643); // UPSIDE-DOWN FACE (Unicode 8). + } + +} diff --git a/termux/termux-shared/.gitignore b/termux/termux-shared/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/termux/termux-shared/.gitignore @@ -0,0 +1 @@ +/build diff --git a/termux/termux-shared/LICENSE.md b/termux/termux-shared/LICENSE.md new file mode 100644 index 000000000..8a9fca141 --- /dev/null +++ b/termux/termux-shared/LICENSE.md @@ -0,0 +1,22 @@ +The `termux-shared` library is released under [MIT](https://opensource.org/licenses/MIT) license. + +### Exceptions + +#### [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) + +- [`src/main/java/com/termux/shared/termux/*`](src/main/java/com/termux/shared/termux). + +The `GPLv3 only` license applies to all files unless specifically specified by a file/directory, like the [`src/main/java/com/termux/shared/termux/TermuxConstants.java`](src/main/java/com/termux/shared/termux/TermuxConstants.java) and [`src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java`](src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java) files are released under the `MIT` license. +## + + +#### [GPLv2 only with "Classpath" exception](https://openjdk.java.net/legal/gplv2+ce.html) + +- [`src/main/java/com/termux/shared/file/filesystem/*`](src/main/java/com/termux/shared/file/filesystem) files that use code from [libcore/ojluni](https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/). +## + + +#### [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) + +- [`src/main/java/com/termux/shared/shell/StreamGobbler.java`](src/main/java/com/termux/shared/shell/StreamGobbler.java) uses code from [libsuperuser ](https://github.com/Chainfire/libsuperuser). +## diff --git a/termux/termux-shared/build.gradle.kts b/termux/termux-shared/build.gradle.kts new file mode 100644 index 000000000..c963db9aa --- /dev/null +++ b/termux/termux-shared/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id ("com.android.library") + id("kotlin-android") +} + +android { + namespace = "com.termux.shared" + ndkVersion = BuildConfig.ndkVersion + + externalNativeBuild { + ndkBuild { + path = file("src/main/cpp/Android.mk") + } + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.annotation) + implementation(libs.androidx.core) + implementation(libs.androidx.window.v1alpha9) + implementation(libs.common.markwon.core) + implementation(libs.common.markwon.extStrikethrough) + implementation(libs.common.markwon.linkify) + implementation(libs.common.markwon.recycler) + implementation(libs.google.material) + implementation(libs.google.guava) + implementation(libs.common.hiddenApiBypass) + + // Do not increment version higher than 1.0.0-alpha09 since it will break ViewUtils and needs to be looked into + // noinspection GradleDependency + implementation(libs.common.io) + implementation(libs.common.termuxAmLib) + + implementation(projects.common) + implementation(projects.termux.termuxView) + implementation(projects.buildInfo) + implementation(projects.preferences) + implementation(projects.resources) + + testImplementation(projects.testing.unit) + testImplementation(projects.testing.android) +} diff --git a/termux/termux-shared/proguard-rules.pro b/termux/termux-shared/proguard-rules.pro new file mode 100644 index 000000000..ab2e21a77 --- /dev/null +++ b/termux/termux-shared/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +-dontobfuscate +#-renamesourcefileattribute SourceFile +#-keepattributes SourceFile,LineNumberTable diff --git a/termux/termux-shared/src/androidTest/java/com/termux/shared/ExampleInstrumentedTest.java b/termux/termux-shared/src/androidTest/java/com/termux/shared/ExampleInstrumentedTest.java new file mode 100644 index 000000000..ff184472c --- /dev/null +++ b/termux/termux-shared/src/androidTest/java/com/termux/shared/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.termux.shared; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.termux.shared.test", appContext.getPackageName()); + } +} diff --git a/termux/termux-shared/src/main/AndroidManifest.xml b/termux/termux-shared/src/main/AndroidManifest.xml new file mode 100644 index 000000000..042e61a70 --- /dev/null +++ b/termux/termux-shared/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/termux/termux-shared/src/main/cpp/Android.mk b/termux/termux-shared/src/main/cpp/Android.mk new file mode 100644 index 000000000..abc213c37 --- /dev/null +++ b/termux/termux-shared/src/main/cpp/Android.mk @@ -0,0 +1,6 @@ +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) +LOCAL_LDLIBS := -llog +LOCAL_MODULE := local-socket +LOCAL_SRC_FILES := local-socket.cpp +include $(BUILD_SHARED_LIBRARY) diff --git a/termux/termux-shared/src/main/cpp/Application.mk b/termux/termux-shared/src/main/cpp/Application.mk new file mode 100644 index 000000000..ce095350e --- /dev/null +++ b/termux/termux-shared/src/main/cpp/Application.mk @@ -0,0 +1 @@ +APP_STL := c++_static diff --git a/termux/termux-shared/src/main/cpp/local-socket.cpp b/termux/termux-shared/src/main/cpp/local-socket.cpp new file mode 100644 index 000000000..6457aca8a --- /dev/null +++ b/termux/termux-shared/src/main/cpp/local-socket.cpp @@ -0,0 +1,603 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#define LOG_TAG "local-socket" +#define JNI_EXCEPTION "jni-exception" + +using namespace std; + + +/* Convert a jstring to a std:string. */ +string jstring_to_stdstr(JNIEnv *env, jstring jString) { + jclass stringClass = env->FindClass("java/lang/String"); + jmethodID getBytes = env->GetMethodID(stringClass, "getBytes", "()[B"); + jbyteArray jStringBytesArray = (jbyteArray) env->CallObjectMethod(jString, getBytes); + jsize length = env->GetArrayLength(jStringBytesArray); + jbyte* jStringBytes = env->GetByteArrayElements(jStringBytesArray, nullptr); + std::string stdString((char *)jStringBytes, length); + env->ReleaseByteArrayElements(jStringBytesArray, jStringBytes, JNI_ABORT); + return stdString; +} + +/* Get characters before first occurrence of the delim in a std:string. */ +string get_string_till_first_delim(string str, char delim) { + if (!str.empty()) { + stringstream cmdline_args(str); + string tmp; + if (getline(cmdline_args, tmp, delim)) + return tmp; + } + return ""; +} + +/* Replace `\0` values with spaces in a std:string. */ +string replace_null_with_space(string str) { + if (str.empty()) + return ""; + + stringstream tokens(str); + string tmp; + string str_spaced; + while (getline(tokens, tmp, '\0')){ + str_spaced.append(" " + tmp); + } + + if (!str_spaced.empty()) { + if (str_spaced.front() == ' ') + str_spaced.erase(0, 1); + } + + return str_spaced; +} + +/* Get class name of a jclazz object with a call to `Class.getName()`. */ +string get_class_name(JNIEnv *env, jclass clazz) { + jclass classClass = env->FindClass("java/lang/Class"); + jmethodID getName = env->GetMethodID(classClass, "getName", "()Ljava/lang/String;"); + jstring className = (jstring) env->CallObjectMethod(clazz, getName); + return jstring_to_stdstr(env, className); +} + + + +/* + * Get /proc/[pid]/cmdline for a process with pid. + * + * https://manpages.debian.org/testing/manpages/proc.5.en.html + */ +string get_process_cmdline(const pid_t pid) { + string cmdline; + char buf[BUFSIZ]; + size_t len; + char procfile[BUFSIZ]; + sprintf(procfile, "/proc/%d/cmdline", pid); + FILE *fp = fopen(procfile, "rb"); + if (fp) { + while ((len = fread(buf, 1, sizeof(buf), fp)) > 0) { + cmdline.append(buf, len); + } + fclose(fp); + } + + return cmdline; +} + +/* Extract process name from /proc/[pid]/cmdline value of a process. */ +string get_process_name_from_cmdline(string cmdline) { + return get_string_till_first_delim(cmdline, '\0'); +} + +/* Replace `\0` values with spaces in /proc/[pid]/cmdline value of a process. */ +string get_process_cmdline_spaced(string cmdline) { + return replace_null_with_space(cmdline); +} + + +/* Send an ERROR log message to android logcat. */ +void log_error(string message) { + __android_log_write(ANDROID_LOG_ERROR, LOG_TAG, message.c_str()); +} + +/* Send an WARN log message to android logcat. */ +void log_warn(string message) { + __android_log_write(ANDROID_LOG_WARN, LOG_TAG, message.c_str()); +} + +/* Get "title: message" formatted string. */ +string get_title_and_message(JNIEnv *env, jstring title, string message) { + if (title) + message = jstring_to_stdstr(env, title) + ": " + message; + return message; +} + + +/* Convert timespec to milliseconds. */ +int64_t timespec_to_milliseconds(const struct timespec* const time) { + return (((int64_t)time->tv_sec) * 1000) + (((int64_t)time->tv_nsec)/1000000); +} + +/* Convert milliseconds to timeval. */ +timeval milliseconds_to_timeval(int milliseconds) { + struct timeval tv = {}; + tv.tv_sec = milliseconds / 1000; + tv.tv_usec = (milliseconds % 1000) * 1000; + return tv; +} + + +// Note: Exceptions thrown from JNI must be caught with Throwable class instead of Exception, +// otherwise exception will be sent to UncaughtExceptionHandler of the thread. +// Android studio complains that getJniResult functions always return nullptr since linter is broken +// for jboolean and jobject if comparisons. +bool checkJniException(JNIEnv *env) { + if (env->ExceptionCheck()) { + jthrowable throwable = env->ExceptionOccurred(); + if (throwable != NULL) { + env->ExceptionClear(); + env->Throw(throwable); + return true; + } + } + + return false; +} + +string getJniResultString(const int retvalParam, const int errnoParam, + string errmsgParam, const int intDataParam) { + return "retval=" + to_string(retvalParam) + ", errno=" + to_string(errnoParam) + + ", errmsg=\"" + errmsgParam + "\"" + ", intData=" + to_string(intDataParam); +} + +/* Get "com/termux/shared/jni/models/JniResult" object that can be returned as result for a JNI call. */ +jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, const int errnoParam, + string errmsgParam, const int intDataParam) { + jclass clazz = env->FindClass("com/termux/shared/jni/models/JniResult"); + if (checkJniException(env)) return NULL; + if (!clazz) { + log_error(get_title_and_message(env, title, + "Failed to find JniResult class to create object for " + + getJniResultString(retvalParam, errnoParam, errmsgParam, intDataParam))); + return NULL; + } + + jmethodID constructor = env->GetMethodID(clazz, "", "(IILjava/lang/String;I)V"); + if (checkJniException(env)) return NULL; + if (!constructor) { + log_error(get_title_and_message(env, title, + "Failed to get constructor for JniResult class to create object for " + + getJniResultString(retvalParam, errnoParam, errmsgParam, intDataParam))); + return NULL; + } + + if (!errmsgParam.empty()) + errmsgParam = get_title_and_message(env, title, string(errmsgParam)); + + jobject obj = env->NewObject(clazz, constructor, retvalParam, errnoParam, env->NewStringUTF(errmsgParam.c_str()), intDataParam); + if (checkJniException(env)) return NULL; + if (obj == NULL) { + log_error(get_title_and_message(env, title, + "Failed to get JniResult object for " + + getJniResultString(retvalParam, errnoParam, errmsgParam, intDataParam))); + return NULL; + } + + return obj; +} + + +jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, const int errnoParam) { + return getJniResult(env, title, retvalParam, errnoParam, strerror(errnoParam), 0); +} + +jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, string errmsgPrefixParam) { + return getJniResult(env, title, retvalParam, 0, errmsgPrefixParam, 0); +} + +jobject getJniResult(JNIEnv *env, jstring title, const int retvalParam, const int errnoParam, string errmsgPrefixParam) { + return getJniResult(env, title, retvalParam, errnoParam, errmsgPrefixParam + ": " + string(strerror(errnoParam)), 0); +} + +jobject getJniResult(JNIEnv *env, jstring title, const int intDataParam) { + return getJniResult(env, title, 0, 0, "", intDataParam); +} + +jobject getJniResult(JNIEnv *env, jstring title) { + return getJniResult(env, title, 0, 0, "", 0); +} + + +/* Set int fieldName field for clazz to value. */ +string setIntField(JNIEnv *env, jobject obj, jclass clazz, const string fieldName, const int value) { + jfieldID field = env->GetFieldID(clazz, fieldName.c_str(), "I"); + if (checkJniException(env)) return JNI_EXCEPTION; + if (!field) { + return "Failed to get int \"" + string(fieldName) + "\" field of \"" + + get_class_name(env, clazz) + "\" class to set value \"" + to_string(value) + "\""; + } + + env->SetIntField(obj, field, value); + if (checkJniException(env)) return JNI_EXCEPTION; + + return ""; +} + +/* Set String fieldName field for clazz to value. */ +string setStringField(JNIEnv *env, jobject obj, jclass clazz, const string fieldName, const string value) { + jfieldID field = env->GetFieldID(clazz, fieldName.c_str(), "Ljava/lang/String;"); + if (checkJniException(env)) return JNI_EXCEPTION; + if (!field) { + return "Failed to get String \"" + string(fieldName) + "\" field of \"" + + get_class_name(env, clazz) + "\" class to set value \"" + value + "\""; + } + + env->SetObjectField(obj, field, env->NewStringUTF(value.c_str())); + if (checkJniException(env)) return JNI_EXCEPTION; + + return ""; +} + + + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_createServerSocketNative(JNIEnv *env, jclass clazz, + jstring logTitle, + jbyteArray pathArray, + jint backlog) { + if (backlog < 1 || backlog > 500) { + return getJniResult(env, logTitle, -1, "createServerSocketNative(): Backlog \"" + + to_string(backlog) + "\" is not between 1-500"); + } + + // Create server socket + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == -1) { + return getJniResult(env, logTitle, -1, errno, "createServerSocketNative(): Create local socket failed"); + } + + jbyte* path = env->GetByteArrayElements(pathArray, nullptr); + if (checkJniException(env)) return NULL; + if (path == nullptr) { + close(fd); + return getJniResult(env, logTitle, -1, "createServerSocketNative(): Path passed is null"); + } + + // On Linux, sun_path is 108 bytes (UNIX_PATH_MAX) in size + int chars = env->GetArrayLength(pathArray); + if (checkJniException(env)) return NULL; + if (chars >= 108 || chars >= sizeof(struct sockaddr_un) - sizeof(sa_family_t)) { + env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT); + if (checkJniException(env)) return NULL; + close(fd); + return getJniResult(env, logTitle, -1, "createServerSocketNative(): Path passed is too long"); + } + + struct sockaddr_un adr = {.sun_family = AF_UNIX}; + memcpy(&adr.sun_path, path, chars); + + // Bind path to server socket + if (::bind(fd, reinterpret_cast(&adr), sizeof(adr)) == -1) { + int errnoBackup = errno; + env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT); + if (checkJniException(env)) return NULL; + close(fd); + return getJniResult(env, logTitle, -1, errnoBackup, + "createServerSocketNative(): Bind to local socket at path \"" + string(adr.sun_path) + "\" with fd " + to_string(fd) + " failed"); + } + + // Start listening for client sockets on server socket + if (listen(fd, backlog) == -1) { + int errnoBackup = errno; + env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT); + if (checkJniException(env)) return NULL; + close(fd); + return getJniResult(env, logTitle, -1, errnoBackup, + "createServerSocketNative(): Listen on local socket at path \"" + string(adr.sun_path) + "\" with fd " + to_string(fd) + " failed"); + } + + env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT); + if (checkJniException(env)) return NULL; + + // Return success and server socket fd in JniResult.intData field + return getJniResult(env, logTitle, fd); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_closeSocketNative(JNIEnv *env, jclass clazz, + jstring logTitle, jint fd) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "closeSocketNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + if (close(fd) == -1) { + return getJniResult(env, logTitle, -1, errno, "closeSocketNative(): Failed to close socket fd " + to_string(fd)); + } + + // Return success + return getJniResult(env, logTitle); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_acceptNative(JNIEnv *env, jclass clazz, + jstring logTitle, jint fd) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "acceptNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + // Accept client socket + int clientFd = accept(fd, nullptr, nullptr); + if (clientFd == -1) { + return getJniResult(env, logTitle, -1, errno, "acceptNative(): Failed to accept client on fd " + to_string(fd)); + } + + // Return success and client socket fd in JniResult.intData field + return getJniResult(env, logTitle, clientFd); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_readNative(JNIEnv *env, jclass clazz, + jstring logTitle, + jint fd, jbyteArray dataArray, + jlong deadline) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "readNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + jbyte* data = env->GetByteArrayElements(dataArray, nullptr); + if (checkJniException(env)) return NULL; + if (data == nullptr) { + return getJniResult(env, logTitle, -1, "readNative(): data passed is null"); + } + + struct timespec time = {}; + jbyte* current = data; + int bytes = env->GetArrayLength(dataArray); + if (checkJniException(env)) return NULL; + int bytesRead = 0; + while (bytesRead < bytes) { + if (deadline > 0) { + if (clock_gettime(CLOCK_REALTIME, &time) != -1) { + // If current time is greater than the time defined in deadline + if (timespec_to_milliseconds(&time) > deadline) { + env->ReleaseByteArrayElements(dataArray, data, 0); + if (checkJniException(env)) return NULL; + return getJniResult(env, logTitle, -1, + "readNative(): Deadline \"" + to_string(deadline) + "\" timeout"); + } + } else { + log_warn(get_title_and_message(env, logTitle, + "readNative(): Deadline \"" + to_string(deadline) + + "\" timeout will not work since failed to get current time")); + } + } + + // Read data from socket + int ret = read(fd, current, bytes); + if (ret == -1) { + int errnoBackup = errno; + env->ReleaseByteArrayElements(dataArray, data, 0); + if (checkJniException(env)) return NULL; + return getJniResult(env, logTitle, -1, errnoBackup, "readNative(): Failed to read on fd " + to_string(fd)); + } + // EOF, peer closed writing end + if (ret == 0) { + break; + } + + bytesRead += ret; + current += ret; + } + + env->ReleaseByteArrayElements(dataArray, data, 0); + if (checkJniException(env)) return NULL; + + // Return success and bytes read in JniResult.intData field + return getJniResult(env, logTitle, bytesRead); +} + + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_sendNative(JNIEnv *env, jclass clazz, + jstring logTitle, + jint fd, jbyteArray dataArray, + jlong deadline) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "sendNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + jbyte* data = env->GetByteArrayElements(dataArray, nullptr); + if (checkJniException(env)) return NULL; + if (data == nullptr) { + return getJniResult(env, logTitle, -1, "sendNative(): data passed is null"); + } + + struct timespec time = {}; + jbyte* current = data; + int bytes = env->GetArrayLength(dataArray); + if (checkJniException(env)) return NULL; + while (bytes > 0) { + if (deadline > 0) { + if (clock_gettime(CLOCK_REALTIME, &time) != -1) { + // If current time is greater than the time defined in deadline + if (timespec_to_milliseconds(&time) > deadline) { + env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT); + if (checkJniException(env)) return NULL; + return getJniResult(env, logTitle, -1, + "sendNative(): Deadline \"" + to_string(deadline) + "\" timeout"); + } + } else { + log_warn(get_title_and_message(env, logTitle, + "sendNative(): Deadline \"" + to_string(deadline) + + "\" timeout will not work since failed to get current time")); + } + } + + // Send data to socket + int ret = send(fd, current, bytes, MSG_NOSIGNAL); + if (ret == -1) { + int errnoBackup = errno; + env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT); + if (checkJniException(env)) return NULL; + return getJniResult(env, logTitle, -1, errnoBackup, "sendNative(): Failed to send on fd " + to_string(fd)); + } + + bytes -= ret; + current += ret; + } + + env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT); + if (checkJniException(env)) return NULL; + + // Return success + return getJniResult(env, logTitle); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_availableNative(JNIEnv *env, jclass clazz, + jstring logTitle, jint fd) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "availableNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + int available = 0; + if (ioctl(fd, SIOCINQ, &available) == -1) { + return getJniResult(env, logTitle, -1, errno, + "availableNative(): Failed to get number of unread bytes in the receive buffer of fd " + to_string(fd)); + } + + // Return success and bytes available in JniResult.intData field + return getJniResult(env, logTitle, available); +} + +/* Sets socket option timeout in milliseconds. */ +int set_socket_timeout(int fd, int option, int timeout) { + struct timeval tv = milliseconds_to_timeval(timeout); + socklen_t len = sizeof(tv); + return setsockopt(fd, SOL_SOCKET, option, &tv, len); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_setSocketReadTimeoutNative(JNIEnv *env, jclass clazz, + jstring logTitle, + jint fd, jint timeout) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "setSocketReadTimeoutNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + if (set_socket_timeout(fd, SO_RCVTIMEO, timeout) == -1) { + return getJniResult(env, logTitle, -1, errno, + "setSocketReadTimeoutNative(): Failed to set socket receiving (SO_RCVTIMEO) timeout for fd " + to_string(fd)); + } + + // Return success + return getJniResult(env, logTitle); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_setSocketSendTimeoutNative(JNIEnv *env, jclass clazz, + jstring logTitle, + jint fd, jint timeout) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "setSocketSendTimeoutNative(): Invalid fd \"" + + to_string(fd) + "\" passed"); + } + + if (set_socket_timeout(fd, SO_SNDTIMEO, timeout) == -1) { + return getJniResult(env, logTitle, -1, errno, + "setSocketSendTimeoutNative(): Failed to set socket sending (SO_SNDTIMEO) timeout for fd " + to_string(fd)); + } + + // Return success + return getJniResult(env, logTitle); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_termux_shared_net_socket_local_LocalSocketManager_getPeerCredNative(JNIEnv *env, jclass clazz, + jstring logTitle, + jint fd, jobject peerCred) { + if (fd < 0) { + return getJniResult(env, logTitle, -1, "getPeerCredNative(): Invalid fd \"" + to_string(fd) + "\" passed"); + } + + if (peerCred == nullptr) { + return getJniResult(env, logTitle, -1, "getPeerCredNative(): peerCred passed is null"); + } + + // Initialize to -1 instead of 0 in case a failed getsockopt() call somehow doesn't report failure and returns the uid of root + struct ucred cred = {}; + cred.pid = -1; cred.uid = -1; cred.gid = -1; + + socklen_t len = sizeof(cred); + + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == -1) { + return getJniResult(env, logTitle, -1, errno, "getPeerCredNative(): Failed to get peer credentials for fd " + to_string(fd)); + } + + // Fill "com.termux.shared.net.socket.local.PeerCred" object. + // The pid, uid and gid will always be set based on ucred. + // The pname and cmdline will only be set if current process has access to "/proc/[pid]/cmdline" + // of peer process. Processes of other users/apps are not normally accessible. + jclass peerCredClazz = env->GetObjectClass(peerCred); + if (checkJniException(env)) return NULL; + if (!peerCredClazz) { + return getJniResult(env, logTitle, -1, errno, "getPeerCredNative(): Failed to get PeerCred class"); + } + + string error; + + error = setIntField(env, peerCred, peerCredClazz, "pid", cred.pid); + if (!error.empty()) { + if (error == JNI_EXCEPTION) return NULL; + return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error); + } + + error = setIntField(env, peerCred, peerCredClazz, "uid", cred.uid); + if (!error.empty()) { + if (error == JNI_EXCEPTION) return NULL; + return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error); + } + + error = setIntField(env, peerCred, peerCredClazz, "gid", cred.gid); + if (!error.empty()) { + if (error == JNI_EXCEPTION) return NULL; + return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error); + } + + string cmdline = get_process_cmdline(cred.pid); + if (!cmdline.empty()) { + error = setStringField(env, peerCred, peerCredClazz, "pname", get_process_name_from_cmdline(cmdline)); + if (!error.empty()) { + if (error == JNI_EXCEPTION) return NULL; + return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error); + } + + error = setStringField(env, peerCred, peerCredClazz, "cmdline", get_process_cmdline_spaced(cmdline)); + if (!error.empty()) { + if (error == JNI_EXCEPTION) return NULL; + return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error); + } + } + + // Return success since PeerCred was filled successfully + return getJniResult(env, logTitle); +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java b/termux/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java new file mode 100644 index 000000000..b9d9a9569 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java @@ -0,0 +1,544 @@ +package com.termux.shared.activities; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.itsaky.androidide.app.BaseIDEActivity; +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.databinding.ActivityReportBinding; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.interact.ShareUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.models.ReportInfo; +import com.termux.shared.termux.TermuxConstants; +import io.noties.markwon.Markwon; +import io.noties.markwon.recycler.MarkwonAdapter; +import io.noties.markwon.recycler.SimpleEntry; +import org.commonmark.node.FencedCodeBlock; +import org.jetbrains.annotations.NotNull; + +/** + * An activity to show reports in markdown format as per CommonMark spec based on config passed as + * {@link ReportInfo}. Add Following to `AndroidManifest.xml` to use in an app: + * {@code `` } and + * {@code `` } Receiver **must not** be `exported="true"`!!! + *

+ * Also make an incremental call to + * {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} in the app to cleanup cached + * files. + */ +public class ReportActivity extends BaseIDEActivity { + + private ActivityReportBinding binding; + + private static final String CLASS_NAME = ReportActivity.class.getCanonicalName(); + private static final String ACTION_DELETE_REPORT_INFO_OBJECT_FILE = + CLASS_NAME + ".ACTION_DELETE_REPORT_INFO_OBJECT_FILE"; + + private static final String EXTRA_REPORT_INFO_OBJECT = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT"; + private static final String EXTRA_REPORT_INFO_OBJECT_FILE_PATH = + CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT_FILE_PATH"; + + private static final String CACHE_DIR_BASENAME = "report_activity"; + private static final String CACHE_FILE_BASENAME_PREFIX = "report_info_"; + + public static final int REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE = 1000; + + public static final int ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES = 1000 * 1024; // 1MB + + private ReportInfo mReportInfo; + private String mReportInfoFilePath; + private String mReportActivityMarkdownString; + private Bundle mBundle; + + private static final String LOG_TAG = "ReportActivity"; + + @NonNull + @Override + protected View bindLayout() { + this.binding = ActivityReportBinding.inflate(getLayoutInflater()); + return binding.getRoot(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + Logger.logVerbose(LOG_TAG, "onCreate"); + super.onCreate(savedInstanceState); + + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + setSupportActionBar(toolbar); + } + + mBundle = null; + Intent intent = getIntent(); + if (intent != null) { + mBundle = intent.getExtras(); + } else if (savedInstanceState != null) { + mBundle = savedInstanceState; + } + + updateUI(); + + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + Logger.logVerbose(LOG_TAG, "onNewIntent"); + + setIntent(intent); + + if (intent != null) { + deleteReportInfoFile(this, mReportInfoFilePath); + mBundle = intent.getExtras(); + updateUI(); + } + } + + private void updateUI() { + + if (mBundle == null) { + finish(); + return; + } + + mReportInfo = null; + mReportInfoFilePath = null; + + if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) { + mReportInfoFilePath = mBundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH); + Logger.logVerbose(LOG_TAG, + ReportInfo.class.getSimpleName() + " serialized object will be read from file at path \"" + + mReportInfoFilePath + "\""); + if (mReportInfoFilePath != null) { + try { + FileUtils.ReadSerializableObjectResult result = FileUtils.readSerializableObjectFromFile( + ReportInfo.class.getSimpleName(), mReportInfoFilePath, ReportInfo.class, false); + if (result.error != null) { + Logger.logErrorExtended(LOG_TAG, result.error.toString()); + Logger.showToast(this, Error.getMinimalErrorString(result.error), true); + finish(); + return; + } else { + if (result.serializableObject != null) { + mReportInfo = (ReportInfo) result.serializableObject; + } + } + } catch (Exception e) { + Logger.logErrorAndShowToast(this, LOG_TAG, e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, + "Failure while getting " + ReportInfo.class.getSimpleName() + + " serialized object from file at path \"" + mReportInfoFilePath + "\"", e); + } + } + } else { + mReportInfo = (ReportInfo) mBundle.getSerializable(EXTRA_REPORT_INFO_OBJECT); + } + + if (mReportInfo == null) { + finish(); + return; + } + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + if (mReportInfo.reportTitle != null) { + actionBar.setTitle(mReportInfo.reportTitle); + } else { + actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report"); + } + } + + RecyclerView recyclerView = findViewById(R.id.recycler_view); + + final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this); + + final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot( + R.layout.markdown_adapter_node_default) + .include(FencedCodeBlock.class, + SimpleEntry.create(R.layout.markdown_adapter_node_code_block, R.id.code_text_view)) + .build(); + + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + recyclerView.setAdapter(adapter); + + generateReportActivityMarkdownString(); + adapter.setMarkdown(markwon, mReportActivityMarkdownString); + adapter.notifyDataSetChanged(); + } + + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) { + outState.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, mReportInfoFilePath); + } else { + outState.putSerializable(EXTRA_REPORT_INFO_OBJECT, mReportInfo); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + Logger.logVerbose(LOG_TAG, "onDestroy"); + binding = null; + deleteReportInfoFile(this, mReportInfoFilePath); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + final MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_report, menu); + + if (mReportInfo.reportSaveFilePath == null) { + MenuItem item = menu.findItem(R.id.menu_item_save_report_to_file); + if (item != null) { + item.setEnabled(false); + } + } + + return true; + } + + @Override + public void onBackPressed() { + // Remove activity from recents menu on back button press + finishAndRemoveTask(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + if (id == R.id.menu_item_share_report) { + ShareUtils.shareText(this, getString(R.string.title_report_text), + ReportInfo.getReportInfoMarkdownString(mReportInfo)); + } else if (id == R.id.menu_item_copy_report) { + ShareUtils.copyTextToClipboard(this, ReportInfo.getReportInfoMarkdownString(mReportInfo), + null); + } else if (id == R.id.menu_item_save_report_to_file) { + ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel, + mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo), + true, REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE); + } + + return false; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Logger.logInfo(LOG_TAG, "Storage permission granted by user on request."); + if (requestCode == REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE) { + ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel, + mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo), + true, -1); + } + } else { + Logger.logInfo(LOG_TAG, "Storage permission denied by user on request."); + } + } + + /** + * Generate the markdown {@link String} to be shown in {@link ReportActivity}. + */ + private void generateReportActivityMarkdownString() { + // We need to reduce chances of OutOfMemoryError happening so reduce new allocations and + // do not keep output of getReportInfoMarkdownString in memory + StringBuilder reportString = new StringBuilder(); + + if (mReportInfo.reportStringPrefix != null) { + reportString.append(mReportInfo.reportStringPrefix); + } + + String reportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo); + int reportMarkdownStringSize = reportMarkdownString.getBytes().length; + boolean truncated = false; + if (reportMarkdownStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) { + Logger.logVerbose(LOG_TAG, + mReportInfo.reportTitle + " report string size " + reportMarkdownStringSize + + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated"); + reportString.append(DataUtils.getTruncatedCommandOutput(reportMarkdownString, + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, true)); + truncated = true; + } else { + reportString.append(reportMarkdownString); + } + + // Free reference + reportMarkdownString = null; + + if (mReportInfo.reportStringSuffix != null) { + reportString.append(mReportInfo.reportStringSuffix); + } + + int reportStringSize = reportString.length(); + if (reportStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) { + // This may break markdown formatting + Logger.logVerbose(LOG_TAG, + mReportInfo.reportTitle + " report string total size " + reportStringSize + + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated"); + mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) + + DataUtils.getTruncatedCommandOutput(reportString.toString(), + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, false); + } else if (truncated) { + mReportActivityMarkdownString = + this.getString(R.string.msg_report_truncated) + reportString.toString(); + } else { + mReportActivityMarkdownString = reportString.toString(); + } + + } + + + public static class NewInstanceResult { + + /** + * An intent that can be used to start the {@link ReportActivity}. + */ + public Intent contentIntent; + /** + * An intent that can should be adding as the {@link android.app.Notification#deleteIntent} by a + * call to {@link android.app.PendingIntent#getBroadcast(Context, int, Intent, int)} so that + * {@link ReportActivityBroadcastReceiver} can do cleanup of + * {@link #EXTRA_REPORT_INFO_OBJECT_FILE_PATH}. + */ + public Intent deleteIntent; + + NewInstanceResult(Intent contentIntent, Intent deleteIntent) { + this.contentIntent = contentIntent; + this.deleteIntent = deleteIntent; + } + } + + /** + * Start the {@link ReportActivity}. + * + * @param context The {@link Context} for operations. + * @param reportInfo The {@link ReportInfo} containing info that needs to be displayed. + */ + public static void startReportActivity(@NonNull final Context context, + @NonNull ReportInfo reportInfo) { + NewInstanceResult result = newInstance(context, reportInfo); + if (result.contentIntent == null) { + return; + } + context.startActivity(result.contentIntent); + } + + /** + * Get content and delete intents for the {@link ReportActivity} that can be used to start it and + * do cleanup. + *

+ * If {@link ReportInfo} size is too large, then a TransactionTooLargeException will be thrown so + * its object may be saved to a file in the {@link Context#getCacheDir()}. Then when activity + * starts, its read back and the file is deleted in {@link #onDestroy()}. Note that files may + * still be left if {@link #onDestroy()} is not called or doesn't finish. A separate cleanup + * routine is implemented from that case by + * {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} which should be called + * incrementally or at app startup. + * + * @param context The {@link Context} for operations. + * @param reportInfo The {@link ReportInfo} containing info that needs to be displayed. + * @return Returns {@link NewInstanceResult}. + */ + @NonNull + public static NewInstanceResult newInstance(@NonNull final Context context, + @NonNull final ReportInfo reportInfo) { + + long size = DataUtils.getSerializedSize(reportInfo); + if (size > DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES) { + String reportInfoDirectoryPath = getReportInfoDirectoryPath(context); + String reportInfoFilePath = + reportInfoDirectoryPath + "/" + CACHE_FILE_BASENAME_PREFIX + reportInfo.reportTimestamp; + Logger.logVerbose(LOG_TAG, reportInfo.reportTitle + " " + ReportInfo.class.getSimpleName() + + " serialized object size " + size + " is greater than " + + DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES + " and it will be written to file at path \"" + + reportInfoFilePath + "\""); + Error error = FileUtils.writeSerializableObjectToFile(ReportInfo.class.getSimpleName(), + reportInfoFilePath, reportInfo); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + Logger.showToast(context, Error.getMinimalErrorString(error), true); + return new NewInstanceResult(null, null); + } + + return new NewInstanceResult(createContentIntent(context, null, reportInfoFilePath), + createDeleteIntent(context, reportInfoFilePath)); + } else { + return new NewInstanceResult(createContentIntent(context, reportInfo, null), + null); + } + } + + private static Intent createContentIntent(@NonNull final Context context, + final ReportInfo reportInfo, final String reportInfoFilePath) { + Intent intent = new Intent(context, ReportActivity.class); + Bundle bundle = new Bundle(); + + if (reportInfoFilePath != null) { + bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath); + } else { + bundle.putSerializable(EXTRA_REPORT_INFO_OBJECT, reportInfo); + } + + intent.putExtras(bundle); + + // Note that ReportActivity should have `documentLaunchMode="intoExisting"` set in `AndroidManifest.xml` + // which has equivalent behaviour to FLAG_ACTIVITY_NEW_DOCUMENT. + // FLAG_ACTIVITY_SINGLE_TOP must also be passed for onNewIntent to be called. + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + return intent; + } + + + private static Intent createDeleteIntent(@NonNull final Context context, + final String reportInfoFilePath) { + if (reportInfoFilePath == null) { + return null; + } + + Intent intent = new Intent(context, ReportActivityBroadcastReceiver.class); + intent.setAction(ACTION_DELETE_REPORT_INFO_OBJECT_FILE); + + Bundle bundle = new Bundle(); + bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath); + intent.putExtras(bundle); + + return intent; + } + + + @NotNull + private static String getReportInfoDirectoryPath(Context context) { + // Canonicalize to solve /data/data and /data/user/0 issues when comparing with reportInfoFilePath + return FileUtils.getCanonicalPath(context.getCacheDir().getAbsolutePath(), null) + "/" + + CACHE_DIR_BASENAME; + } + + private static void deleteReportInfoFile(Context context, String reportInfoFilePath) { + if (context == null || reportInfoFilePath == null) { + return; + } + + // Extra protection for mainly if someone set `exported="true"` for ReportActivityBroadcastReceiver + String reportInfoDirectoryPath = getReportInfoDirectoryPath(context); + reportInfoFilePath = FileUtils.getCanonicalPath(reportInfoFilePath, null); + if (!reportInfoFilePath.equals(reportInfoDirectoryPath) && reportInfoFilePath.startsWith( + reportInfoDirectoryPath + "/")) { + Logger.logVerbose(LOG_TAG, + "Deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + + reportInfoFilePath + "\""); + Error error = FileUtils.deleteRegularFile(ReportInfo.class.getSimpleName(), + reportInfoFilePath, true); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + } + } else { + Logger.logError(LOG_TAG, + "Not deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + + reportInfoFilePath + "\" since its not under \"" + reportInfoDirectoryPath + "\""); + } + } + + /** + * Delete {@link ReportInfo} serialized object files from cache older than x days. If a + * notification has still not been opened after x days that's using a PendingIntent to + * ReportActivity, then opening the notification will throw a file not found error, so choose days + * value appropriately or check if a notification is still active if tracking notification ids. + * The {@link Context} object passed must be of the same package with which + * {@link #newInstance(Context, ReportInfo)} was called since a call to + * {@link Context#getCacheDir()} is made. + * + * @param context The {@link Context} for operations. + * @param days The x amount of days before which files should be deleted. This must be + * `>=0`. + * @param isSynchronous If set to {@code true}, then the command will be executed in the caller + * thread and results returned synchronously. If set to {@code false}, then a + * new thread is started run the commands asynchronously in the background + * and control is returned to the caller thread. + * @return Returns the {@code error} if deleting was not successful, otherwise {@code null}. + */ + public static Error deleteReportInfoFilesOlderThanXDays(@NonNull final Context context, int days, + final boolean isSynchronous) { + if (isSynchronous) { + return deleteReportInfoFilesOlderThanXDaysInner(context, days); + } else { + new Thread() { + public void run() { + Error error = deleteReportInfoFilesOlderThanXDaysInner(context, days); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + } + } + }.start(); + return null; + } + } + + private static Error deleteReportInfoFilesOlderThanXDaysInner(@NonNull final Context context, + int days) { + // Only regular files are deleted and subdirectories are not checked + String reportInfoDirectoryPath = getReportInfoDirectoryPath(context); + Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + + " serialized object files under directory path \"" + reportInfoDirectoryPath + + "\" older than " + days + " days"); + return FileUtils.deleteFilesOlderThanXDays(ReportInfo.class.getSimpleName(), + reportInfoDirectoryPath, null, days, true, FileType.REGULAR.getValue()); + } + + + /** + * The {@link BroadcastReceiver} for {@link ReportActivity} that currently does cleanup when + * {@link android.app.Notification#deleteIntent} is called. It must be registered in + * `AndroidManifest.xml`. + */ + public static class ReportActivityBroadcastReceiver extends BroadcastReceiver { + + private static final String LOG_TAG = "ReportActivityBroadcastReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + + String action = intent.getAction(); + Logger.logVerbose(LOG_TAG, "onReceive: \"" + action + "\" action"); + + if (ACTION_DELETE_REPORT_INFO_OBJECT_FILE.equals(action)) { + Bundle bundle = intent.getExtras(); + if (bundle == null) { + return; + } + if (bundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) { + deleteReportInfoFile(context, bundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)); + } + } + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java b/termux/termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java new file mode 100644 index 000000000..7cdf6ab0e --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java @@ -0,0 +1,278 @@ +package com.termux.shared.activities; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputFilter; +import android.text.TextWatcher; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import com.termux.shared.interact.ShareUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.R; +import com.termux.shared.models.TextIOInfo; +import com.termux.shared.view.KeyboardUtils; + +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; + +/** + * An activity to edit or view text based on config passed as {@link TextIOInfo}. + * + * Add Following to `AndroidManifest.xml` to use in an app: + * + * {@code ` ` } + */ +public class TextIOActivity extends AppCompatActivity { + + private static final String CLASS_NAME = ReportActivity.class.getCanonicalName(); + public static final String EXTRA_TEXT_IO_INFO_OBJECT = CLASS_NAME + ".EXTRA_TEXT_IO_INFO_OBJECT"; + + private TextView mTextIOLabel; + private View mTextIOLabelSeparator; + private EditText mTextIOText; + private HorizontalScrollView mTextIOHorizontalScrollView; + private LinearLayout mTextIOTextLinearLayout; + private TextView mTextIOTextCharacterUsage; + + private TextIOInfo mTextIOInfo; + private Bundle mBundle; + + private static final String LOG_TAG = "TextIOActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Logger.logVerbose(LOG_TAG, "onCreate"); + + setContentView(R.layout.activity_text_io); + + mTextIOLabel = findViewById(R.id.text_io_label); + mTextIOLabelSeparator = findViewById(R.id.text_io_label_separator); + mTextIOText = findViewById(R.id.text_io_text); + mTextIOHorizontalScrollView = findViewById(R.id.text_io_horizontal_scroll_view); + mTextIOTextLinearLayout = findViewById(R.id.text_io_text_linear_layout); + mTextIOTextCharacterUsage = findViewById(R.id.text_io_text_character_usage); + + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + setSupportActionBar(toolbar); + } + + mBundle = null; + Intent intent = getIntent(); + if (intent != null) + mBundle = intent.getExtras(); + else if (savedInstanceState != null) + mBundle = savedInstanceState; + + updateUI(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + Logger.logVerbose(LOG_TAG, "onNewIntent"); + + // Views must be re-created since different configs for isEditingTextDisabled() and + // isHorizontallyScrollable() will not work or at least reliably + finish(); + startActivity(intent); + } + + @SuppressLint("ClickableViewAccessibility") + private void updateUI() { + if (mBundle == null) { + finish(); return; + } + + mTextIOInfo = (TextIOInfo) mBundle.getSerializable(EXTRA_TEXT_IO_INFO_OBJECT); + if (mTextIOInfo == null) { + finish(); return; + } + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + if (mTextIOInfo.getTitle() != null) + actionBar.setTitle(mTextIOInfo.getTitle()); + else + actionBar.setTitle("Text Input"); + + if (mTextIOInfo.shouldShowBackButtonInActionBar()) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + } + + mTextIOLabel.setVisibility(View.GONE); + mTextIOLabelSeparator.setVisibility(View.GONE); + if (mTextIOInfo.isLabelEnabled()) { + mTextIOLabel.setVisibility(View.VISIBLE); + mTextIOLabelSeparator.setVisibility(View.VISIBLE); + mTextIOLabel.setText(mTextIOInfo.getLabel()); + mTextIOLabel.setFilters(new InputFilter[] { new InputFilter.LengthFilter(TextIOInfo.LABEL_SIZE_LIMIT_IN_BYTES) }); + mTextIOLabel.setTextSize(mTextIOInfo.getLabelSize()); + mTextIOLabel.setTextColor(mTextIOInfo.getLabelColor()); + mTextIOLabel.setTypeface(Typeface.create(mTextIOInfo.getLabelTypeFaceFamily(), mTextIOInfo.getLabelTypeFaceStyle())); + } + + + if (mTextIOInfo.isHorizontallyScrollable()) { + mTextIOHorizontalScrollView.setEnabled(true); + mTextIOText.setHorizontallyScrolling(true); + } else { + // Remove mTextIOHorizontalScrollView and add mTextIOText in its place + ViewGroup parent = (ViewGroup) mTextIOHorizontalScrollView.getParent(); + if (parent != null && parent.indexOfChild(mTextIOText) < 0) { + ViewGroup.LayoutParams params = mTextIOHorizontalScrollView.getLayoutParams(); + int index = parent.indexOfChild(mTextIOHorizontalScrollView); + mTextIOTextLinearLayout.removeAllViews(); + mTextIOHorizontalScrollView.removeAllViews(); + parent.removeView(mTextIOHorizontalScrollView); + parent.addView(mTextIOText, index, params); + mTextIOText.setHorizontallyScrolling(false); + } + } + + mTextIOText.setText(mTextIOInfo.getText()); + mTextIOText.setFilters(new InputFilter[] { new InputFilter.LengthFilter(mTextIOInfo.getTextLengthLimit()) }); + mTextIOText.setTextSize(mTextIOInfo.getTextSize()); + mTextIOText.setTextColor(mTextIOInfo.getTextColor()); + mTextIOText.setTypeface(Typeface.create(mTextIOInfo.getTextTypeFaceFamily(), mTextIOInfo.getTextTypeFaceStyle())); + + // setTextIsSelectable must be called after changing KeyListener to regain focusability and selectivity + if (mTextIOInfo.isEditingTextDisabled()) { + mTextIOText.setCursorVisible(false); + mTextIOText.setKeyListener(null); + mTextIOText.setTextIsSelectable(true); + } + + if (mTextIOInfo.shouldShowTextCharacterUsage()) { + mTextIOTextCharacterUsage.setVisibility(View.VISIBLE); + updateTextIOTextCharacterUsage(mTextIOInfo.getText()); + + mTextIOText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + @Override + public void afterTextChanged(Editable editable) { + if (editable != null) + updateTextIOTextCharacterUsage(editable.toString()); + } + }); + } else { + mTextIOTextCharacterUsage.setVisibility(View.GONE); + mTextIOText.addTextChangedListener(null); + } + } + + private void updateTextIOInfoText() { + if (mTextIOText != null) + mTextIOInfo.setText(mTextIOText.getText().toString()); + } + + private void updateTextIOTextCharacterUsage(String text) { + if (text == null) text = ""; + if (mTextIOTextCharacterUsage != null) + mTextIOTextCharacterUsage.setText(String.format(Locale.getDefault(), "%1$d/%2$d", text.length(), mTextIOInfo.getTextLengthLimit())); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + updateTextIOInfoText(); + outState.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, mTextIOInfo); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + final MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_text_io, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + String text = ""; + if (mTextIOText != null) + text = mTextIOText.getText().toString(); + + int id = item.getItemId(); + if (id == android.R.id.home) { + confirm(); + } if (id == R.id.menu_item_cancel) { + cancel(); + } else if (id == R.id.menu_item_share_text) { + ShareUtils.shareText(this, mTextIOInfo.getTitle(), text); + } else if (id == R.id.menu_item_copy_text) { + ShareUtils.copyTextToClipboard(this, text, null); + } + + return false; + } + + @Override + public void onBackPressed() { + confirm(); + } + + /** Confirm current text and send it back to calling {@link Activity}. */ + private void confirm() { + updateTextIOInfoText(); + KeyboardUtils.hideSoftKeyboard(this, mTextIOText); + setResult(Activity.RESULT_OK, getResultIntent()); + finish(); + } + + /** Cancel current text and notify calling {@link Activity}. */ + private void cancel() { + KeyboardUtils.hideSoftKeyboard(this, mTextIOText); + setResult(Activity.RESULT_CANCELED, getResultIntent()); + finish(); + } + + @NotNull + private Intent getResultIntent() { + Intent intent = new Intent(); + Bundle bundle = new Bundle(); + bundle.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, mTextIOInfo); + intent.putExtras(bundle); + return intent; + } + + /** + * Get the {@link Intent} that can be used to start the {@link TextIOActivity}. + * + * @param context The {@link Context} for operations. + * @param textIOInfo The {@link TextIOInfo} containing info for the edit text. + */ + public static Intent newInstance(@NonNull final Context context, @NonNull final TextIOInfo textIOInfo) { + Intent intent = new Intent(context, TextIOActivity.class); + Bundle bundle = new Bundle(); + bundle.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, textIOInfo); + intent.putExtras(bundle); + return intent; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java new file mode 100644 index 000000000..96a0e9ce0 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java @@ -0,0 +1,20 @@ +package com.termux.shared.activity; + +import com.termux.shared.errors.Errno; + +public class ActivityErrno extends Errno { + + public static final String TYPE = "Activity Error"; + + + /* Errors for starting activities (100-150) */ + public static final Errno ERRNO_START_ACTIVITY_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Failed to start \"%1$s\" activity.\nException: %2$s"); + public static final Errno ERRNO_START_ACTIVITY_FOR_RESULT_FAILED_WITH_EXCEPTION = new Errno(TYPE, 101, "Failed to start \"%1$s\" activity for result.\nException: %2$s"); + public static final Errno ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT = new Errno(TYPE, 102, "Cannot start \"%1$s\" activity with null Context"); + + + ActivityErrno(final String type, final int code, final String message) { + super(type, code, message); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java new file mode 100644 index 000000000..0ff28126a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java @@ -0,0 +1,137 @@ +package com.termux.shared.activity; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.termux.shared.errors.Error; +import com.termux.shared.errors.FunctionErrno; + + +public class ActivityUtils { + + private static final String LOG_TAG = "ActivityUtils"; + + /** + * Wrapper for {@link #startActivity(Context, Intent, boolean, boolean)}. + */ + public static Error startActivity(@NonNull Context context, @NonNull Intent intent) { + return startActivity(context, intent, true, true); + } + + /** + * Start an {@link Activity}. + * + * @param context The context for operations. + * @param intent The {@link Intent} to send to start the activity. + * @param logErrorMessage If an error message should be logged if failed to start activity. + * @param showErrorMessage If an error message toast should be shown if failed to start activity + * in addition to logging a message. The {@code context} must not be + * {@code null}. + * @return Returns the {@code error} if starting activity was not successful, otherwise {@code null}. + */ + public static Error startActivity(Context context, @NonNull Intent intent, + boolean logErrorMessage, boolean showErrorMessage) { + Error error; + String activityName = intent.getComponent() != null ? intent.getComponent().getClassName() : "Unknown"; + + if (context == null) { + error = ActivityErrno.ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT.getError(activityName); + if (logErrorMessage) + error.logErrorAndShowToast(null, LOG_TAG); + return error; + } + + try { + context.startActivity(intent); + } catch (Exception e) { + error = ActivityErrno.ERRNO_START_ACTIVITY_FAILED_WITH_EXCEPTION.getError(e, activityName, e.getMessage()); + if (logErrorMessage) + error.logErrorAndShowToast(showErrorMessage ? context : null, LOG_TAG); + return error; + } + + return null; + } + + + + /** + * Wrapper for {@link #startActivityForResult(Context, int, Intent, boolean, boolean, ActivityResultLauncher)}. + */ + public static Error startActivityForResult(Context context, int requestCode, @NonNull Intent intent) { + return startActivityForResult(context, requestCode, intent, true, true, null); + } + + /** + * Wrapper for {@link #startActivityForResult(Context, int, Intent, boolean, boolean, ActivityResultLauncher)}. + */ + public static Error startActivityForResult(Context context, int requestCode, @NonNull Intent intent, + boolean logErrorMessage, boolean showErrorMessage) { + return startActivityForResult(context, requestCode, intent, logErrorMessage, showErrorMessage, null); + } + + /** + * Start an {@link Activity} for result. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity}. It is ignored if {@code activityResultLauncher} + * is not {@code null}. + * @param requestCode The request code to use while sending intent. This must be >= 0, otherwise + * exception will be raised. This is ignored if {@code activityResultLauncher} + * is {@code null}. + * @param intent The {@link Intent} to send to start the activity. + * @param logErrorMessage If an error message should be logged if failed to start activity. + * @param showErrorMessage If an error message toast should be shown if failed to start activity + * in addition to logging a message. The {@code context} must not be + * {@code null}. + * @param activityResultLauncher The {@link ActivityResultLauncher} to use for start the + * activity. If this is {@code null}, then + * {@link Activity#startActivityForResult(Intent, int)} will be + * used instead. + * Note that later is deprecated. + * @return Returns the {@code error} if starting activity was not successful, otherwise {@code null}. + */ + public static Error startActivityForResult(Context context, int requestCode, @NonNull Intent intent, + boolean logErrorMessage, boolean showErrorMessage, + @Nullable ActivityResultLauncher activityResultLauncher) { + Error error; + String activityName = intent.getComponent() != null ? intent.getComponent().getClassName() : "Unknown"; + try { + if (activityResultLauncher != null) { + activityResultLauncher.launch(intent); + } else { + if (context == null) { + error = ActivityErrno.ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT.getError(activityName); + if (logErrorMessage) + error.logErrorAndShowToast(null, LOG_TAG); + return error; + } + + if (context instanceof AppCompatActivity) + ((AppCompatActivity) context).startActivityForResult(intent, requestCode); + else if (context instanceof Activity) + ((Activity) context).startActivityForResult(intent, requestCode); + else { + error = FunctionErrno.ERRNO_PARAMETER_NOT_INSTANCE_OF.getError("context", "startActivityForResult", "Activity or AppCompatActivity"); + if (logErrorMessage) + error.logErrorAndShowToast(showErrorMessage ? context : null, LOG_TAG); + return error; + } + } + } catch (Exception e) { + error = ActivityErrno.ERRNO_START_ACTIVITY_FOR_RESULT_FAILED_WITH_EXCEPTION.getError(e, activityName, e.getMessage()); + if (logErrorMessage) + error.logErrorAndShowToast(showErrorMessage ? context : null, LOG_TAG); + return error; + } + + return null; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java new file mode 100644 index 000000000..0666d58ee --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java @@ -0,0 +1,120 @@ +package com.termux.shared.activity.media; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.widget.Toolbar; + +import com.termux.shared.logger.Logger; +import com.termux.shared.theme.NightMode; + +public class AppCompatActivityUtils { + + private static final String LOG_TAG = "AppCompatActivityUtils"; + + + /** Set activity night mode. + * + * @param activity The host {@link AppCompatActivity}. + * @param name The {@link String} representing the name for a {@link NightMode}. + * @param local If set to {@code true}, then a call to {@link AppCompatDelegate#setLocalNightMode(int)} + * will be made, otherwise to {@link AppCompatDelegate#setDefaultNightMode(int)}. + */ + public static void setNightMode(AppCompatActivity activity, String name, boolean local) { + if (name == null) return; + NightMode nightMode = NightMode.modeOf(name); + if (nightMode != null) { + if (local) { + if (activity != null) { + activity.getDelegate().setLocalNightMode(nightMode.getMode()); + } + } else { + AppCompatDelegate.setDefaultNightMode(nightMode.getMode()); + } + } + + } + + /** Set activity toolbar. + * + * @param activity The host {@link AppCompatActivity}. + * @param id The toolbar resource id. + */ + public static void setToolbar(@NonNull AppCompatActivity activity, @IdRes int id) { + Toolbar toolbar = activity.findViewById(id); + if (toolbar != null) + activity.setSupportActionBar(toolbar); + } + + /** Set activity toolbar title. + * + * @param activity The host {@link AppCompatActivity}. + * @param id The toolbar resource id. + * @param title The toolbar title {@link String}. + * @param titleAppearance The toolbar title TextAppearance resource id. + */ + public static void setToolbarTitle(@NonNull AppCompatActivity activity, @IdRes int id, + String title, @StyleRes int titleAppearance) { + Toolbar toolbar = activity.findViewById(id); + if (toolbar != null) { + //toolbar.setTitle(title); // Does not work + final ActionBar actionBar = activity.getSupportActionBar(); + if (actionBar != null) + actionBar.setTitle(title); + + try { + if (titleAppearance != 0) + toolbar.setTitleTextAppearance(activity, titleAppearance); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to set toolbar title appearance to style resource id " + titleAppearance, e); + } + + + } + } + + /** Set activity toolbar subtitle. + * + * @param activity The host {@link AppCompatActivity}. + * @param id The toolbar resource id. + * @param subtitle The toolbar subtitle {@link String}. + * @param subtitleAppearance The toolbar subtitle TextAppearance resource id. + */ + public static void setToolbarSubtitle(@NonNull AppCompatActivity activity, @IdRes int id, + String subtitle, @StyleRes int subtitleAppearance) { + Toolbar toolbar = activity.findViewById(id); + if (toolbar != null) { + toolbar.setSubtitle(subtitle); + try { + if (subtitleAppearance != 0) + toolbar.setSubtitleTextAppearance(activity, subtitleAppearance); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to set toolbar subtitle appearance to style resource id " + subtitleAppearance, e); + } + } + } + + + /** Set whether back button should be shown in activity toolbar. + * + * @param activity The host {@link AppCompatActivity}. + * @param showBackButtonInActionBar Set to {@code true} to enable and {@code false} to disable. + */ + public static void setShowBackButtonInActionBar(@NonNull AppCompatActivity activity, + boolean showBackButtonInActionBar) { + final ActionBar actionBar = activity.getSupportActionBar(); + if (actionBar != null) { + if (showBackButtonInActionBar) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } else { + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setDisplayShowHomeEnabled(false); + } + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java new file mode 100644 index 000000000..bca47578e --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java @@ -0,0 +1,270 @@ +package com.termux.shared.android; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.os.Build; + +import androidx.annotation.NonNull; + +import com.google.common.base.Joiner; +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Properties; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class AndroidUtils { + + /** + * Get a markdown {@link String} for the app info for the package associated with the {@code context}. + * This will contain additional info about the app in addition to the one returned by + * {@link #getAppInfoMarkdownString(Context, String)}, which will be got via the {@code context} + * object. + * + * @param context The context for operations for the package. + * @return Returns the markdown {@link String}. + */ + public static String getAppInfoMarkdownString(@NonNull final Context context) { + StringBuilder markdownString = new StringBuilder(); + + String appInfo = getAppInfoMarkdownString(context, context.getPackageName()); + if (appInfo == null) + return markdownString.toString(); + else + markdownString.append(appInfo); + + String filesDir = context.getFilesDir().getAbsolutePath(); + if (!filesDir.equals("/data/user/0/" + context.getPackageName() + "/files") && + !filesDir.equals("/data/data/" + context.getPackageName() + "/files")) + AndroidUtils.appendPropertyToMarkdown(markdownString,"FILES_DIR", filesDir); + + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Long userId = PackageUtils.getUserIdForPackage(context); + if (userId == null || userId != 0) + AndroidUtils.appendPropertyToMarkdown(markdownString, "USER_ID", userId); + } + + AndroidUtils.appendPropertyToMarkdownIfSet(markdownString,"PROFILE_OWNER", PackageUtils.getProfileOwnerPackageNameForUser(context)); + + return markdownString.toString(); + } + + /** + * Get a markdown {@link String} for the app info for the {@code packageName}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @return Returns the markdown {@link String}. + */ + public static String getAppInfoMarkdownString(@NonNull final Context context, @NonNull final String packageName) { + PackageInfo packageInfo = PackageUtils.getPackageInfoForPackage(context, packageName); + if (packageInfo == null) return null; + ApplicationInfo applicationInfo = PackageUtils.getApplicationInfoForPackage(context, packageName); + if (applicationInfo == null) return null; + + StringBuilder markdownString = new StringBuilder(); + + AndroidUtils.appendPropertyToMarkdown(markdownString,"APP_NAME", PackageUtils.getAppNameForPackage(context, applicationInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"PACKAGE_NAME", PackageUtils.getPackageNameForPackage(applicationInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"VERSION_NAME", PackageUtils.getVersionNameForPackage(packageInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"VERSION_CODE", PackageUtils.getVersionCodeForPackage(packageInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"UID", PackageUtils.getUidForPackage(applicationInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"TARGET_SDK", PackageUtils.getTargetSDKForPackage(applicationInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"IS_DEBUGGABLE_BUILD", PackageUtils.isAppForPackageADebuggableBuild(applicationInfo)); + + if (PackageUtils.isAppInstalledOnExternalStorage(applicationInfo)) { + AndroidUtils.appendPropertyToMarkdown(markdownString,"APK_PATH", PackageUtils.getBaseAPKPathForPackage(applicationInfo)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"IS_INSTALLED_ON_EXTERNAL_STORAGE", true); + } + + AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_PROCESS_CONTEXT", SELinuxUtils.getContext()); + AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_FILE_CONTEXT", SELinuxUtils.getFileContext(context.getFilesDir().getAbsolutePath())); + + String seInfoUser = PackageUtils.getApplicationInfoSeInfoUserForPackage(applicationInfo); + AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_INFO", PackageUtils.getApplicationInfoSeInfoForPackage(applicationInfo) + + (DataUtils.isNullOrEmpty(seInfoUser) ? "" : seInfoUser)); + + return markdownString.toString(); + } + + public static String getDeviceInfoMarkdownString(@NonNull final Context context) { + return getDeviceInfoMarkdownString(context, false); + } + + /** + * Get a markdown {@link String} for the device info. + * + * @param context The context for operations. + * @param addPhantomProcessesInfo If phantom processes info should be added on Android >= 12. + * @return Returns the markdown {@link String}. + */ + public static String getDeviceInfoMarkdownString(@NonNull final Context context, boolean addPhantomProcessesInfo) { + // Some properties cannot be read with {@link System#getProperty(String)} but can be read + // directly by running getprop command + Properties systemProperties = getSystemProperties(); + + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## Device Info"); + + markdownString.append("\n\n### Software\n"); + appendPropertyToMarkdown(markdownString,"OS_VERSION", getSystemPropertyWithAndroidAPI("os.version")); + appendPropertyToMarkdown(markdownString, "SDK_INT", Build.VERSION.SDK_INT); + // If its a release version + if ("REL".equals(Build.VERSION.CODENAME)) + appendPropertyToMarkdown(markdownString, "RELEASE", Build.VERSION.RELEASE); + else + appendPropertyToMarkdown(markdownString, "CODENAME", Build.VERSION.CODENAME); + appendPropertyToMarkdown(markdownString, "ID", Build.ID); + appendPropertyToMarkdown(markdownString, "DISPLAY", Build.DISPLAY); + appendPropertyToMarkdown(markdownString, "INCREMENTAL", Build.VERSION.INCREMENTAL); + appendPropertyToMarkdownIfSet(markdownString, "SECURITY_PATCH", systemProperties.getProperty("ro.build.version.security_patch")); + appendPropertyToMarkdownIfSet(markdownString, "IS_DEBUGGABLE", systemProperties.getProperty("ro.debuggable")); + appendPropertyToMarkdownIfSet(markdownString, "IS_EMULATOR", systemProperties.getProperty("ro.boot.qemu")); + appendPropertyToMarkdownIfSet(markdownString, "IS_TREBLE_ENABLED", systemProperties.getProperty("ro.treble.enabled")); + appendPropertyToMarkdown(markdownString, "TYPE", Build.TYPE); + appendPropertyToMarkdown(markdownString, "TAGS", Build.TAGS); + + // If on Android >= 12 + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { + Integer maxPhantomProcesses = PhantomProcessUtils.getActivityManagerMaxPhantomProcesses(context); + if (maxPhantomProcesses != null) + appendPropertyToMarkdown(markdownString, "MAX_PHANTOM_PROCESSES", maxPhantomProcesses); + else + appendLiteralPropertyToMarkdown(markdownString, "MAX_PHANTOM_PROCESSES", "- (*" + context.getString(R.string.msg_requires_dump_and_package_usage_stats_permissions) + "*)"); + + appendPropertyToMarkdown(markdownString, "MONITOR_PHANTOM_PROCS", PhantomProcessUtils.getFeatureFlagMonitorPhantomProcsValueString(context).getName()); + appendPropertyToMarkdown(markdownString, "DEVICE_CONFIG_SYNC_DISABLED", PhantomProcessUtils.getSettingsGlobalDeviceConfigSyncDisabled(context)); + } + + markdownString.append("\n\n### Hardware\n"); + appendPropertyToMarkdown(markdownString, "MANUFACTURER", Build.MANUFACTURER); + appendPropertyToMarkdown(markdownString, "BRAND", Build.BRAND); + appendPropertyToMarkdown(markdownString, "MODEL", Build.MODEL); + appendPropertyToMarkdown(markdownString, "PRODUCT", Build.PRODUCT); + appendPropertyToMarkdown(markdownString, "BOARD", Build.BOARD); + appendPropertyToMarkdown(markdownString, "HARDWARE", Build.HARDWARE); + appendPropertyToMarkdown(markdownString, "DEVICE", Build.DEVICE); + appendPropertyToMarkdown(markdownString, "SUPPORTED_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_ABIS)); + + markdownString.append("\n##\n"); + + return markdownString.toString(); + } + + + + public static Properties getSystemProperties() { + Properties systemProperties = new Properties(); + + // getprop commands returns values in the format `[key]: [value]` + // Regex matches string starting with a literal `[`, + // followed by one or more characters that do not match a closing square bracket as the key, + // followed by a literal `]: [`, + // followed by one or more characters as the value, + // followed by string ending with literal `]` + // multiline values will be ignored + Pattern propertiesPattern = Pattern.compile("^\\[([^]]+)]: \\[(.+)]$"); + + try { + Process process = new ProcessBuilder() + .command("/system/bin/getprop") + .redirectErrorStream(true) + .start(); + + InputStream inputStream = process.getInputStream(); + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + String line, key, value; + + while ((line = bufferedReader.readLine()) != null) { + Matcher matcher = propertiesPattern.matcher(line); + if (matcher.matches()) { + key = matcher.group(1); + value = matcher.group(2); + if (key != null && value != null && !key.isEmpty() && !value.isEmpty()) + systemProperties.put(key, value); + } + } + + bufferedReader.close(); + process.destroy(); + + } catch (IOException e) { + Logger.logStackTraceWithMessage("Failed to get run \"/system/bin/getprop\" to get system properties.", e); + } + + //for (String key : systemProperties.stringPropertyNames()) { + // Logger.logVerbose(key + ": " + systemProperties.get(key)); + //} + + return systemProperties; + } + + public static String getSystemPropertyWithAndroidAPI(@NonNull String property) { + try { + return System.getProperty(property); + } catch (Exception e) { + Logger.logVerbose("Failed to get system property \"" + property + "\":" + e.getMessage()); + return null; + } + } + + public static void appendPropertyToMarkdownIfSet(StringBuilder markdownString, String label, Object value) { + if (value == null) return; + if (value instanceof String && (((String) value).isEmpty()) || "REL".equals(value)) return; + markdownString.append("\n").append(getPropertyMarkdown(label, value)); + } + + public static void appendPropertyToMarkdown(StringBuilder markdownString, String label, Object value) { + markdownString.append("\n").append(getPropertyMarkdown(label, value)); + } + + public static String getPropertyMarkdown(String label, Object value) { + return MarkdownUtils.getSingleLineMarkdownStringEntry(label, value, "-"); + } + + public static void appendLiteralPropertyToMarkdown(StringBuilder markdownString, String label, Object value) { + markdownString.append("\n").append(getLiteralPropertyMarkdown(label, value)); + } + + public static String getLiteralPropertyMarkdown(String label, Object value) { + return MarkdownUtils.getLiteralSingleLineMarkdownStringEntry(label, value, "-"); + } + + + + public static String getCurrentTimeStamp() { + @SuppressLint("SimpleDateFormat") + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + return df.format(new Date()); + } + + public static String getCurrentMilliSecondUTCTimeStamp() { + @SuppressLint("SimpleDateFormat") + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS z"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + return df.format(new Date()); + } + + public static String getCurrentMilliSecondLocalTimeStamp() { + @SuppressLint("SimpleDateFormat") + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd_HH.mm.ss.SSS"); + df.setTimeZone(TimeZone.getDefault()); + return df.format(new Date()); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java new file mode 100644 index 000000000..14e6cd5b3 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java @@ -0,0 +1,169 @@ +package com.termux.shared.android; + +import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; + +import java.lang.reflect.Method; +import java.util.Map; + +/** + * Utils for Developer Options -> Feature Flags. The page won't show in user/production builds and + * is only shown in userdebug builds. + * https://cs.android.com/android/_/android/platform/frameworks/base/+/09dcdad5ebc159861920f090e07da60fac71ac0a:core/java/android/util/FeatureFlagUtils.java + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r31:packages/apps/Settings/src/com/android/settings/development/featureflags/FeatureFlagsPreferenceController.java;l=42 + * + * The feature flags value can be modified in two ways. + * + * 1. sysprops with `setprop` command with root. Will be unset by default. + * Set value: `setprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs false` + * Get value: `getprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs` + * Unset value: `setprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs ""` + * Running `setprop` command requires root and even adb `shell` user cannot modify the values + * since selinux will not allow it by default. Some props like `settings_dynamic_system` can be + * set since they are exempted for `shell` in sepolicy. + * + * init: Unable to set property 'persist.sys.fflag.override.settings_enable_monitor_phantom_procs' from uid:2000 gid:2000 pid:9576: SELinux permission check failed + * [ 1034.877067] type=1107 audit(1644436809.637:34): uid=0 auid=4294967295 ses=4294967295 subj=u:r:init:s0 msg='avc: denied { set } for property=persist.sys.fflag.override.settings_enable_monitor_phantom_procs pid=9576 uid=2000 gid=2000 scontext=u:r:shell:s0 tcontext=u:object_r:system_prop:s0 tclass=property_service permissive=0' + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/sepolicy/private/property_contexts;l=71 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/sepolicy/private/shell.te;l=149 + * + * 2. settings global list with adb or root. Will be unset by default. This takes precedence over + * sysprop value since `FeatureFlagUtils.isEnabled()` + * checks its value first. Override precedence: Settings.Global -> sys.fflag.override.* -> static list. + * Set value: `adb shell settings put global settings_enable_monitor_phantom_procs false` + * Get value: adb shell settings get global settings_enable_monitor_phantom_procs` + * Unset value: `adb shell settings delete global settings_enable_monitor_phantom_procs` + * + * https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-12.0.0_r31:core/java/android/util/FeatureFlagUtils.java;l=113 + * + * The feature flag values can be modified in user builds with settings global list, but since the + * developer options feature flags page is not shown and considering that getprop values for features + * will be unset by default and settings global list will not be set either and there is no shell API, + * it will require an android app process to check if feature is supported on a device and what its + * default value is with reflection after bypassing hidden api restrictions since {@link #FEATURE_FLAGS_CLASS} + * is annotated as `@hide`. + */ +public class FeatureFlagUtils { + + public enum FeatureFlagValue { + + /** Unknown like due to exception raised while getting value. */ + UNKNOWN(""), + + /** Flag is unsupported on current android build. */ + UNSUPPORTED(""), + + /** Flag is enabled. */ + TRUE("true"), + + /** Flag is not enabled. */ + FALSE("false"); + + private final String name; + + FeatureFlagValue(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + + } + + public static final String FEATURE_FLAGS_CLASS = "android.util.FeatureFlagUtils"; + + private static final String LOG_TAG = "FeatureFlagUtils"; + + /** + * Get all feature flags in their raw form. + */ + @SuppressWarnings("unchecked") + public static Map getAllFeatureFlags() { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(FEATURE_FLAGS_CLASS); + Method getAllFeatureFlagsMethod = ReflectionUtils.getDeclaredMethod(clazz, "getAllFeatureFlags"); + if (getAllFeatureFlagsMethod == null) return null; + return (Map) ReflectionUtils.invokeMethod(getAllFeatureFlagsMethod, null).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get all feature flags", e); + return null; + } + } + + /** + * Check if a feature flag exists. + * + * @return Returns {@code true} if flag exists, otherwise {@code false}. This will be + * {@code null} if an exception is raised. + */ + @Nullable + public static Boolean featureFlagExists(@NonNull String feature) { + Map featureFlags = getAllFeatureFlags(); + if (featureFlags == null) return null; + return featureFlags.containsKey(feature); + } + + /** + * Get {@link FeatureFlagValue} for a feature. + * + * @param context The {@link Context} for operations. + * @param feature The {@link String} name for feature. + * @return Returns {@link FeatureFlagValue}. + */ + @NonNull + public static FeatureFlagValue getFeatureFlagValueString(@NonNull Context context, @NonNull String feature) { + Boolean featureFlagExists = featureFlagExists(feature); + if (featureFlagExists == null) { + Logger.logError(LOG_TAG, "Failed to get feature flags \"" + feature + "\" value"); + return FeatureFlagValue.UNKNOWN; + } else if (!featureFlagExists) { + return FeatureFlagValue.UNSUPPORTED; + } + + Boolean featureFlagValue = isFeatureEnabled(context, feature); + if (featureFlagValue == null) { + Logger.logError(LOG_TAG, "Failed to get feature flags \"" + feature + "\" value"); + return FeatureFlagValue.UNKNOWN; + } else { + return featureFlagValue ? FeatureFlagValue.TRUE : FeatureFlagValue.FALSE; + } + } + + /** + * Check if a feature flag exists. + * + * @param context The {@link Context} for operations. + * @param feature The {@link String} name for feature. + * @return Returns {@code true} if flag exists, otherwise {@code false}. This will be + * {@code null} if an exception is raised. + */ + @Nullable + public static Boolean isFeatureEnabled(@NonNull Context context, @NonNull String feature) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(FEATURE_FLAGS_CLASS); + Method isFeatureEnabledMethod = ReflectionUtils.getDeclaredMethod(clazz, "isEnabled", Context.class, String.class); + if (isFeatureEnabledMethod == null) { + Logger.logError(LOG_TAG, "Failed to check if feature flag \"" + feature + "\" is enabled"); + return null; + } + + return (boolean) ReflectionUtils.invokeMethod(isFeatureEnabledMethod, null, context, feature).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to check if feature flag \"" + feature + "\" is enabled", e); + return null; + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java new file mode 100644 index 000000000..904c75cb5 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java @@ -0,0 +1,830 @@ +package com.termux.shared.android; + +import android.app.ActivityManager; +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.UserHandle; +import android.os.UserManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.interact.MessageDialogUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; + +import java.lang.reflect.Field; +import java.security.MessageDigest; +import java.util.List; + +public class PackageUtils { + + private static final String LOG_TAG = "PackageUtils"; + + /** + * Get the {@link Context} for the package name with {@link Context#CONTEXT_RESTRICTED} flags. + * + * @param context The {@link Context} to use to get the {@link Context} of the {@code packageName}. + * @param packageName The package name whose {@link Context} to get. + * @return Returns the {@link Context}. This will {@code null} if an exception is raised. + */ + @Nullable + public static Context getContextForPackage(@NonNull final Context context, String packageName) { + return getContextForPackage(context, packageName, Context.CONTEXT_RESTRICTED); + } + + /** + * Get the {@link Context} for the package name. + * + * @param context The {@link Context} to use to get the {@link Context} of the {@code packageName}. + * @param packageName The package name whose {@link Context} to get. + * @param flags The flags for {@link Context} type. + * @return Returns the {@link Context}. This will {@code null} if an exception is raised. + */ + @Nullable + public static Context getContextForPackage(@NonNull final Context context, String packageName, int flags) { + try { + return context.createPackageContext(packageName, flags); + } catch (Exception e) { + Logger.logVerbose(LOG_TAG, "Failed to get \"" + packageName + "\" package context with flags " + flags + ": " + e.getMessage()); + return null; + } + } + + /** + * Get the {@link Context} for a package name. + * + * @param context The {@link Context} to use to get the {@link Context} of the {@code packageName}. + * @param packageName The package name whose {@link Context} to get. + * @param exitAppOnError If {@code true} and failed to get package context, then a dialog will + * be shown which when dismissed will exit the app. + * @param helpUrl The help user to add to {@link R.string#error_get_package_context_failed_help_url_message}. + * @return Returns the {@link Context}. This will {@code null} if an exception is raised. + */ + @Nullable + public static Context getContextForPackageOrExitApp(@NonNull Context context, String packageName, + final boolean exitAppOnError, @Nullable String helpUrl) { + Context packageContext = getContextForPackage(context, packageName); + + if (packageContext == null && exitAppOnError) { + String errorMessage = context.getString(R.string.error_get_package_context_failed_message, + packageName); + if (!DataUtils.isNullOrEmpty(helpUrl)) + errorMessage += "\n" + context.getString(R.string.error_get_package_context_failed_help_url_message, helpUrl); + Logger.logError(LOG_TAG, errorMessage); + MessageDialogUtils.exitAppWithErrorMessage(context, + context.getString(R.string.error_get_package_context_failed_title), + errorMessage); + } + + return packageContext; + } + + + + /** + * Get the {@link PackageInfo} for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised. + */ + public static PackageInfo getPackageInfoForPackage(@NonNull final Context context) { + return getPackageInfoForPackage(context, context.getPackageName()); + } + + /** + * Get the {@link PackageInfo} for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @param flags The flags to pass to {@link PackageManager#getPackageInfo(String, int)}. + * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static PackageInfo getPackageInfoForPackage(@NonNull final Context context, final int flags) { + return getPackageInfoForPackage(context, context.getPackageName(), flags); + } + + /** + * Get the {@link PackageInfo} for the package associated with the {@code packageName}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised. + */ + public static PackageInfo getPackageInfoForPackage(@NonNull final Context context, @NonNull final String packageName) { + return getPackageInfoForPackage(context, packageName, 0); + } + + /** + * Get the {@link PackageInfo} for the package associated with the {@code packageName}. + * + * Also check {@link #isAppInstalled(Context, String, String) if targetting targeting sdk + * `30` (android `11`) since {@link PackageManager.NameNotFoundException} may be thrown. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @param flags The flags to pass to {@link PackageManager#getPackageInfo(String, int)}. + * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static PackageInfo getPackageInfoForPackage(@NonNull final Context context, @NonNull final String packageName, final int flags) { + try { + return context.getPackageManager().getPackageInfo(packageName, flags); + } catch (final Exception e) { + return null; + } + } + + + + /** + * Get the {@link ApplicationInfo} for the {@code packageName}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @return Returns the {@link ApplicationInfo}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static ApplicationInfo getApplicationInfoForPackage(@NonNull final Context context, @NonNull final String packageName) { + return getApplicationInfoForPackage(context, packageName, 0); + } + + /** + * Get the {@link ApplicationInfo} for the {@code packageName}. + * + * Also check {@link #isAppInstalled(Context, String, String) if targetting targeting sdk + * `30` (android `11`) since {@link PackageManager.NameNotFoundException} may be thrown. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @param flags The flags to pass to {@link PackageManager#getApplicationInfo(String, int)}. + * @return Returns the {@link ApplicationInfo}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static ApplicationInfo getApplicationInfoForPackage(@NonNull final Context context, @NonNull final String packageName, final int flags) { + try { + return context.getPackageManager().getApplicationInfo(packageName, flags); + } catch (final Exception e) { + return null; + } + } + + /** + * Get the {@code privateFlags} {@link Field} of the {@link ApplicationInfo} class. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the private flags or {@code null} if an exception was raised. + */ + @Nullable + public static Integer getApplicationInfoPrivateFlagsForPackage(@NonNull final ApplicationInfo applicationInfo) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (Integer) ReflectionUtils.invokeField(ApplicationInfo.class, "privateFlags", applicationInfo).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get privateFlags field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Get the {@code seInfo} {@link Field} of the {@link ApplicationInfo} class. + * + * String retrieved from the seinfo tag found in selinux policy. This value can be set through + * the mac_permissions.xml policy construct. This value is used for setting an SELinux security + * context on the process as well as its data directory. + * + * https://cs.android.com/android/platform/superproject/+/android-7.1.0_r1:frameworks/base/core/java/android/content/pm/ApplicationInfo.java;l=609 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/content/pm/ApplicationInfo.java;l=981 + * https://cs.android.com/android/platform/superproject/+/android-7.0.0_r1:frameworks/base/services/core/java/com/android/server/pm/SELinuxMMAC.java;l=282 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/pm/SELinuxMMAC.java;l=375 + * https://cs.android.com/android/_/android/platform/frameworks/base/+/be0b8896d1bc385d4c8fb54c21929745935dcbea + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the selinux info or {@code null} if an exception was raised. + */ + @Nullable + public static String getApplicationInfoSeInfoForPackage(@NonNull final ApplicationInfo applicationInfo) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (String) ReflectionUtils.invokeField(ApplicationInfo.class, Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? "seinfo" : "seInfo", applicationInfo).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get seInfo field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Get the {@code seInfoUser} {@link Field} of the {@link ApplicationInfo} class. + * + * Also check {@link #getApplicationInfoSeInfoForPackage(ApplicationInfo)}. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the selinux info user or {@code null} if an exception was raised. + */ + @Nullable + public static String getApplicationInfoSeInfoUserForPackage(@NonNull final ApplicationInfo applicationInfo) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null; + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (String) ReflectionUtils.invokeField(ApplicationInfo.class, "seInfoUser", applicationInfo).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get seInfoUser field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Get the {@code privateFlags} {@link Field} of the {@link ApplicationInfo} class. + * + * @param fieldName The name of the field to get. + * @return Returns the field value or {@code null} if an exception was raised. + */ + @Nullable + public static Integer getApplicationInfoStaticIntFieldValue(@NonNull String fieldName) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (Integer) ReflectionUtils.invokeField(ApplicationInfo.class, fieldName, null).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Check if the app associated with the {@code applicationInfo} has a specific flag set. + * + * @param flagToCheckName The name of the field for the flag to check. + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns {@code true} if app has flag is set, otherwise {@code false}. This will be + * {@code null} if an exception is raised. + */ + @Nullable + public static Boolean isApplicationInfoPrivateFlagSetForPackage(@NonNull String flagToCheckName, @NonNull final ApplicationInfo applicationInfo) { + Integer privateFlags = getApplicationInfoPrivateFlagsForPackage(applicationInfo); + if (privateFlags == null) return null; + + Integer flagToCheck = getApplicationInfoStaticIntFieldValue(flagToCheckName); + if (flagToCheck == null) return null; + + return ( 0 != ( privateFlags & flagToCheck ) ); + } + + + + + + /** + * Get the app name for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the {@code android:name} attribute. + */ + public static String getAppNameForPackage(@NonNull final Context context) { + return getAppNameForPackage(context, context.getApplicationInfo()); + } + + /** + * Get the app name for the package associated with the {@code applicationInfo}. + * + * @param context The {@link Context} for operations. + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the {@code android:name} attribute. + */ + public static String getAppNameForPackage(@NonNull final Context context, @NonNull final ApplicationInfo applicationInfo) { + return applicationInfo.loadLabel(context.getPackageManager()).toString(); + } + + + + /** + * Get the package name for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the package name. + */ + public static String getPackageNameForPackage(@NonNull final Context context) { + return getPackageNameForPackage(context.getApplicationInfo()); + } + + /** + * Get the package name for the package associated with the {@code applicationInfo}. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the package name. + */ + public static String getPackageNameForPackage(@NonNull final ApplicationInfo applicationInfo) { + return applicationInfo.packageName; + } + + + + /** + * Get the uid for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the uid. + */ + public static int getUidForPackage(@NonNull final Context context) { + return getUidForPackage(context.getApplicationInfo()); + } + + /** + * Get the uid for the package associated with the {@code applicationInfo}. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the uid. + */ + public static int getUidForPackage(@NonNull final ApplicationInfo applicationInfo) { + return applicationInfo.uid; + } + + + + /** + * Get the {@code targetSdkVersion} for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the {@code targetSdkVersion}. + */ + public static int getTargetSDKForPackage(@NonNull final Context context) { + return getTargetSDKForPackage(context.getApplicationInfo()); + } + + /** + * Get the {@code targetSdkVersion} for the package associated with the {@code applicationInfo}. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the {@code targetSdkVersion}. + */ + public static int getTargetSDKForPackage(@NonNull final ApplicationInfo applicationInfo) { + return applicationInfo.targetSdkVersion; + } + + + + /** + * Get the base apk path for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the base apk path. + */ + public static String getBaseAPKPathForPackage(@NonNull final Context context) { + return getBaseAPKPathForPackage(context.getApplicationInfo()); + } + + /** + * Get the base apk path for the package associated with the {@code applicationInfo}. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the base apk path. + */ + public static String getBaseAPKPathForPackage(@NonNull final ApplicationInfo applicationInfo) { + return applicationInfo.publicSourceDir; + } + + + + /** + * Check if the app associated with the {@code context} has {@link ApplicationInfo#FLAG_DEBUGGABLE} + * set. + * + * @param context The {@link Context} for the package. + * @return Returns {@code true} if app is debuggable, otherwise {@code false}. + */ + public static boolean isAppForPackageADebuggableBuild(@NonNull final Context context) { + return isAppForPackageADebuggableBuild(context.getApplicationInfo()); + } + + /** + * Check if the app associated with the {@code applicationInfo} has {@link ApplicationInfo#FLAG_DEBUGGABLE} + * set. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns {@code true} if app is debuggable, otherwise {@code false}. + */ + public static boolean isAppForPackageADebuggableBuild(@NonNull final ApplicationInfo applicationInfo) { + return ( 0 != ( applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE ) ); + } + + + + /** + * Check if the app associated with the {@code context} has {@link ApplicationInfo#FLAG_EXTERNAL_STORAGE} + * set. + * + * @param context The {@link Context} for the package. + * @return Returns {@code true} if app is installed on external storage, otherwise {@code false}. + */ + public static boolean isAppInstalledOnExternalStorage(@NonNull final Context context) { + return isAppInstalledOnExternalStorage(context.getApplicationInfo()); + } + + /** + * Check if the app associated with the {@code applicationInfo} has {@link ApplicationInfo#FLAG_EXTERNAL_STORAGE} + * set. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns {@code true} if app is installed on external storage, otherwise {@code false}. + */ + public static boolean isAppInstalledOnExternalStorage(@NonNull final ApplicationInfo applicationInfo) { + return ( 0 != ( applicationInfo.flags & ApplicationInfo.FLAG_EXTERNAL_STORAGE ) ); + } + + + + /** + * Check if the app associated with the {@code context} has + * ApplicationInfo.PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE (requestLegacyExternalStorage) + * set to {@code true} in app manifest. + * + * @param context The {@link Context} for the package. + * @return Returns {@code true} if app has requested legacy external storage, otherwise + * {@code false}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Boolean hasRequestedLegacyExternalStorage(@NonNull final Context context) { + return hasRequestedLegacyExternalStorage(context.getApplicationInfo()); + } + + /** + * Check if the app associated with the {@code applicationInfo} has + * ApplicationInfo.PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE (requestLegacyExternalStorage) + * set to {@code true} in app manifest. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns {@code true} if app has requested legacy external storage, otherwise + * {@code false}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Boolean hasRequestedLegacyExternalStorage(@NonNull final ApplicationInfo applicationInfo) { + return isApplicationInfoPrivateFlagSetForPackage("PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE", applicationInfo); + } + + + + /** + * Get the {@code versionCode} for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the {@code versionCode}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Integer getVersionCodeForPackage(@NonNull final Context context) { + return getVersionCodeForPackage(context, context.getPackageName()); + } + + /** + * Get the {@code versionCode} for the {@code packageName}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @return Returns the {@code versionCode}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Integer getVersionCodeForPackage(@NonNull final Context context, @NonNull final String packageName) { + return getVersionCodeForPackage(getPackageInfoForPackage(context, packageName)); + } + + /** + * Get the {@code versionCode} for the {@code packageName}. + * + * @param packageInfo The {@link PackageInfo} for the package. + * @return Returns the {@code versionCode}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Integer getVersionCodeForPackage(@Nullable final PackageInfo packageInfo) { + return packageInfo != null ? packageInfo.versionCode : null; + } + + + + /** + * Get the {@code versionName} for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the {@code versionName}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getVersionNameForPackage(@NonNull final Context context) { + return getVersionNameForPackage(context, context.getPackageName()); + } + + /** + * Get the {@code versionName} for the {@code packageName}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @return Returns the {@code versionName}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getVersionNameForPackage(@NonNull final Context context, @NonNull final String packageName) { + return getVersionNameForPackage(getPackageInfoForPackage(context, packageName)); + } + + /** + * Get the {@code versionName} for the {@code packageName}. + * + * @param packageInfo The {@link PackageInfo} for the package. + * @return Returns the {@code versionName}. This will be {@code null} if an {@code packageInfo} + * is {@code null}. + */ + @Nullable + public static String getVersionNameForPackage(@Nullable final PackageInfo packageInfo) { + return packageInfo != null ? packageInfo.versionName : null; + } + + + + /** + * Get the {@code SHA-256 digest} of signing certificate for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the {@code SHA-256 digest}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getSigningCertificateSHA256DigestForPackage(@NonNull final Context context) { + return getSigningCertificateSHA256DigestForPackage(context, context.getPackageName()); + } + + /** + * Get the {@code SHA-256 digest} of signing certificate for the {@code packageName}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the package. + * @return Returns the {@code SHA-256 digest}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getSigningCertificateSHA256DigestForPackage(@NonNull final Context context, @NonNull final String packageName) { + try { + /* + * Todo: We may need AndroidManifest queries entries if package is installed but with a different signature on android 11 + * https://developer.android.com/training/package-visibility + * Need a device that allows (manual) installation of apk with mismatched signature of + * sharedUserId apps to test. Currently, if its done, PackageManager just doesn't load + * the package and removes its apk automatically if its installed as a user app instead of system app + * W/PackageManager: Failed to parse /path/to/com.termux.tasker.apk: Signature mismatch for shared user: SharedUserSetting{xxxxxxx com.termux/10xxx} + */ + PackageInfo packageInfo = getPackageInfoForPackage(context, packageName, PackageManager.GET_SIGNATURES); + if (packageInfo == null) return null; + return DataUtils.bytesToHex(MessageDigest.getInstance("SHA-256").digest(packageInfo.signatures[0].toByteArray())); + } catch (final Exception e) { + return null; + } + } + + + + /** + * Get the serial number for the user for the package associated with the {@code context}. + * + * @param context The {@link Context} for the package. + * @return Returns the serial number. This will be {@code null} if failed to get it. + */ + @RequiresApi(api = Build.VERSION_CODES.N) + @Nullable + public static Long getUserIdForPackage(@NonNull Context context) { + UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE); + if (userManager == null) return null; + return userManager.getSerialNumberForUser(UserHandle.getUserHandleForUid(getUidForPackage(context))); + } + + /** + * Check if the current user is the primary user. This is done by checking if the the serial + * number for the current user equals 0. + * + * @param context The {@link Context} for operations. + * @return Returns {@code true} if the current user is the primary user, otherwise [@code false}. + */ + @RequiresApi(api = Build.VERSION_CODES.N) + public static boolean isCurrentUserThePrimaryUser(@NonNull Context context) { + Long userId = getUserIdForPackage(context); + return userId != null && userId == 0; + } + + /** + * Get the profile owner package name for the current user. + * + * @param context The {@link Context} for operations. + * @return Returns the profile owner package name. This will be {@code null} if failed to get it + * or no profile owner for the current user. + */ + @Nullable + public static String getProfileOwnerPackageNameForUser(@NonNull Context context) { + DevicePolicyManager devicePolicyManager = (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE); + if (devicePolicyManager == null) return null; + List activeAdmins = devicePolicyManager.getActiveAdmins(); + if (activeAdmins != null){ + for (ComponentName admin:activeAdmins){ + String packageName = admin.getPackageName(); + if(devicePolicyManager.isProfileOwnerApp(packageName)) + return packageName; + } + } + return null; + } + + + + /** + * Get the process id of the main app process of a package. This will work for sharedUserId. Note + * that some apps have multiple processes for the app like with `android:process=":background"` + * attribute in AndroidManifest.xml. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the process. + * @return Returns the process if found and running, otherwise {@code null}. + */ + @Nullable + public static String getPackagePID(final Context context, String packageName) { + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager != null) { + List processInfos = activityManager.getRunningAppProcesses(); + if (processInfos != null) { + ActivityManager.RunningAppProcessInfo processInfo; + for (int i = 0; i < processInfos.size(); i++) { + processInfo = processInfos.get(i); + if (processInfo.processName.equals(packageName)) + return String.valueOf(processInfo.pid); + } + } + } + return null; + } + + + + /** + * Check if app is installed and enabled. This can be used by external apps that don't + * share `sharedUserId` with the an app. + * + * If your third-party app is targeting sdk `30` (android `11`), then it needs to add package + * name to the `queries` element or request `QUERY_ALL_PACKAGES` permission in its + * `AndroidManifest.xml`. Otherwise it will get `PackageSetting{...... package_name/......} BLOCKED` + * errors in `logcat` and {@link PackageManager.NameNotFoundException} may be thrown. + * `RUN_COMMAND` intent won't work either. + * Check [package-visibility](https://developer.android.com/training/basics/intents/package-visibility#package-name), + * `QUERY_ALL_PACKAGES` [googleplay policy](https://support.google.com/googleplay/android-developer/answer/10158779 + * and this [article](https://medium.com/androiddevelopers/working-with-package-visibility-dc252829de2d) for more info. + * + * {@code + * + * + * + * + * + * + * } + * + * @param context The context for operations. + * @param appName The name of the app. + * @param packageName The package name of the package. + * @return Returns {@code errmsg} if {@code packageName} is not installed or disabled, otherwise {@code null}. + */ + public static String isAppInstalled(@NonNull final Context context, String appName, String packageName) { + String errmsg = null; + + ApplicationInfo applicationInfo = getApplicationInfoForPackage(context, packageName); + boolean isAppEnabled = (applicationInfo != null && applicationInfo.enabled); + + // If app is not installed or is disabled + if (!isAppEnabled) + errmsg = context.getString(R.string.error_app_not_installed_or_disabled_warning, appName, packageName); + + return errmsg; + } + + + /** Wrapper for {@link #setComponentState(Context, String, String, boolean, String, boolean, boolean)} with + * {@code alwaysShowToast} {@code true}. */ + public static String setComponentState(@NonNull final Context context, @NonNull String packageName, + @NonNull String className, boolean newState, String toastString, + boolean showErrorMessage) { + return setComponentState(context, packageName, className, newState, toastString, showErrorMessage, true); + } + + /** + * Enable or disable a {@link ComponentName} with a call to + * {@link PackageManager#setComponentEnabledSetting(ComponentName, int, int)}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the component. + * @param className The {@link Class} name of the component. + * @param newState If component should be enabled or disabled. + * @param toastString If this is not {@code null} or empty, then a toast before setting state. + * @param showErrorMessage If an error message toast should be shown. + * @param alwaysShowToast If toast should always be shown even if current state matches new state. + * @return Returns the errmsg if failed to set state, otherwise {@code null}. + */ + @Nullable + public static String setComponentState(@NonNull final Context context, @NonNull String packageName, + @NonNull String className, boolean newState, String toastString, + boolean alwaysShowToast, boolean showErrorMessage) { + try { + PackageManager packageManager = context.getPackageManager(); + if (packageManager != null) { + if (toastString != null && alwaysShowToast) { + Logger.showToast(context, toastString, true); + toastString = null; + } + + Boolean currentlyDisabled = PackageUtils.isComponentDisabled(context, packageName, className, false); + if (currentlyDisabled == null) + throw new UnsupportedOperationException("Failed to find if component currently disabled"); + + Boolean setState = null; + if (newState && currentlyDisabled) + setState = true; + else if (!newState && !currentlyDisabled) + setState = false; + + if (setState == null) return null; + + if (toastString != null) Logger.showToast(context, toastString, true); + ComponentName componentName = new ComponentName(packageName, className); + packageManager.setComponentEnabledSetting(componentName, + setState ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + } + return null; + } catch (final Exception e) { + String errmsg = context.getString( + newState ? R.string.error_enable_component_failed : R.string.error_disable_component_failed, + packageName, className) + ": " + e.getMessage(); + if (showErrorMessage) + Logger.showToast(context, errmsg, true); + return errmsg; + } + } + + /** + * Check if state of a {@link ComponentName} is {@link PackageManager#COMPONENT_ENABLED_STATE_DISABLED} + * with a call to {@link PackageManager#getComponentEnabledSetting(ComponentName)}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the component. + * @param className The {@link Class} name of the component. + * @param logErrorMessage If an error message should be logged. + * @return Returns {@code true} if disabled, {@code false} if not and {@code null} if failed to + * get the state. + */ + public static Boolean isComponentDisabled(@NonNull final Context context, @NonNull String packageName, + @NonNull String className, boolean logErrorMessage) { + try { + PackageManager packageManager = context.getPackageManager(); + if (packageManager != null) { + ComponentName componentName = new ComponentName(packageName, className); + // Will throw IllegalArgumentException: Unknown component: ComponentInfo{} if app + // for context is not installed or component does not exist. + return packageManager.getComponentEnabledSetting(componentName) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED; + } + } catch (final Exception e) { + if (logErrorMessage) + Logger.logStackTraceWithMessage(LOG_TAG, context.getString(R.string.error_get_component_state_failed, packageName, className), e); + } + + return null; + } + + /** + * Check if an {@link android.app.Activity} {@link ComponentName} can be called by calling + * {@link PackageManager#queryIntentActivities(Intent, int)}. + * + * @param context The {@link Context} for operations. + * @param packageName The package name of the component. + * @param className The {@link Class} name of the component. + * @param flags The flags to filter results. + * @return Returns {@code true} if it exists, otherwise {@code false}. + */ + public static boolean doesActivityComponentExist(@NonNull final Context context, @NonNull String packageName, + @NonNull String className, int flags) { + try { + PackageManager packageManager = context.getPackageManager(); + if (packageManager != null) { + Intent intent = new Intent(); + intent.setClassName(packageName, className); + return packageManager.queryIntentActivities(intent, flags).size() > 0; + } + } catch (final Exception e) { + // ignore + } + + return false; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java new file mode 100644 index 000000000..4a15649df --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java @@ -0,0 +1,573 @@ +package com.termux.shared.android; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.PowerManager; +import android.provider.Settings; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.google.common.base.Joiner; +import com.termux.shared.R; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.errors.Error; +import com.termux.shared.errors.FunctionErrno; +import com.termux.shared.activity.ActivityUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class PermissionUtils { + + public static final int REQUEST_GRANT_STORAGE_PERMISSION = 1000; + + public static final int REQUEST_DISABLE_BATTERY_OPTIMIZATIONS = 2000; + public static final int REQUEST_GRANT_DISPLAY_OVER_OTHER_APPS_PERMISSION = 2001; + + private static final String LOG_TAG = "PermissionUtils"; + + + /** + * Check if app has been granted the required permission. + * + * @param context The context for operations. + * @param permission The {@link String} name for permission to check. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkPermission(@NonNull Context context, @NonNull String permission) { + return checkPermissions(context, new String[]{permission}); + } + + /** + * Check if app has been granted the required permissions. + * + * @param context The context for operations. + * @param permissions The {@link String[]} names for permissions to check. + * @return Returns {@code true} if permissions are granted, otherwise {@code false}. + */ + public static boolean checkPermissions(@NonNull Context context, @NonNull String[] permissions) { + // checkSelfPermission may return true for permissions not even requested + List permissionsNotRequested = getPermissionsNotRequested(context, permissions); + if (permissionsNotRequested.size() > 0) { + Logger.logError(LOG_TAG, + context.getString(R.string.error_attempted_to_check_for_permissions_not_requested, + Joiner.on(", ").join(permissionsNotRequested))); + return false; + } + + int result; + for (String permission : permissions) { + result = ContextCompat.checkSelfPermission(context, permission); + if (result != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + + return true; + } + + + + /** + * Request user to grant required permissions to the app. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity}. + * @param permission The {@link String} name for permission to request. + * @param requestCode The request code to use while asking for permission. It must be `>=0` or + * will fail silently and will log an exception. + * @return Returns {@code true} if requesting the permission was successful, otherwise {@code false}. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + public static boolean requestPermission(@NonNull Context context, @NonNull String permission, + int requestCode) { + return requestPermissions(context, new String[]{permission}, requestCode); + } + + /** + * Request user to grant required permissions to the app. + * + * On sdk 30 (android 11), Activity.onRequestPermissionsResult() will pass + * {@link PackageManager#PERMISSION_DENIED} (-1) without asking the user for the permission + * if user previously denied the permission prompt. On sdk 29 (android 10), + * Activity.onRequestPermissionsResult() will pass {@link PackageManager#PERMISSION_DENIED} (-1) + * without asking the user for the permission if user previously selected "Deny & don't ask again" + * option in prompt. The user will have to manually enable permission in app info in Android + * settings. If user grants and then denies in settings, then next time prompt will shown. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity}. + * @param permissions The {@link String[]} names for permissions to request. + * @param requestCode The request code to use while asking for permissions. It must be `>=0` or + * will fail silently and will log an exception. + * @return Returns {@code true} if requesting the permissions was successful, otherwise {@code false}. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + public static boolean requestPermissions(@NonNull Context context, @NonNull String[] permissions, + int requestCode) { + List permissionsNotRequested = getPermissionsNotRequested(context, permissions); + if (permissionsNotRequested.size() > 0) { + Logger.logErrorAndShowToast(context, LOG_TAG, + context.getString(R.string.error_attempted_to_ask_for_permissions_not_requested, + Joiner.on(", ").join(permissionsNotRequested))); + return false; + } + + for (String permission : permissions) { + int result = ContextCompat.checkSelfPermission(context, permission); + // If at least one permission not granted + if (result != PackageManager.PERMISSION_GRANTED) { + Logger.logInfo(LOG_TAG, "Requesting Permissions: " + Arrays.toString(permissions)); + + try { + if (context instanceof AppCompatActivity) + ((AppCompatActivity) context).requestPermissions(permissions, requestCode); + else if (context instanceof Activity) + ((Activity) context).requestPermissions(permissions, requestCode); + else { + Error.logErrorAndShowToast(context, LOG_TAG, + FunctionErrno.ERRNO_PARAMETER_NOT_INSTANCE_OF.getError("context", "requestPermissions", "Activity or AppCompatActivity")); + return false; + } + } catch (Exception e) { + String errmsg = context.getString(R.string.error_failed_to_request_permissions, requestCode, Arrays.toString(permissions)); + Logger.logStackTraceWithMessage(LOG_TAG, errmsg, e); + Logger.showToast(context, errmsg + "\n" + e.getMessage(), true); + return false; + } + + break; + } + } + + return true; + } + + + + + /** + * Check if app has requested the required permission in the manifest. + * + * @param context The context for operations. + * @param permission The {@link String} name for permission to check. + * @return Returns {@code true} if permission has been requested, otherwise {@code false}. + */ + public static boolean isPermissionRequested(@NonNull Context context, @NonNull String permission) { + return getPermissionsNotRequested(context, new String[]{permission}).size() == 0; + } + + /** + * Check if app has requested the required permissions or not in the manifest. + * + * @param context The context for operations. + * @param permissions The {@link String[]} names for permissions to check. + * @return Returns {@link List} of permissions that have not been requested. It will have + * size 0 if all permissions have been requested. + */ + @NonNull + public static List getPermissionsNotRequested(@NonNull Context context, @NonNull String[] permissions) { + List permissionsNotRequested = new ArrayList<>(); + Collections.addAll(permissionsNotRequested, permissions); + + PackageInfo packageInfo = PackageUtils.getPackageInfoForPackage(context, PackageManager.GET_PERMISSIONS); + if (packageInfo == null) { + return permissionsNotRequested; + } + + // If no permissions are requested, then nothing to check + if (packageInfo.requestedPermissions == null || packageInfo.requestedPermissions.length == 0) + return permissionsNotRequested; + + List requestedPermissionsList = Arrays.asList(packageInfo.requestedPermissions); + for (String permission : permissions) { + if (requestedPermissionsList.contains(permission)) { + permissionsNotRequested.remove(permission); + } + } + + return permissionsNotRequested; + } + + + + + /** If path is under primary external storage directory and storage permission is missing, + * then legacy or manage external storage permission will be requested from the user via a call + * to {@link #checkAndRequestLegacyOrManageExternalStoragePermission(Context, int, boolean)}. + * + * @param context The context for operations. + * @param filePath The path to check. + * @param requestCode The request code to use while asking for permission. + * @param showErrorMessage If an error message toast should be shown if permission is not granted. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + @SuppressLint("SdCardPath") + public static boolean checkAndRequestLegacyOrManageExternalStoragePermissionIfPathOnPrimaryExternalStorage( + @NonNull Context context, String filePath, int requestCode, boolean showErrorMessage) { + // If path is under primary external storage directory, then check for missing permissions. + if (!FileUtils.isPathInDirPaths(filePath, + Arrays.asList(Environment.getExternalStorageDirectory().getAbsolutePath(), "/sdcard"), true)) + return true; + + return checkAndRequestLegacyOrManageExternalStoragePermission(context, requestCode, showErrorMessage); + } + + /** + * Check if legacy or manage external storage permissions has been granted. If + * {@link #isLegacyExternalStoragePossible(Context)} returns {@code true}, them it will be + * checked if app has has been granted {@link Manifest.permission#READ_EXTERNAL_STORAGE} and + * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} permissions, otherwise it will be checked + * if app has been granted the {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} permission. + * + * If storage permission is missing, it will be requested from the user if {@code context} is an + * instance of {@link Activity} or {@link AppCompatActivity} and {@code requestCode} + * is `>=0` and the function will automatically return. The caller should register for + * Activity.onActivityResult() and Activity.onRequestPermissionsResult() and call this function + * again but set {@code requestCode} to `-1` to check if permission was granted or not. + * + * Caller must add following to AndroidManifest.xml of the app, otherwise errors will be thrown. + * {@code + * + * + * + * + * + * + *} + * @param context The context for operations. + * @param requestCode The request code to use while asking for permission. + * @param showErrorMessage If an error message toast should be shown if permission is not granted. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkAndRequestLegacyOrManageExternalStoragePermission(@NonNull Context context, + int requestCode, + boolean showErrorMessage) { + String errmsg; + boolean requestLegacyStoragePermission = isLegacyExternalStoragePossible(context); + boolean checkIfHasRequestedLegacyExternalStorage = checkIfHasRequestedLegacyExternalStorage(context); + + if (requestLegacyStoragePermission && checkIfHasRequestedLegacyExternalStorage) { + // Check if requestLegacyExternalStorage is set to true in app manifest + if (!hasRequestedLegacyExternalStorage(context, showErrorMessage)) + return false; + } + + if (checkStoragePermission(context, requestLegacyStoragePermission)) { + return true; + } + + + errmsg = context.getString(R.string.msg_storage_permission_not_granted); + Logger.logError(LOG_TAG, errmsg); + if (showErrorMessage) + Logger.showToast(context, errmsg, false); + + if (requestCode < 0 || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return false; + + if (requestLegacyStoragePermission || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + requestLegacyStorageExternalPermission(context, requestCode); + } else { + requestManageStorageExternalPermission(context, requestCode); + } + + return false; + } + + /** + * Check if app has been granted storage permission. + * + * @param context The context for operations. + * @param checkLegacyStoragePermission If set to {@code true}, then it will be checked if app + * has been granted {@link Manifest.permission#READ_EXTERNAL_STORAGE} + * and {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} + * permissions, otherwise it will be checked if app has been + * granted the {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} + * permission. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkStoragePermission(@NonNull Context context, boolean checkLegacyStoragePermission) { + if (checkLegacyStoragePermission || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return checkPermissions(context, + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE}); + } else { + return Environment.isExternalStorageManager(); + } + } + + /** + * Request user to grant {@link Manifest.permission#READ_EXTERNAL_STORAGE} and + * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} permissions to the app. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity}. + * @param requestCode The request code to use while asking for permission. It must be `>=0` or + * will fail silently and will log an exception. + * @return Returns {@code true} if requesting the permission was successful, otherwise {@code false}. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + public static boolean requestLegacyStorageExternalPermission(@NonNull Context context, int requestCode) { + Logger.logInfo(LOG_TAG, "Requesting legacy external storage permission"); + return requestPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE, requestCode); + } + + /** Wrapper for {@link #requestManageStorageExternalPermission(Context, int)}. */ + @RequiresApi(api = Build.VERSION_CODES.R) + public static Error requestManageStorageExternalPermission(@NonNull Context context) { + return requestManageStorageExternalPermission(context, -1); + } + + /** + * Request user to grant {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} permission to the app. + * + * @param context The context for operations, like an {@link Activity} or {@link Service} context. + * It must be an instance of {@link Activity} or {@link AppCompatActivity} if + * result is required via the Activity#onActivityResult() callback and + * {@code requestCode} is `>=0`. + * @param requestCode The request code to use while asking for permission. It must be `>=0` if + * result it required. + * @return Returns the {@code error} if requesting the permission was not successful, otherwise {@code null}. + */ + @RequiresApi(api = Build.VERSION_CODES.R) + public static Error requestManageStorageExternalPermission(@NonNull Context context, int requestCode) { + Logger.logInfo(LOG_TAG, "Requesting manage external storage permission"); + + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + intent.addCategory("android.intent.category.DEFAULT"); + intent.setData(Uri.parse("package:" + context.getPackageName())); + + // Flag must not be passed for activity contexts, otherwise onActivityResult() will not be called with permission grant result. + // Flag must be passed for non-activity contexts like services, otherwise "Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag" exception will be raised. + if (!(context instanceof Activity)) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + Error error; + if (requestCode >=0) + error = ActivityUtils.startActivityForResult(context, requestCode, intent, true, false); + else + error = ActivityUtils.startActivity(context, intent, true, false); + + // Use fallback if matching Activity did not exist for ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION. + if (error != null) { + intent = new Intent(); + intent.setAction(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + if (requestCode >=0) + return ActivityUtils.startActivityForResult(context, requestCode, intent); + else + return ActivityUtils.startActivity(context, intent); + } + + return null; + } + + /** + * If app is targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) or + * higher, then {@link android.R.attr#requestLegacyExternalStorage} attribute is ignored. + * https://developer.android.com/training/data-storage/use-cases#opt-out-scoped-storage + */ + public static boolean isLegacyExternalStoragePossible(@NonNull Context context) { + return !(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + PackageUtils.getTargetSDKForPackage(context) >= Build.VERSION_CODES.R); + } + + /** + * Return whether it should be checked if app has set + * {@link android.R.attr#requestLegacyExternalStorage} attribute to {@code true}, if storage + * permissions are to be requested based on if {@link #isLegacyExternalStoragePossible(Context)} + * return {@code true}. + * + * If app is targeting targetSdkVersion 30 (android 11), then legacy storage can only be + * requested if running on sdk 29 (android 10). + * If app is targeting targetSdkVersion 29 (android 10), then legacy storage can only be + * requested if running on sdk 29 (android 10) and higher. + */ + public static boolean checkIfHasRequestedLegacyExternalStorage(@NonNull Context context) { + int targetSdkVersion = PackageUtils.getTargetSDKForPackage(context); + + if (targetSdkVersion >= Build.VERSION_CODES.R) { + return Build.VERSION.SDK_INT == Build.VERSION_CODES.Q; + } else if (targetSdkVersion == Build.VERSION_CODES.Q) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } else { + return false; + } + } + + /** + * Call to {@link Environment#isExternalStorageLegacy()} will not return the actual value defined + * in app manifest for {@link android.R.attr#requestLegacyExternalStorage} attribute, + * since an app may inherit its legacy state based on when it was first installed, target sdk and + * other factors. To provide consistent experience for all users regardless of current legacy + * state on a specific device, we directly use the value defined in app` manifest. + */ + public static boolean hasRequestedLegacyExternalStorage(@NonNull Context context, + boolean showErrorMessage) { + String errmsg; + Boolean hasRequestedLegacyExternalStorage = PackageUtils.hasRequestedLegacyExternalStorage(context); + if (hasRequestedLegacyExternalStorage != null && !hasRequestedLegacyExternalStorage) { + errmsg = context.getString(R.string.error_has_not_requested_legacy_external_storage, + context.getPackageName(), PackageUtils.getTargetSDKForPackage(context), Build.VERSION.SDK_INT); + Logger.logError(LOG_TAG, errmsg); + if (showErrorMessage) + Logger.showToast(context, errmsg, true); + return false; + } + + return true; + } + + + + + + /** + * Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW} permission has been granted. + * + * @param context The context for operations. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkDisplayOverOtherAppsPermission(@NonNull Context context) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) + return Settings.canDrawOverlays(context); + else + return true; + } + + /** Wrapper for {@link #requestDisplayOverOtherAppsPermission(Context, int)}. */ + public static Error requestDisplayOverOtherAppsPermission(@NonNull Context context) { + return requestDisplayOverOtherAppsPermission(context, -1); + } + + /** + * Request user to grant {@link Manifest.permission#SYSTEM_ALERT_WINDOW} permission to the app. + * + * @param context The context for operations, like an {@link Activity} or {@link Service} context. + * It must be an instance of {@link Activity} or {@link AppCompatActivity} if + * result is required via the Activity#onActivityResult() callback and + * {@code requestCode} is `>=0`. + * @param requestCode The request code to use while asking for permission. It must be `>=0` if + * result it required. + * @return Returns the {@code error} if requesting the permission was not successful, otherwise {@code null}. + */ + public static Error requestDisplayOverOtherAppsPermission(@NonNull Context context, int requestCode) { + Logger.logInfo(LOG_TAG, "Requesting display over apps permission"); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return null; + + Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); + intent.setData(Uri.parse("package:" + context.getPackageName())); + + // Flag must not be passed for activity contexts, otherwise onActivityResult() will not be called with permission grant result. + // Flag must be passed for non-activity contexts like services, otherwise "Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag" exception will be raised. + if (!(context instanceof Activity)) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (requestCode >=0) + return ActivityUtils.startActivityForResult(context, requestCode, intent); + else + return ActivityUtils.startActivity(context, intent); + } + + /** + * Check if running on sdk 29 (android 10) or higher and {@link Manifest.permission#SYSTEM_ALERT_WINDOW} + * permission has been granted or not. + * + * @param context The context for operations. + * @param logResults If it should be logged that permission has been granted or not. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean validateDisplayOverOtherAppsPermissionForPostAndroid10(@NonNull Context context, + boolean logResults) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true; + + if (!checkDisplayOverOtherAppsPermission(context)) { + if (logResults) + Logger.logWarn(LOG_TAG, context.getPackageName() + " does not have Display over other apps (SYSTEM_ALERT_WINDOW) permission"); + return false; + } else { + if (logResults) + Logger.logDebug(LOG_TAG, context.getPackageName() + " already has Display over other apps (SYSTEM_ALERT_WINDOW) permission"); + return true; + } + } + + + + + + /** + * Check if {@link Manifest.permission#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS} permission has been + * granted. + * + * @param context The context for operations. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkIfBatteryOptimizationsDisabled(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + return powerManager.isIgnoringBatteryOptimizations(context.getPackageName()); + } else + return true; + } + + /** Wrapper for {@link #requestDisableBatteryOptimizations(Context, int)}. */ + public static Error requestDisableBatteryOptimizations(@NonNull Context context) { + return requestDisableBatteryOptimizations(context, -1); + } + + /** + * Request user to grant {@link Manifest.permission#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS} + * permission to the app. + * + * @param context The context for operations, like an {@link Activity} or {@link Service} context. + * It must be an instance of {@link Activity} or {@link AppCompatActivity} if + * result is required via the Activity#onActivityResult() callback and + * {@code requestCode} is `>=0`. + * @param requestCode The request code to use while asking for permission. It must be `>=0` if + * result it required. + * @return Returns the {@code error} if requesting the permission was not successful, otherwise {@code null}. + */ + @SuppressLint("BatteryLife") + public static Error requestDisableBatteryOptimizations(@NonNull Context context, int requestCode) { + Logger.logInfo(LOG_TAG, "Requesting to disable battery optimizations"); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return null; + + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + context.getPackageName())); + + // Flag must not be passed for activity contexts, otherwise onActivityResult() will not be called with permission grant result. + // Flag must be passed for non-activity contexts like services, otherwise "Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag" exception will be raised. + if (!(context instanceof Activity)) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (requestCode >=0) + return ActivityUtils.startActivityForResult(context, requestCode, intent); + else + return ActivityUtils.startActivity(context, intent); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java new file mode 100644 index 000000000..c4dc7790f --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java @@ -0,0 +1,115 @@ +package com.termux.shared.android; + +import android.Manifest; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.environment.AndroidShellEnvironment; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.runner.app.AppShell; + +/** + * Utils for phantom processes added in android 12. + * + * https://github.com/termux/termux-app/issues/2366 + * https://issuetracker.google.com/u/1/issues/205156966#comment28 + * https://cs.android.com/android/_/android/platform/frameworks/base/+/09dcdad5 + * https://github.com/agnostic-apollo/Android-Docs/tree/master/ocs/apps/processes/phantom-cached-and-empty-processes.md + */ +public class PhantomProcessUtils { + + private static final String LOG_TAG = "PhantomProcessUtils"; + + /** + * If feature flag set to false, then will disable trimming of phantom process and processes using + * excessive CPU. Flag is available on Pixel Android 12L beta 3 and Android 13. Availability on + * other devices will depend on if other vendors merged the 09dcdad5 commit or not in their releases + * and if they actually want to support the flag. Check {@link FeatureFlagUtils} javadocs for + * more details. + */ + public static final String FEATURE_FLAG_SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS = "settings_enable_monitor_phantom_procs"; + + /** + * Maximum number of allowed phantom processes. It is also used as the label for the currently + * enforced ActivityManagerConstants MAX_PHANTOM_PROCESSES value in the `dumpsys activity settings` + * output. + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/services/core/java/com/android/server/am/ActivityManagerConstants.java;l=574 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/services/core/java/com/android/server/am/ActivityManagerConstants.java;l=172 + */ + public static final String KEY_MAX_PHANTOM_PROCESSES = "max_phantom_processes"; + + /** + * Whether or not syncs (bulk set operations) for DeviceConfig are disabled currently. The value + * is boolean (1 or 0). The value '1' means that DeviceConfig#setProperties(DeviceConfig.Properties) + * will return {@code false}. + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/core/java/android/provider/DeviceConfig.java + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java;l=1186 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java;l=1142 + */ + public static final String SETTINGS_GLOBAL_DEVICE_CONFIG_SYNC_DISABLED = "device_config_sync_disabled"; + + /** + * Get {@link #FEATURE_FLAG_SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS} feature flag value. + * + * @param context The {@link Context} for operations. + * @return Returns {@link FeatureFlagUtils.FeatureFlagValue}. + */ + @NonNull + public static FeatureFlagUtils.FeatureFlagValue getFeatureFlagMonitorPhantomProcsValueString(@NonNull Context context) { + return FeatureFlagUtils.getFeatureFlagValueString(context, FEATURE_FLAG_SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS); + } + + /** + * Get currently enforced ActivityManagerConstants MAX_PHANTOM_PROCESSES value, defaults to 32. + * Can be changed by modifying device config activity_manager namespace "max_phantom_processes" value. + * + * @param context The {@link Context} for operations. + * @return Returns {@link Integer}. + */ + @Nullable + public static Integer getActivityManagerMaxPhantomProcesses(@NonNull Context context) { + if (!PermissionUtils.checkPermissions(context, new String[]{Manifest.permission.DUMP, Manifest.permission.PACKAGE_USAGE_STATS})) { + return null; + } + + // Dumpsys logs the currently enforced MAX_PHANTOM_PROCESSES value and not the device config setting. + String script = "/system/bin/dumpsys activity settings | /system/bin/grep -iE '^[\t ]+" + KEY_MAX_PHANTOM_PROCESSES + "=[0-9]+$' | /system/bin/cut -d = -f2"; + ExecutionCommand executionCommand = new ExecutionCommand(-1, "/system/bin/sh", null, + script + "\n", "/", ExecutionCommand.Runner.APP_SHELL.getName(), true); + executionCommand.commandLabel = " ActivityManager " + KEY_MAX_PHANTOM_PROCESSES + " Command"; + executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF; + AppShell appShell = AppShell.execute(context, executionCommand, null, new AndroidShellEnvironment(), null, true); + boolean stderrSet = !executionCommand.resultData.stderr.toString().isEmpty(); + if (appShell == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0 || stderrSet) { + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + return null; + } + + try { + return Integer.parseInt(executionCommand.resultData.stdout.toString().trim()); + } catch (NumberFormatException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "The " + executionCommand.commandLabel + " did not return a valid integer", e); + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + } + + return null; + } + + /** + * Get {@link #SETTINGS_GLOBAL_DEVICE_CONFIG_SYNC_DISABLED} settings value. + * + * @param context The {@link Context} for operations. + * @return Returns {@link Integer}. + */ + @Nullable + public static Integer getSettingsGlobalDeviceConfigSyncDisabled(@NonNull Context context) { + return (Integer) SettingsProviderUtils.getSettingsValue(context, SettingsProviderUtils.SettingNamespace.GLOBAL, + SettingsProviderUtils.SettingType.INT, SETTINGS_GLOBAL_DEVICE_CONFIG_SYNC_DISABLED, null); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java new file mode 100644 index 000000000..c6d182b06 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java @@ -0,0 +1,58 @@ +package com.termux.shared.android; + +import android.app.ActivityManager; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; + +import java.util.List; + +public class ProcessUtils { + + public static final String LOG_TAG = "ProcessUtils"; + + /** + * Get the app process name for a pid with a call to {@link ActivityManager#getRunningAppProcesses()}. + * + * This will not return child process names. Android did not keep track of them before android 12 + * phantom process addition, but there is no API via IActivityManager to get them. + * + * To get process name for pids of own app's child processes, check `get_process_name_from_cmdline()` + * in `local-socket.cpp`. + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ActivityManager.java;l=3362 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=8434 + * https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-12.0.0_r32:services/core/java/com/android/server/am/PhantomProcessList.java + * https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-12.0.0_r32:services/core/java/com/android/server/am/PhantomProcessRecord.java + * + * @param context The {@link Context} for operations. + * @param pid The pid of the process. + * @return Returns the app process name if found, otherwise {@code null}. + */ + @Nullable + public static String getAppProcessNameForPid(@NonNull Context context, int pid) { + if (pid < 0) return null; + + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) return null; + try { + List runningApps = activityManager.getRunningAppProcesses(); + if (runningApps == null) { + return null; + } + for (ActivityManager.RunningAppProcessInfo procInfo : runningApps) { + if (procInfo.pid == pid) { + return procInfo.processName; + } + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get app process name for pid " + pid, e); + } + + return null; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java new file mode 100644 index 000000000..13810eb7b --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java @@ -0,0 +1,96 @@ +package com.termux.shared.android; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; + +import java.lang.reflect.Method; + +public class SELinuxUtils { + + public static final String ANDROID_OS_SELINUX_CLASS = "android.os.SELinux"; + + private static final String LOG_TAG = "SELinuxUtils"; + + /** + * Gets the security context of the current process. + * + * @return Returns a {@link String} representing the security context of the current process. + * This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getContext() { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + String methodName = "getContext"; + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(ANDROID_OS_SELINUX_CLASS); + Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName); + if (method == null) { + Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class"); + return null; + } + + return (String) ReflectionUtils.invokeMethod(method, null).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e); + return null; + } + } + + /** + * Get the security context of a given process id. + * + * @param pid The pid of process. + * @return Returns a {@link String} representing the security context of the given pid. + * This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getPidContext(int pid) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + String methodName = "getPidContext"; + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(ANDROID_OS_SELINUX_CLASS); + Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName, int.class); + if (method == null) { + Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class"); + return null; + } + + return (String) ReflectionUtils.invokeMethod(method, null, pid).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e); + return null; + } + } + + /** + * Get the security context of a file object. + * + * @param path The pathname of the file object. + * @return Returns a {@link String} representing the security context of the file. + * This will be {@code null} if an exception is raised. + */ + @Nullable + public static String getFileContext(@NonNull String path) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + String methodName = "getFileContext"; + try { + @SuppressLint("PrivateApi") Class clazz = Class.forName(ANDROID_OS_SELINUX_CLASS); + Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName, String.class); + if (method == null) { + Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class"); + return null; + } + + return (String) ReflectionUtils.invokeMethod(method, null, path).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e); + return null; + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java new file mode 100644 index 000000000..4b7cde597 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java @@ -0,0 +1,99 @@ +package com.termux.shared.android; + +import android.content.Context; +import android.provider.Settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; + +public class SettingsProviderUtils { + + private static final String LOG_TAG = "SettingsProviderUtils"; + + /** The namespaces for {@link Settings} provider. */ + public enum SettingNamespace { + /** The {@link Settings.Global} namespace */ + GLOBAL, + + /** The {@link Settings.Secure} namespace */ + SECURE, + + /** The {@link Settings.System} namespace */ + SYSTEM + } + + /** The type of values for {@link Settings} provider. */ + public enum SettingType { + FLOAT, + INT, + LONG, + STRING, + URI + } + + /** + * Get settings key value from {@link SettingNamespace} namespace and of {@link SettingType} type. + * + * @param context The {@link Context} for operations. + * @param namespace The {@link SettingNamespace} in which to get key value from. + * @param type The {@link SettingType} for the key. + * @param key The {@link String} name for key. + * @param def The {@link Object} default value for key. + * @return Returns the key value. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Object getSettingsValue(@NonNull Context context, @NonNull SettingNamespace namespace, + @NonNull SettingType type, @NonNull String key, @Nullable Object def) { + try { + switch (namespace) { + case GLOBAL: + switch (type) { + case FLOAT: + return Settings.Global.getFloat(context.getContentResolver(), key); + case INT: + return Settings.Global.getInt(context.getContentResolver(), key); + case LONG: + return Settings.Global.getLong(context.getContentResolver(), key); + case STRING: + return Settings.Global.getString(context.getContentResolver(), key); + case URI: + return Settings.Global.getUriFor(key); + } + case SECURE: + switch (type) { + case FLOAT: + return Settings.Secure.getFloat(context.getContentResolver(), key); + case INT: + return Settings.Secure.getInt(context.getContentResolver(), key); + case LONG: + return Settings.Secure.getLong(context.getContentResolver(), key); + case STRING: + return Settings.Secure.getString(context.getContentResolver(), key); + case URI: + return Settings.Secure.getUriFor(key); + } + case SYSTEM: + switch (type) { + case FLOAT: + return Settings.System.getFloat(context.getContentResolver(), key); + case INT: + return Settings.System.getInt(context.getContentResolver(), key); + case LONG: + return Settings.System.getLong(context.getContentResolver(), key); + case STRING: + return Settings.System.getString(context.getContentResolver(), key); + case URI: + return Settings.System.getUriFor(key); + } + } + } catch (Settings.SettingNotFoundException e) { + // Ignore + } catch (Exception e) { + Logger.logError(LOG_TAG, "Failed to get \"" + key + "\" key value from settings \"" + namespace.name() + "\" namespace of type \"" + type.name() + "\""); + } + return def; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/UserUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/UserUtils.java new file mode 100644 index 000000000..f819de3f4 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/UserUtils.java @@ -0,0 +1,143 @@ +package com.termux.shared.android; + +import android.content.Context; +import android.content.pm.PackageManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; + +import java.lang.reflect.Method; + +public class UserUtils { + + public static final String LOG_TAG = "UserUtils"; + + /** + * Get the user name for user id with a call to {@link #getNameForUidFromPackageManager(Context, int)} + * and if that fails, then a call to {@link #getNameForUidFromLibcore(int)}. + * + * @param context The {@link Context} for operations. + * @param uid The user id. + * @return Returns the user name if found, otherwise {@code null}. + */ + @Nullable + public static String getNameForUid(@NonNull Context context, int uid) { + String name = getNameForUidFromPackageManager(context, uid); + if (name == null) + name = getNameForUidFromLibcore(uid); + return name; + } + + /** + * Get the user name for user id with a call to {@link PackageManager#getNameForUid(int)}. + * + * This will not return user names for non app user id like for root user 0, use {@link #getNameForUidFromLibcore(int)} + * to get those. + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/content/pm/PackageManager.java;l=5556 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ApplicationPackageManager.java;l=1028 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java;l=10293 + * + * @param context The {@link Context} for operations. + * @param uid The user id. + * @return Returns the user name if found, otherwise {@code null}. + */ + @Nullable + public static String getNameForUidFromPackageManager(@NonNull Context context, int uid) { + if (uid < 0) return null; + + try { + String name = context.getPackageManager().getNameForUid(uid); + if (name != null && name.endsWith(":" + uid)) + name = name.replaceAll(":" + uid + "$", ""); // Remove ":" suffix + return name; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get name for uid \"" + uid + "\" from package manager", e); + return null; + } + } + + /** + * Get the user name for user id with a call to `Libcore.os.getpwuid()`. + * + * This will return user names for non app user id like for root user 0 as well, but this call + * is expensive due to usage of reflection, and requires hidden API bypass, check + * {@link ReflectionUtils#bypassHiddenAPIReflectionRestrictions()} for details. + * + * `BlockGuardOs` implements the `Os` interface and its instance is stored in `Libcore` class static `os` field. + * The `getpwuid` method is implemented by `ForwardingOs`, which is the super class of `BlockGuardOs`. + * The `getpwuid` method returns `StructPasswd` object whose `pw_name` contains the user name for id. + * + * https://stackoverflow.com/a/28057167/14686958 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/Libcore.java;l=39 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/Os.java;l=279 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/BlockGuardOs.java + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/ForwardingOs.java;l=340 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/android/system/StructPasswd.java + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:bionic/libc/bionic/grp_pwd.cpp;l=553 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:system/core/libcutils/include/private/android_filesystem_config.h;l=43 + * + * @param uid The user id. + * @return Returns the user name if found, otherwise {@code null}. + */ + @Nullable + public static String getNameForUidFromLibcore(int uid) { + if (uid < 0) return null; + + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + String libcoreClassName = "libcore.io.Libcore"; + Class clazz = Class.forName(libcoreClassName); + Object os; // libcore.io.BlockGuardOs + try { + os = ReflectionUtils.invokeField(Class.forName(libcoreClassName), "os", null).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"os\" field value for " + libcoreClassName + " class", e); + return null; + } + + if (os == null) { + Logger.logError(LOG_TAG, "Failed to get BlockGuardOs class obj from Libcore"); + return null; + } + + clazz = os.getClass().getSuperclass(); // libcore.io.ForwardingOs + if (clazz == null) { + Logger.logError(LOG_TAG, "Failed to find super class ForwardingOs from object of class " + os.getClass().getName()); + return null; + } + + Object structPasswd; // android.system.StructPasswd + try { + Method getpwuidMethod = ReflectionUtils.getDeclaredMethod(clazz, "getpwuid", int.class); + if (getpwuidMethod == null) return null; + structPasswd = ReflectionUtils.invokeMethod(getpwuidMethod, os, uid).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke getpwuid() method of " + clazz.getName() + " class", e); + return null; + } + + if (structPasswd == null) { + Logger.logError(LOG_TAG, "Failed to get StructPasswd obj from call to ForwardingOs.getpwuid()"); + return null; + } + + try { + clazz = structPasswd.getClass(); + return (String) ReflectionUtils.invokeField(clazz, "pw_name", structPasswd).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"pw_name\" field value for " + clazz.getName() + " class", e); + return null; + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get name for uid \"" + uid + "\" from Libcore", e); + return null; + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java new file mode 100644 index 000000000..67cfb662d --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java @@ -0,0 +1,136 @@ +package com.termux.shared.android.resource; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; + +public class ResourceUtils { + + public static final String RES_TYPE_COLOR = "color"; + public static final String RES_TYPE_DRAWABLE = "drawable"; + public static final String RES_TYPE_ID = "id"; + public static final String RES_TYPE_LAYOUT = "layout"; + public static final String RES_TYPE_STRING = "string"; + public static final String RES_TYPE_STYLE = "style"; + + + private static final String LOG_TAG = "ResourceUtils"; + + + /** Wrapper for {@link #getResourceId(Context, String, String, String, boolean)} without {@code defPackage}. */ + @Nullable + public static Integer getResourceId(@NonNull Context context, String name, + @Nullable String defType, + boolean logErrorMessage) { + return getResourceId(context, name, defType, null, logErrorMessage); + } + + /** + * Get resource identifier for the given resource name. A fully qualified resource name is of + * the form "package:type/entry". The first two components (package and type) are optional if + * defType and defPackage, respectively, are specified here. + * + * @param context The {@link Context} for operations. + * @param name The name of the desired resource. + * @param defType Optional default resource type to find, if "type/" is not included in the name. + * Can be null to require an explicit type. + * @param defPackage Optional default package to find, if "package:" is not included in the name. + * Can be null to require an explicit package. + * @param logErrorMessage If an error message should be logged if failed to find resource. + * @return Returns the resource identifier if found. Otherwise {@code null} if an exception was + * raised or resource was not found. + */ + @Nullable + public static Integer getResourceId(@NonNull Context context, String name, + @Nullable String defType, @Nullable String defPackage, + boolean logErrorMessage) { + if (DataUtils.isNullOrEmpty(name)) return null; + + Integer resourceId = null; + try { + resourceId = context.getResources().getIdentifier(name, defType, defPackage); + if (resourceId == 0) resourceId = null; + } catch (Exception e) { + // Ignore + } + + if (resourceId == null && logErrorMessage) { + Logger.logError(LOG_TAG, "Resource id not found. name: \"" + name + "\", type: \"" + defType+ "\", package: \"" + defPackage + "\", component \"" + context.getClass().getName() + "\""); + } + + return resourceId; + } + + + + /** + * Get resource identifier for the given {@link #RES_TYPE_COLOR} resource name. + * + * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}. + */ + @Nullable + public static Integer getColorResourceId(@NonNull Context context, String name, + @Nullable String defPackage, boolean logErrorMessage) { + return getResourceId(context, name, RES_TYPE_COLOR, defPackage, logErrorMessage); + } + + /** + * Get resource identifier for the given {@link #RES_TYPE_DRAWABLE} resource name. + * + * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}. + */ + @Nullable + public static Integer getDrawableResourceId(@NonNull Context context, String name, + @Nullable String defPackage, boolean logErrorMessage) { + return getResourceId(context, name, RES_TYPE_DRAWABLE, defPackage, logErrorMessage); + } + + /** + * Get resource identifier for the given {@link #RES_TYPE_ID} resource name. + * + * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}. + */ + @Nullable + public static Integer getIdResourceId(@NonNull Context context, String name, + @Nullable String defPackage, boolean logErrorMessage) { + return getResourceId(context, name, RES_TYPE_ID, defPackage, logErrorMessage); + } + + /** + * Get resource identifier for the given {@link #RES_TYPE_LAYOUT} resource name. + * + * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}. + */ + @Nullable + public static Integer getLayoutResourceId(@NonNull Context context, String name, + @Nullable String defPackage, boolean logErrorMessage) { + return getResourceId(context, name, RES_TYPE_LAYOUT, defPackage, logErrorMessage); + } + + /** + * Get resource identifier for the given {@link #RES_TYPE_STRING} resource name. + * + * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}. + */ + @Nullable + public static Integer getStringResourceId(@NonNull Context context, String name, + @Nullable String defPackage, boolean logErrorMessage) { + return getResourceId(context, name, RES_TYPE_STRING, defPackage, logErrorMessage); + } + + /** + * Get resource identifier for the given {@link #RES_TYPE_STYLE} resource name. + * + * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}. + */ + @Nullable + public static Integer getStyleResourceId(@NonNull Context context, String name, + @Nullable String defPackage, boolean logErrorMessage) { + return getResourceId(context, name, RES_TYPE_STYLE, defPackage, logErrorMessage); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java b/termux/termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java new file mode 100644 index 000000000..f67a76128 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java @@ -0,0 +1,158 @@ +package com.termux.shared.crash; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.android.AndroidUtils; + +import java.nio.charset.Charset; + +/** + * Catches uncaught exceptions and logs them. + */ +public class CrashHandler implements Thread.UncaughtExceptionHandler { + + private final Context mContext; + private final CrashHandlerClient mCrashHandlerClient; + private final Thread.UncaughtExceptionHandler mDefaultUEH; + private final boolean mIsDefaultHandler; + + private static final String LOG_TAG = "CrashUtils"; + + private CrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient, + boolean isDefaultHandler) { + mContext = context; + mCrashHandlerClient = crashHandlerClient; + mDefaultUEH = Thread.getDefaultUncaughtExceptionHandler(); + mIsDefaultHandler = isDefaultHandler; + } + + public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { + Logger.logInfo(LOG_TAG, "uncaughtException() for " + thread + ": " + throwable.getMessage()); + logCrash(thread, throwable); + + // Don't stop the app if not on the main thread + if (mIsDefaultHandler) + mDefaultUEH.uncaughtException(thread, throwable); + } + + /** + * Set default uncaught crash handler for the app to {@link CrashHandler}. + */ + public static void setDefaultCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) { + if (!(Thread.getDefaultUncaughtExceptionHandler() instanceof CrashHandler)) { + Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(context, crashHandlerClient, true)); + } + } + + /** + * Set uncaught crash handler of current non-main thread to {@link CrashHandler}. + */ + public static void setCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) { + Thread.currentThread().setUncaughtExceptionHandler(new CrashHandler(context, crashHandlerClient, false)); + } + + /** + * Get {@link CrashHandler} instance that can be set as uncaught crash handler of a non-main thread. + */ + public static CrashHandler getCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) { + return new CrashHandler(context, crashHandlerClient, false); + } + + /** + * Log a crash in the crash log file at path returned by {@link CrashHandlerClient#getCrashLogFilePath(Context)}. + * + * @param context The {@link Context} for operations. + * @param crashHandlerClient The {@link CrashHandlerClient} implementation. + * @param thread The {@link Thread} in which the crash happened. + * @param throwable The {@link Throwable} thrown for the crash. + */ + public static void logCrash(@NonNull Context context, + @NonNull CrashHandlerClient crashHandlerClient, + @NonNull Thread thread, @NonNull Throwable throwable) { + Logger.logInfo(LOG_TAG, "logCrash() for " + thread + ": " + throwable.getMessage()); + new CrashHandler(context, crashHandlerClient, false).logCrash(thread, throwable); + } + + public void logCrash(@NonNull Thread thread, @NonNull Throwable throwable) { + if (!mCrashHandlerClient.onPreLogCrash(mContext, thread, throwable)) { + logCrashToFile(mContext, mCrashHandlerClient, thread, throwable); + mCrashHandlerClient.onPostLogCrash(mContext, thread, throwable); + } + } + + public void logCrashToFile(@NonNull Context context, + @NonNull CrashHandlerClient crashHandlerClient, + @NonNull Thread thread, @NonNull Throwable throwable) { + StringBuilder reportString = new StringBuilder(); + + reportString.append("## Crash Details\n"); + reportString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Crash Thread", thread.toString(), "-")); + reportString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Crash Timestamp", AndroidUtils.getCurrentMilliSecondUTCTimeStamp(), "-")); + reportString.append("\n\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Crash Message", throwable.getMessage(), "-")); + reportString.append("\n\n").append(Logger.getStackTracesMarkdownString("Stacktrace", Logger.getStackTracesStringArray(throwable))); + + String appInfoMarkdownString = crashHandlerClient.getAppInfoMarkdownString(context); + if (appInfoMarkdownString != null && !appInfoMarkdownString.isEmpty()) + reportString.append("\n\n").append(appInfoMarkdownString); + + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context)); + + // Log report string to logcat + Logger.logError(reportString.toString()); + + // Write report string to crash log file + Error error = FileUtils.writeTextToFile("crash log", crashHandlerClient.getCrashLogFilePath(context), + Charset.defaultCharset(), reportString.toString(), false); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + } + } + + public interface CrashHandlerClient { + + /** + * Called before {@link #logCrashToFile(Context, CrashHandlerClient, Thread, Throwable)} is called. + * + * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}. + * @param thread The {@link Thread} in which the crash happened. + * @param throwable The {@link Throwable} thrown for the crash. + * @return Should return {@code true} if crash has been handled and should not be logged, + * otherwise {@code false}. + */ + boolean onPreLogCrash(Context context, Thread thread, Throwable throwable); + + /** + * Called after {@link #logCrashToFile(Context, CrashHandlerClient, Thread, Throwable)} is called. + * + * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}. + * @param thread The {@link Thread} in which the crash happened. + * @param throwable The {@link Throwable} thrown for the crash. + */ + void onPostLogCrash(Context context, Thread thread, Throwable throwable); + + /** + * Get crash log file path. + * + * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}. + * @return Should return the crash log file path. + */ + @NonNull + String getCrashLogFilePath(Context context); + + /** + * Get app info markdown string to add to crash log. + * + * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}. + * @return Should return app info markdown string. + */ + String getAppInfoMarkdownString(Context context); + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/data/DataUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/data/DataUtils.java new file mode 100644 index 000000000..f95f2b52f --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/data/DataUtils.java @@ -0,0 +1,258 @@ +package com.termux.shared.data; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.base.Strings; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Collections; + +public class DataUtils { + + /** Max safe limit of data size to prevent TransactionTooLargeException when transferring data + * inside or to other apps via transactions. */ + public static final int TRANSACTION_SIZE_LIMIT_IN_BYTES = 100 * 1024; // 100KB + + private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + public static String getTruncatedCommandOutput(String text, int maxLength, boolean fromEnd, boolean onNewline, boolean addPrefix) { + if (text == null) return null; + + String prefix = "(truncated) "; + + if (addPrefix) + maxLength = maxLength - prefix.length(); + + if (maxLength < 0 || text.length() < maxLength) return text; + + if (fromEnd) { + text = text.substring(0, maxLength); + } else { + int cutOffIndex = text.length() - maxLength; + + if (onNewline) { + int nextNewlineIndex = text.indexOf('\n', cutOffIndex); + if (nextNewlineIndex != -1 && nextNewlineIndex != text.length() - 1) { + cutOffIndex = nextNewlineIndex + 1; + } + } + text = text.substring(cutOffIndex); + } + + if (addPrefix) + text = prefix + text; + + return text; + } + + /** + * Replace a sub string in each item of a {@link String[]}. + * + * @param array The {@link String[]} to replace in. + * @param find The sub string to replace. + * @param replace The sub string to replace with. + */ + public static void replaceSubStringsInStringArrayItems(String[] array, String find, String replace) { + if(array == null || array.length == 0) return; + + for (int i = 0; i < array.length; i++) { + array[i] = array[i].replace(find, replace); + } + } + + /** + * Get the {@code float} from a {@link String}. + * + * @param value The {@link String} value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code float} value after parsing the {@link String} value, otherwise + * returns default if failed to read a valid value, like in case of an exception. + */ + public static float getFloatFromString(String value, float def) { + if (value == null) return def; + + try { + return Float.parseFloat(value); + } + catch (Exception e) { + return def; + } + } + + /** + * Get the {@code int} from a {@link String}. + * + * @param value The {@link String} value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code int} value after parsing the {@link String} value, otherwise + * returns default if failed to read a valid value, like in case of an exception. + */ + public static int getIntFromString(String value, int def) { + if (value == null) return def; + + try { + return Integer.parseInt(value); + } + catch (Exception e) { + return def; + } + } + + /** + * Get the {@code String} from an {@link Integer}. + * + * @param value The {@link Integer} value. + * @param def The default {@link String} value. + * @return Returns {@code value} if it is not {@code null}, otherwise returns {@code def}. + */ + public static String getStringFromInteger(Integer value, String def) { + return (value == null) ? def : String.valueOf((int) value); + } + + /** + * Get the {@code hex string} from a {@link byte[]}. + * + * @param bytes The {@link byte[]} value. + * @return Returns the {@code hex string} value. + */ + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + /** + * Get an {@code int} from {@link Bundle} that is stored as a {@link String}. + * + * @param bundle The {@link Bundle} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code int} value after parsing the {@link String} value stored in + * {@link Bundle}, otherwise returns default if failed to read a valid value, + * like in case of an exception. + */ + public static int getIntStoredAsStringFromBundle(Bundle bundle, String key, int def) { + if (bundle == null) return def; + return getIntFromString(bundle.getString(key, Integer.toString(def)), def); + } + + + + /** + * If value is not in the range [min, max], set it to either min or max. + */ + public static int clamp(int value, int min, int max) { + return Math.min(Math.max(value, min), max); + } + + /** + * If value is not in the range [min, max], set it to default. + */ + public static float rangedOrDefault(float value, float def, float min, float max) { + if (value < min || value > max) + return def; + else + return value; + } + + + + /** + * Add a space indent to a {@link String}. Each indent is 4 space characters long. + * + * @param string The {@link String} to add indent to. + * @param count The indent count. + * @return Returns the indented {@link String}. + */ + public static String getSpaceIndentedString(String string, int count) { + if (string == null || string.isEmpty()) + return string; + else + return getIndentedString(string, " ", count); + } + + /** + * Add a tab indent to a {@link String}. Each indent is 1 tab character long. + * + * @param string The {@link String} to add indent to. + * @param count The indent count. + * @return Returns the indented {@link String}. + */ + public static String getTabIndentedString(String string, int count) { + if (string == null || string.isEmpty()) + return string; + else + return getIndentedString(string, "\t", count); + } + + /** + * Add an indent to a {@link String}. + * + * @param string The {@link String} to add indent to. + * @param indent The indent characters. + * @param count The indent count. + * @return Returns the indented {@link String}. + */ + public static String getIndentedString(String string, @NonNull String indent, int count) { + if (string == null || string.isEmpty()) + return string; + else + return string.replaceAll("(?m)^", Strings.repeat(indent, Math.max(count, 1))); + } + + + + /** + * Get the object itself if it is not {@code null}, otherwise default. + * + * @param object The {@link Object} to check. + * @param def The default {@link Object}. + * @return Returns {@code object} if it is not {@code null}, otherwise returns {@code def}. + */ + public static T getDefaultIfNull(@Nullable T object, @Nullable T def) { + return (object == null) ? def : object; + } + + /** + * Get the {@link String} itself if it is not {@code null} or empty, otherwise default. + * + * @param value The {@link String} to check. + * @param def The default {@link String}. + * @return Returns {@code value} if it is not {@code null} or empty, otherwise returns {@code def}. + */ + public static String getDefaultIfUnset(@Nullable String value, String def) { + return (value == null || value.isEmpty()) ? def : value; + } + + /** Check if a string is null or empty. */ + public static boolean isNullOrEmpty(String string) { + return string == null || string.isEmpty(); + } + + + + /** Get size of a serializable object. */ + public static long getSerializedSize(Serializable object) { + if (object == null) return 0; + try { + ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream(); + ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteOutputStream); + objectOutputStream.writeObject(object); + objectOutputStream.flush(); + objectOutputStream.close(); + return byteOutputStream.toByteArray().length; + } catch (Exception e) { + return -1; + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java new file mode 100644 index 000000000..0c692fc35 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java @@ -0,0 +1,166 @@ +package com.termux.shared.data; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import java.util.Arrays; + +public class IntentUtils { + + private static final String LOG_TAG = "IntentUtils"; + + + /** + * Get a {@link String} extra from an {@link Intent} if its not {@code null} or empty. + * + * @param intent The {@link Intent} to get the extra from. + * @param key The {@link String} key name. + * @param def The default value if extra is not set. + * @param throwExceptionIfNotSet If set to {@code true}, then an exception will be thrown if extra + * is not set. + * @return Returns the {@link String} extra if set, otherwise {@code null}. + */ + public static String getStringExtraIfSet(@NonNull Intent intent, String key, String def, boolean throwExceptionIfNotSet) throws Exception { + String value = getStringExtraIfSet(intent, key, def); + if (value == null && throwExceptionIfNotSet) + throw new Exception("The \"" + key + "\" key string value is null or empty"); + return value; + } + + /** + * Get a {@link String} extra from an {@link Intent} if its not {@code null} or empty. + * + * @param intent The {@link Intent} to get the extra from. + * @param key The {@link String} key name. + * @param def The default value if extra is not set. + * @return Returns the {@link String} extra if set, otherwise {@code null}. + */ + public static String getStringExtraIfSet(@NonNull Intent intent, String key, String def) { + String value = intent.getStringExtra(key); + if (value == null || value.isEmpty()) { + if (def != null && !def.isEmpty()) + return def; + else + return null; + } + return value; + } + + /** + * Get an {@link Integer} from an {@link Intent} stored as a {@link String} extra if its not + * {@code null} or empty. + * + * @param intent The {@link Intent} to get the extra from. + * @param key The {@link String} key name. + * @param def The default value if extra is not set. + * @return Returns the {@link Integer} extra if set, otherwise {@code null}. + */ + public static Integer getIntegerExtraIfSet(@NonNull Intent intent, String key, Integer def) { + try { + String value = intent.getStringExtra(key); + if (value == null || value.isEmpty()) { + return def; + } + + return Integer.parseInt(value); + } + catch (Exception e) { + return def; + } + } + + + + /** + * Get a {@link String[]} extra from an {@link Intent} if its not {@code null} or empty. + * + * @param intent The {@link Intent} to get the extra from. + * @param key The {@link String} key name. + * @param def The default value if extra is not set. + * @param throwExceptionIfNotSet If set to {@code true}, then an exception will be thrown if extra + * is not set. + * @return Returns the {@link String[]} extra if set, otherwise {@code null}. + */ + public static String[] getStringArrayExtraIfSet(@NonNull Intent intent, String key, String[] def, boolean throwExceptionIfNotSet) throws Exception { + String[] value = getStringArrayExtraIfSet(intent, key, def); + if (value == null && throwExceptionIfNotSet) + throw new Exception("The \"" + key + "\" key string array is null or empty"); + return value; + } + + /** + * Get a {@link String[]} extra from an {@link Intent} if its not {@code null} or empty. + * + * @param intent The {@link Intent} to get the extra from. + * @param key The {@link String} key name. + * @param def The default value if extra is not set. + * @return Returns the {@link String[]} extra if set, otherwise {@code null}. + */ + public static String[] getStringArrayExtraIfSet(Intent intent, String key, String[] def) { + String[] value = intent.getStringArrayExtra(key); + if (value == null || value.length == 0) { + if (def != null && def.length != 0) + return def; + else + return null; + } + return value; + } + + public static String getIntentString(Intent intent) { + if (intent == null) return null; + + return intent.toString() + "\n" + getBundleString(intent.getExtras()); + } + + public static String getBundleString(Bundle bundle) { + if (bundle == null || bundle.size() == 0) return "Bundle[]"; + + StringBuilder bundleString = new StringBuilder("Bundle[\n"); + boolean first = true; + for (String key : bundle.keySet()) { + if (!first) + bundleString.append("\n"); + + bundleString.append(key).append(": `"); + + Object value = bundle.get(key); + if (value instanceof int[]) { + bundleString.append(Arrays.toString((int[]) value)); + } else if (value instanceof byte[]) { + bundleString.append(Arrays.toString((byte[]) value)); + } else if (value instanceof boolean[]) { + bundleString.append(Arrays.toString((boolean[]) value)); + } else if (value instanceof short[]) { + bundleString.append(Arrays.toString((short[]) value)); + } else if (value instanceof long[]) { + bundleString.append(Arrays.toString((long[]) value)); + } else if (value instanceof float[]) { + bundleString.append(Arrays.toString((float[]) value)); + } else if (value instanceof double[]) { + bundleString.append(Arrays.toString((double[]) value)); + } else if (value instanceof String[]) { + bundleString.append(Arrays.toString((String[]) value)); + } else if (value instanceof CharSequence[]) { + bundleString.append(Arrays.toString((CharSequence[]) value)); + } else if (value instanceof Parcelable[]) { + bundleString.append(Arrays.toString((Parcelable[]) value)); + } else if (value instanceof Bundle) { + bundleString.append(getBundleString((Bundle) value)); + } else { + bundleString.append(value); + } + + bundleString.append("`"); + + first = false; + } + + bundleString.append("\n]"); + return bundleString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/errors/Errno.java b/termux/termux-shared/src/main/java/com/termux/shared/errors/Errno.java new file mode 100644 index 000000000..5dfa745c2 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/errors/Errno.java @@ -0,0 +1,118 @@ +package com.termux.shared.errors; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +/** The {@link Class} that defines error messages and codes. */ +public class Errno { + + private static final HashMap map = new HashMap<>(); + + public static final String TYPE = "Error"; + + + public static final Errno ERRNO_SUCCESS = new Errno(TYPE, Activity.RESULT_OK, "Success"); + public static final Errno ERRNO_CANCELLED = new Errno(TYPE, Activity.RESULT_CANCELED, "Cancelled"); + public static final Errno ERRNO_MINOR_FAILURES = new Errno(TYPE, Activity.RESULT_FIRST_USER, "Minor failure"); + public static final Errno ERRNO_FAILED = new Errno(TYPE, Activity.RESULT_FIRST_USER + 1, "Failed"); + + /** The errno type. */ + protected final String type; + /** The errno code. */ + protected final int code; + /** The errno message. */ + protected final String message; + + private static final String LOG_TAG = "Errno"; + + + public Errno(@NonNull final String type, final int code, @NonNull final String message) { + this.type = type; + this.code = code; + this.message = message; + map.put(type + ":" + code, this); + } + + @NonNull + @Override + public String toString() { + return "type=" + type + ", code=" + code + ", message=\"" + message + "\""; + } + + @NonNull + public String getType() { + return type; + } + + public int getCode() { + return code; + } + + @NonNull + public String getMessage() { + return message; + } + + + + /** + * Get the {@link Errno} of a specific type and code. + * + * @param type The unique type of the {@link Errno}. + * @param code The unique code of the {@link Errno}. + */ + public static Errno valueOf(String type, Integer code) { + if (type == null || type.isEmpty() || code == null) return null; + return map.get(type + ":" + code); + } + + + + public Error getError() { + return new Error(getType(), getCode(), getMessage()); + } + + public Error getError(Object... args) { + try { + return new Error(getType(), getCode(), String.format(getMessage(), args)); + } catch (Exception e) { + Logger.logWarn(LOG_TAG, "Exception raised while calling String.format() for error message of errno " + this + " with args" + Arrays.toString(args) + "\n" + e.getMessage()); + // Return unformatted message as a backup + return new Error(getType(), getCode(), getMessage() + ": " + Arrays.toString(args)); + } + } + + public Error getError(Throwable throwable, Object... args) { + if (throwable == null) + return getError(args); + else + return getError(Collections.singletonList(throwable), args); + } + + public Error getError(List throwablesList, Object... args) { + try { + if (throwablesList == null) + return new Error(getType(), getCode(), String.format(getMessage(), args)); + else + return new Error(getType(), getCode(), String.format(getMessage(), args), throwablesList); + } catch (Exception e) { + Logger.logWarn(LOG_TAG, "Exception raised while calling String.format() for error message of errno " + this + " with args" + Arrays.toString(args) + "\n" + e.getMessage()); + // Return unformatted message as a backup + return new Error(getType(), getCode(), getMessage() + ": " + Arrays.toString(args), throwablesList); + } + } + + public boolean equalsErrorTypeAndCode(Error error) { + if (error == null) return false; + return type.equals(error.getType()) && code == error.getCode(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/errors/Error.java b/termux/termux-shared/src/main/java/com/termux/shared/errors/Error.java new file mode 100644 index 000000000..be00e34ff --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/errors/Error.java @@ -0,0 +1,298 @@ +package com.termux.shared.errors; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Error implements Serializable { + + /** The optional error label. */ + private String label; + /** The error type. */ + private String type; + /** The error code. */ + private int code; + /** The error message. */ + private String message; + /** The error exceptions. */ + private List throwablesList = new ArrayList<>(); + + private static final String LOG_TAG = "Error"; + + + public Error() { + InitError(null, null, null, null); + } + + public Error(String type, Integer code, String message, List throwablesList) { + InitError(type, code, message, throwablesList); + } + + public Error(String type, Integer code, String message, Throwable throwable) { + InitError(type, code, message, Collections.singletonList(throwable)); + } + + public Error(String type, Integer code, String message) { + InitError(type, code, message, null); + } + + public Error(Integer code, String message, List throwablesList) { + InitError(null, code, message, throwablesList); + } + + public Error(Integer code, String message, Throwable throwable) { + InitError(null, code, message, Collections.singletonList(throwable)); + } + + public Error(Integer code, String message) { + InitError(null, code, message, null); + } + + public Error(String message, Throwable throwable) { + InitError(null, null, message, Collections.singletonList(throwable)); + } + + public Error(String message, List throwablesList) { + InitError(null, null, message, throwablesList); + } + + public Error(String message) { + InitError(null, null, message, null); + } + + private void InitError(String type, Integer code, String message, List throwablesList) { + if (type != null && !type.isEmpty()) + this.type = type; + else + this.type = Errno.TYPE; + + if (code != null && code > Errno.ERRNO_SUCCESS.getCode()) + this.code = code; + else + this.code = Errno.ERRNO_SUCCESS.getCode(); + + this.message = message; + + if (throwablesList != null) + this.throwablesList = throwablesList; + } + + public Error setLabel(String label) { + this.label = label; + return this; + } + + public String getLabel() { + return label; + } + + + public String getType() { + return type; + } + + public Integer getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public void prependMessage(String message) { + if (message != null && isStateFailed()) + this.message = message + this.message; + } + + public void appendMessage(String message) { + if (message != null && isStateFailed()) + this.message = this.message + message; + } + + public List getThrowablesList() { + return Collections.unmodifiableList(throwablesList); + } + + + public synchronized boolean setStateFailed(@NonNull Error error) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), null); + } + + public synchronized boolean setStateFailed(@NonNull Error error, Throwable throwable) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), Collections.singletonList(throwable)); + } + public synchronized boolean setStateFailed(@NonNull Error error, List throwablesList) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), throwablesList); + } + + public synchronized boolean setStateFailed(int code, String message) { + return setStateFailed(this.type, code, message, null); + } + + public synchronized boolean setStateFailed(int code, String message, Throwable throwable) { + return setStateFailed(this.type, code, message, Collections.singletonList(throwable)); + } + + public synchronized boolean setStateFailed(int code, String message, List throwablesList) { + return setStateFailed(this.type, code, message, throwablesList); + } + + public synchronized boolean setStateFailed(String type, int code, String message, List throwablesList) { + this.message = message; + this.throwablesList = throwablesList; + + if (type != null && !type.isEmpty()) + this.type = type; + + if (code > Errno.ERRNO_SUCCESS.getCode()) { + this.code = code; + return true; + } else { + Logger.logWarn(LOG_TAG, "Ignoring invalid error code value \"" + code + "\". Force setting it to RESULT_CODE_FAILED \"" + Errno.ERRNO_FAILED.getCode() + "\""); + this.code = Errno.ERRNO_FAILED.getCode(); + return false; + } + } + + public boolean isStateFailed() { + return code > Errno.ERRNO_SUCCESS.getCode(); + } + + + @NonNull + @Override + public String toString() { + return getErrorLogString(this); + } + + + + /** + * Log the {@link Error} and show a toast for the minimal {@link String} for the {@link Error}. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param error The {@link Error} to convert. + */ + public static void logErrorAndShowToast(Context context, String logTag, Error error) { + if (error == null) return; + error.logErrorAndShowToast(context, logTag); + } + + public void logErrorAndShowToast(Context context, String logTag) { + Logger.logErrorExtended(logTag, getErrorLogString()); + Logger.showToast(context, getMinimalErrorLogString(), true); + } + + + /** + * Get a log friendly {@link String} for {@link Error} error parameters. + * + * @param error The {@link Error} to convert. + * @return Returns the log friendly {@link String}. + */ + public static String getErrorLogString(final Error error) { + if (error == null) return "null"; + return error.getErrorLogString(); + } + + public String getErrorLogString() { + StringBuilder logString = new StringBuilder(); + + logString.append(getCodeString()); + logString.append("\n").append(getTypeAndMessageLogString()); + if (throwablesList != null && throwablesList.size() > 0) + logString.append("\n").append(geStackTracesLogString()); + + return logString.toString(); + } + + /** + * Get a minimal log friendly {@link String} for {@link Error} error parameters. + * + * @param error The {@link Error} to convert. + * @return Returns the log friendly {@link String}. + */ + public static String getMinimalErrorLogString(final Error error) { + if (error == null) return "null"; + return error.getMinimalErrorLogString(); + } + + public String getMinimalErrorLogString() { + StringBuilder logString = new StringBuilder(); + + logString.append(getCodeString()); + logString.append(getTypeAndMessageLogString()); + + return logString.toString(); + } + + /** + * Get a minimal {@link String} for {@link Error} error parameters. + * + * @param error The {@link Error} to convert. + * @return Returns the {@link String}. + */ + public static String getMinimalErrorString(final Error error) { + if (error == null) return "null"; + return error.getMinimalErrorString(); + } + + public String getMinimalErrorString() { + StringBuilder logString = new StringBuilder(); + + logString.append("(").append(getCode()).append(") "); + logString.append(getType()).append(": ").append(getMessage()); + + return logString.toString(); + } + + /** + * Get a markdown {@link String} for {@link Error}. + * + * @param error The {@link Error} to convert. + * @return Returns the markdown {@link String}. + */ + public static String getErrorMarkdownString(final Error error) { + if (error == null) return "null"; + return error.getErrorMarkdownString(); + } + + public String getErrorMarkdownString() { + StringBuilder markdownString = new StringBuilder(); + + markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Error Code", getCode(), "-")); + markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry( + (Errno.TYPE.equals(getType()) ? "Error Message" : "Error Message (" + getType() + ")"), message, "-")); + if (throwablesList != null && throwablesList.size() > 0) + markdownString.append("\n\n").append(geStackTracesMarkdownString()); + + return markdownString.toString(); + } + + + public String getCodeString() { + return Logger.getSingleLineLogStringEntry("Error Code", code, "-"); + } + + public String getTypeAndMessageLogString() { + return Logger.getMultiLineLogStringEntry(Errno.TYPE.equals(type) ? "Error Message" : "Error Message (" + type + ")", message, "-"); + } + + public String geStackTracesLogString() { + return Logger.getStackTracesString("StackTraces:", Logger.getStackTracesStringArray(throwablesList)); + } + + public String geStackTracesMarkdownString() { + return Logger.getStackTracesMarkdownString("StackTraces", Logger.getStackTracesStringArray(throwablesList)); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java new file mode 100644 index 000000000..533a9f5c1 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java @@ -0,0 +1,22 @@ +package com.termux.shared.errors; + +/** The {@link Class} that defines function error messages and codes. */ +public class FunctionErrno extends Errno { + + public static final String TYPE = "Function Error"; + + + /* Errors for null or empty parameters (100-150) */ + public static final Errno ERRNO_NULL_OR_EMPTY_PARAMETER = new Errno(TYPE, 100, "The %1$s parameter passed to \"%2$s\" is null or empty."); + public static final Errno ERRNO_NULL_OR_EMPTY_PARAMETERS = new Errno(TYPE, 101, "The %1$s parameters passed to \"%2$s\" are null or empty."); + public static final Errno ERRNO_UNSET_PARAMETER = new Errno(TYPE, 102, "The %1$s parameter passed to \"%2$s\" must be set."); + public static final Errno ERRNO_UNSET_PARAMETERS = new Errno(TYPE, 103, "The %1$s parameters passed to \"%2$s\" must be set."); + public static final Errno ERRNO_INVALID_PARAMETER = new Errno(TYPE, 104, "The %1$s parameter passed to \"%2$s\" is invalid.\"%3$s\""); + public static final Errno ERRNO_PARAMETER_NOT_INSTANCE_OF = new Errno(TYPE, 104, "The %1$s parameter passed to \"%2$s\" is not an instance of %3$s."); + + + FunctionErrno(final String type, final int code, final String message) { + super(type, code, message); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtils.java new file mode 100644 index 000000000..365613fe9 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtils.java @@ -0,0 +1,2002 @@ +package com.termux.shared.file; + +import android.os.Build; +import android.system.Os; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.termux.shared.data.DataUtils; +import com.termux.shared.errors.Errno; +import com.termux.shared.errors.Error; +import com.termux.shared.errors.FunctionErrno; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.file.filesystem.FileTypes; +import com.termux.shared.logger.Logger; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OutputStreamWriter; +import java.io.Serializable; +import java.nio.charset.Charset; +import java.nio.file.LinkOption; +import java.nio.file.StandardCopyOption; +import java.util.Calendar; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; +import kotlin.io.path.PathsKt; +import org.apache.commons.io.filefilter.AgeFileFilter; +import org.apache.commons.io.filefilter.IOFileFilter; + +public class FileUtils { + + /** Required file permissions for the executable file for app usage. Executable file must have read and execute permissions */ + public static final String APP_EXECUTABLE_FILE_PERMISSIONS = "r-x"; // Default: "r-x" + /** Required file permissions for the working directory for app usage. Working directory must have read and write permissions. + * Execute permissions should be attempted to be set, but ignored if they are missing */ + public static final String APP_WORKING_DIRECTORY_PERMISSIONS = "rwx"; // Default: "rwx" + + private static final String LOG_TAG = "FileUtils"; + + /** + * Get canonical path. + * + * If path is already an absolute path, then it is used as is to get canonical path. + * If path is not an absolute path and {code prefixForNonAbsolutePath} is not {@code null}, then + * {code prefixForNonAbsolutePath} + "/" is prefixed before path before getting canonical path. + * If path is not an absolute path and {code prefixForNonAbsolutePath} is {@code null}, then + * "/" is prefixed before path before getting canonical path. + * + * If an exception is raised to get the canonical path, then absolute path is returned. + * + * @param path The {@code path} to convert. + * @param prefixForNonAbsolutePath Optional prefix path to prefix before non-absolute paths. This + * can be set to {@code null} if non-absolute paths should + * be prefixed with "/". The call to {@link File#getCanonicalPath()} + * will automatically do this anyways. + * @return Returns the {@code canonical path}. + */ + public static String getCanonicalPath(String path, final String prefixForNonAbsolutePath) { + if (path == null) path = ""; + + String absolutePath; + + // If path is already an absolute path + if (path.startsWith("/")) { + absolutePath = path; + } else { + if (prefixForNonAbsolutePath != null) + absolutePath = prefixForNonAbsolutePath + "/" + path; + else + absolutePath = "/" + path; + } + + try { + return new File(absolutePath).getCanonicalPath(); + } catch(Exception e) { + } + + return absolutePath; + } + + /** + * Removes one or more forward slashes "//" with single slash "/" + * Removes "./" + * Removes trailing forward slash "/" + * + * @param path The {@code path} to convert. + * @return Returns the {@code normalized path}. + */ + @Nullable + public static String normalizePath(String path) { + if (path == null) return null; + + path = path.replaceAll("/+", "/"); + path = path.replaceAll("\\./", ""); + + if (path.endsWith("/")) { + path = path.replaceAll("/+$", ""); + } + + return path; + } + + /** + * Convert special characters `\/:*?"<>|` to underscore. + * + * @param fileName The name to sanitize. + * @param sanitizeWhitespaces If set to {@code true}, then white space characters ` \t\n` will be + * converted. + * @param toLower If set to {@code true}, then file name will be converted to lower case. + * @return Returns the {@code sanitized name}. + */ + public static String sanitizeFileName(String fileName, boolean sanitizeWhitespaces, boolean toLower) { + if (fileName == null) return null; + + if (sanitizeWhitespaces) + fileName = fileName.replaceAll("[\\\\/:*?\"<>| \t\n]", "_"); + else + fileName = fileName.replaceAll("[\\\\/:*?\"<>|]", "_"); + + if (toLower) + return fileName.toLowerCase(); + else + return fileName; + } + + /** + * Determines whether path is in {@code dirPath}. The {@code dirPath} is not canonicalized and + * only normalized. + * + * @param path The {@code path} to check. + * @param dirPath The {@code directory path} to check in. + * @param ensureUnder If set to {@code true}, then it will be ensured that {@code path} is + * under the directory and does not equal it. + * @return Returns {@code true} if path in {@code dirPath}, otherwise returns {@code false}. + */ + public static boolean isPathInDirPath(String path, final String dirPath, final boolean ensureUnder) { + return isPathInDirPaths(path, Collections.singletonList(dirPath), ensureUnder); + } + + /** + * Determines whether path is in one of the {@code dirPaths}. The {@code dirPaths} are not + * canonicalized and only normalized. + * + * @param path The {@code path} to check. + * @param dirPaths The {@code directory paths} to check in. + * @param ensureUnder If set to {@code true}, then it will be ensured that {@code path} is + * under the directories and does not equal it. + * @return Returns {@code true} if path in {@code dirPaths}, otherwise returns {@code false}. + */ + public static boolean isPathInDirPaths(String path, final List dirPaths, final boolean ensureUnder) { + if (path == null || path.isEmpty() || dirPaths == null || dirPaths.size() < 1) return false; + + try { + path = new File(path).getCanonicalPath(); + } catch(Exception e) { + return false; + } + + boolean isPathInDirPaths; + + for (String dirPath : dirPaths) { + String normalizedDirPath = normalizePath(dirPath); + + if (ensureUnder) + isPathInDirPaths = !path.equals(normalizedDirPath) && path.startsWith(normalizedDirPath + "/"); + else + isPathInDirPaths = path.startsWith(normalizedDirPath + "/"); + + if (isPathInDirPaths) return true; + } + + return false; + } + + + + /** + * Validate that directory is empty or contains only files in {@code ignoredSubFilePaths}. + * + * If parent path of an ignored file exists, but ignored file itself does not exist, then directory + * is not considered empty. + * + * @param label The optional label for directory to check. This can optionally be {@code null}. + * @param filePath The {@code path} for directory to check. + * @param ignoredSubFilePaths The list of absolute file paths under {@code filePath} dir. + * Validation is done for the paths. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to be checked doesn't exist. + * @return Returns {@code null} if directory is empty or contains only files in {@code ignoredSubFilePaths}. + * Returns {@code FileUtilsErrno#ERRNO_NON_EMPTY_DIRECTORY_FILE} if a file was found that did not + * exist in the {@code ignoredSubFilePaths}, otherwise returns an appropriate {@code error} if + * checking was not successful. + */ + public static Error validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(String label, String filePath, + final List ignoredSubFilePaths, + final boolean ignoreNonExistentFile) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "isDirectoryFileEmptyOrOnlyContainsSpecificFiles"); + + try { + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a directory file + if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) { + return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory"); + } + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If checking is to be ignored if file does not exist + if (ignoreNonExistentFile) + return null; + else { + label += "directory to check if is empty or only contains specific files"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label); + } + } + + File[] subFiles = file.listFiles(); + if (subFiles == null || subFiles.length == 0) + return null; + + // If sub files exists but no file should be ignored + if (ignoredSubFilePaths == null || ignoredSubFilePaths.size() == 0) + return FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE.getError(label, filePath); + + // If a sub file does not exist in ignored file path + if (nonIgnoredSubFileExists(subFiles, ignoredSubFilePaths)) { + return FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE.getError(label, filePath); + } + + } catch (Exception e) { + return FileUtilsErrno.ERRNO_VALIDATE_DIRECTORY_EMPTY_OR_ONLY_CONTAINS_SPECIFIC_FILES_FAILED_WITH_EXCEPTION.getError(e, label + "directory", filePath, e.getMessage()); + } + + return null; + } + + /** + * Check if {@code subFiles} contains contains a file not in {@code ignoredSubFilePaths}. + * + * If parent path of an ignored file exists, but ignored file itself does not exist, then directory + * is not considered empty. + * + * This function should ideally not be called by itself but through + * {@link #validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(String, String, List, boolean)}. + * + * @param subFiles The list of files of a directory to check. + * @param ignoredSubFilePaths The list of absolute file paths under {@code filePath} dir. + * Validation is done for the paths. + * @return Returns {@code true} if a file was found that did not exist in the {@code ignoredSubFilePaths}, + * otherwise {@code false}. + */ + public static boolean nonIgnoredSubFileExists(File[] subFiles, @NonNull List ignoredSubFilePaths) { + if (subFiles == null || subFiles.length == 0) return false; + + String subFilePath; + for (File subFile : subFiles) { + subFilePath = subFile.getAbsolutePath(); + // If sub file does not exist in ignored sub file paths + if (!ignoredSubFilePaths.contains(subFilePath)) { + boolean isParentPath = false; + for (String ignoredSubFilePath : ignoredSubFilePaths) { + if (ignoredSubFilePath.startsWith(subFilePath + "/") && fileExists(ignoredSubFilePath, false)) { + isParentPath = true; + break; + } + } + // If sub file is not a parent of any existing ignored sub file paths + if (!isParentPath) { + return true; + } + } + + if (getFileType(subFilePath, false) == FileType.DIRECTORY) { + // If non ignored sub file found, then early exit, otherwise continue looking + if (nonIgnoredSubFileExists(subFile.listFiles(), ignoredSubFilePaths)) + return true; + } + } + + return false; + } + + + + /** + * Checks whether a regular file exists at {@code filePath}. + * + * @param filePath The {@code path} for regular file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if regular file exists, otherwise {@code false}. + */ + public static boolean regularFileExists(final String filePath, final boolean followLinks) { + return getFileType(filePath, followLinks) == FileType.REGULAR; + } + + /** + * Checks whether a directory file exists at {@code filePath}. + * + * @param filePath The {@code path} for directory file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if directory file exists, otherwise {@code false}. + */ + public static boolean directoryFileExists(final String filePath, final boolean followLinks) { + return getFileType(filePath, followLinks) == FileType.DIRECTORY; + } + + /** + * Checks whether a symlink file exists at {@code filePath}. + * + * @param filePath The {@code path} for symlink file to check. + * @return Returns {@code true} if symlink file exists, otherwise {@code false}. + */ + public static boolean symlinkFileExists(final String filePath) { + return getFileType(filePath, false) == FileType.SYMLINK; + } + + /** + * Checks whether a regular or directory file exists at {@code filePath}. + * + * @param filePath The {@code path} for regular file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if regular or directory file exists, otherwise {@code false}. + */ + public static boolean regularOrDirectoryFileExists(final String filePath, final boolean followLinks) { + FileType fileType = getFileType(filePath, followLinks); + return fileType == FileType.REGULAR || fileType == FileType.DIRECTORY; + } + + /** + * Checks whether any file exists at {@code filePath}. + * + * @param filePath The {@code path} for file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if file exists, otherwise {@code false}. + */ + public static boolean fileExists(final String filePath, final boolean followLinks) { + return getFileType(filePath, followLinks) != FileType.NO_EXIST; + } + + /** + * Get the type of file that exists at {@code filePath}. + * + * This function is a wrapper for + * {@link FileTypes#getFileType(String, boolean)} + * + * @param filePath The {@code path} for file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding type. If set to {@code true}, then type of symlink target will + * be returned if file at {@code filePath} is a symlink. If set to + * {@code false}, then type of file at {@code filePath} itself will be + * returned. + * @return Returns the {@link FileType} of file. + */ + @NonNull + public static FileType getFileType(final String filePath, final boolean followLinks) { + return FileTypes.getFileType(filePath, followLinks); + } + + + + /** + * Validate the existence and permissions of regular file at path. + * + * If the {@code parentDirPath} is not {@code null}, then setting of missing permissions will + * only be done if {@code path} is under {@code parentDirPath}. + * + * @param label The optional label for the regular file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to validate. Symlinks will not be followed. + * @param parentDirPath The optional {@code parent directory path} to restrict operations to. + * This can optionally be {@code null}. It is not canonicalized and only normalized. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @param ignoreErrorsIfPathIsUnderParentDirPath The {@code boolean} that decides if permission + * errors are to be ignored if path is under + * {@code parentDirPath}. + * @return Returns the {@code error} if path is not a regular file, or validating permissions + * failed, otherwise {@code null}. + */ + public static Error validateRegularFileExistenceAndPermissions(String label, final String filePath, final String parentDirPath, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly, + final boolean ignoreErrorsIfPathIsUnderParentDirPath) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "regular file path", "validateRegularFileExistenceAndPermissions"); + + try { + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file"); + } + + boolean isPathUnderParentDirPath = false; + if (parentDirPath != null) { + // The path can only be under parent directory path + isPathUnderParentDirPath = isPathInDirPath(filePath, parentDirPath, true); + } + + // If setPermissions is enabled and path is a regular file + if (setPermissions && permissionsToCheck != null && fileType == FileType.REGULAR) { + // If there is not parentDirPath restriction or path is under parentDirPath + if (parentDirPath == null || (isPathUnderParentDirPath && getFileType(parentDirPath, false) == FileType.DIRECTORY)) { + if (setMissingPermissionsOnly) + setMissingFilePermissions(label + "file", filePath, permissionsToCheck); + else + setFilePermissions(label + "file", filePath, permissionsToCheck); + } + } + + // If path is not a regular file + // Regular files cannot be automatically created so we do not ignore if missing + if (fileType != FileType.REGULAR) { + label += "regular file"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label); + } + + // If there is not parentDirPath restriction or path is not under parentDirPath or + // if permission errors must not be ignored for paths under parentDirPath + if (parentDirPath == null || !isPathUnderParentDirPath || !ignoreErrorsIfPathIsUnderParentDirPath) { + if (permissionsToCheck != null) { + // Check if permissions are missing + return checkMissingFilePermissions(label + "regular", filePath, permissionsToCheck, false); + } + } + } catch (Exception e) { + return FileUtilsErrno.ERRNO_VALIDATE_FILE_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()); + } + + return null; + + } + + /** + * Validate the existence and permissions of directory file at path. + * + * If the {@code parentDirPath} is not {@code null}, then creation of missing directory and + * setting of missing permissions will only be done if {@code path} is under + * {@code parentDirPath} or equals {@code parentDirPath}. + * + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to validate or create. Symlinks will not be followed. + * @param parentDirPath The optional {@code parent directory path} to restrict operations to. + * This can optionally be {@code null}. It is not canonicalized and only normalized. + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @param ignoreErrorsIfPathIsInParentDirPath The {@code boolean} that decides if existence + * and permission errors are to be ignored if path is + * in {@code parentDirPath}. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. This allows making an attempt to set + * executable permissions, but ignoring if it fails. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error validateDirectoryFileExistenceAndPermissions(String label, final String filePath, final String parentDirPath, final boolean createDirectoryIfMissing, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly, + final boolean ignoreErrorsIfPathIsInParentDirPath, final boolean ignoreIfNotExecutable) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "directory file path", "validateDirectoryExistenceAndPermissions"); + + try { + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a directory file + if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) { + return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory"); + } + + boolean isPathInParentDirPath = false; + if (parentDirPath != null) { + // The path can be equal to parent directory path or under it + isPathInParentDirPath = isPathInDirPath(filePath, parentDirPath, false); + } + + if (createDirectoryIfMissing || setPermissions) { + // If there is not parentDirPath restriction or path is in parentDirPath + if (parentDirPath == null || (isPathInParentDirPath && getFileType(parentDirPath, false) == FileType.DIRECTORY)) { + // If createDirectoryIfMissing is enabled and no file exists at path, then create directory + if (createDirectoryIfMissing && fileType == FileType.NO_EXIST) { + Logger.logVerbose(LOG_TAG, "Creating " + label + "directory file at path \"" + filePath + "\""); + // Create directory and update fileType if successful, otherwise return with error + // It "might" be possible that mkdirs returns false even though directory was created + boolean result = file.mkdirs(); + fileType = getFileType(filePath, false); + if (!result && fileType != FileType.DIRECTORY) + return FileUtilsErrno.ERRNO_CREATING_FILE_FAILED.getError(label + "directory file", filePath); + } + + // If setPermissions is enabled and path is a directory + if (setPermissions && permissionsToCheck != null && fileType == FileType.DIRECTORY) { + if (setMissingPermissionsOnly) + setMissingFilePermissions(label + "directory", filePath, permissionsToCheck); + else + setFilePermissions(label + "directory", filePath, permissionsToCheck); + } + } + } + + // If there is not parentDirPath restriction or path is not in parentDirPath or + // if existence or permission errors must not be ignored for paths in parentDirPath + if (parentDirPath == null || !isPathInParentDirPath || !ignoreErrorsIfPathIsInParentDirPath) { + // If path is not a directory + // Directories can be automatically created so we can ignore if missing with above check + if (fileType != FileType.DIRECTORY) { + label += "directory"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label); + } + + if (permissionsToCheck != null) { + // Check if permissions are missing + return checkMissingFilePermissions(label + "directory", filePath, permissionsToCheck, ignoreIfNotExecutable); + } + } + } catch (Exception e) { + return FileUtilsErrno.ERRNO_VALIDATE_DIRECTORY_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION.getError(e, label + "directory file", filePath, e.getMessage()); + } + + return null; + } + + + + /** + * Create a regular file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param filePath The {@code path} for regular file to create. + * @return Returns the {@code error} if path is not a regular file or failed to create it, + * otherwise {@code null}. + */ + public static Error createRegularFile(final String filePath) { + return createRegularFile(null, filePath); + } + + /** + * Create a regular file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param label The optional label for the regular file. This can optionally be {@code null}. + * @param filePath The {@code path} for regular file to create. + * @return Returns the {@code error} if path is not a regular file or failed to create it, + * otherwise {@code null}. + */ + public static Error createRegularFile(final String label, final String filePath) { + return createRegularFile(label, filePath, + null, false, false); + } + + /** + * Create a regular file at path. + * + * This function is a wrapper for + * {@link #validateRegularFileExistenceAndPermissions(String, String, String, String, boolean, boolean, boolean)}. + * + * @param label The optional label for the regular file. This can optionally be {@code null}. + * @param filePath The {@code path} for regular file to create. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @return Returns the {@code error} if path is not a regular file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error createRegularFile(String label, final String filePath, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "createRegularFile"); + + Error error; + + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file"); + } + + // If regular file already exists + if (fileType == FileType.REGULAR) { + return null; + } + + // Create the file parent directory + error = createParentDirectoryFile(label + "regular file parent", filePath); + if (error != null) + return error; + + try { + Logger.logVerbose(LOG_TAG, "Creating " + label + "regular file at path \"" + filePath + "\""); + + if (!file.createNewFile()) + return FileUtilsErrno.ERRNO_CREATING_FILE_FAILED.getError(label + "regular file", filePath); + } catch (Exception e) { + return FileUtilsErrno.ERRNO_CREATING_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "regular file", filePath, e.getMessage()); + } + + return validateRegularFileExistenceAndPermissions(label, filePath, + null, + permissionsToCheck, setPermissions, setMissingPermissionsOnly, + false); + } + + + + /** + * Create parent directory of file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param label The optional label for the parent directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for file whose parent needs to be created. + * @return Returns the {@code error} if parent path is not a directory file or failed to create it, + * otherwise {@code null}. + */ + public static Error createParentDirectoryFile(final String label, final String filePath) { + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "createParentDirectoryFile"); + + File file = new File(filePath); + String fileParentPath = file.getParent(); + + if (fileParentPath != null) + return createDirectoryFile(label, fileParentPath, + null, false, false); + else + return null; + } + + /** + * Create a directory file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param filePath The {@code path} for directory file to create. + * @return Returns the {@code error} if path is not a directory file or failed to create it, + * otherwise {@code null}. + */ + public static Error createDirectoryFile(final String filePath) { + return createDirectoryFile(null, filePath); + } + + /** + * Create a directory file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for directory file to create. + * @return Returns the {@code error} if path is not a directory file or failed to create it, + * otherwise {@code null}. + */ + public static Error createDirectoryFile(final String label, final String filePath) { + return createDirectoryFile(label, filePath, + null, false, false); + } + + /** + * Create a directory file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for directory file to create. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error createDirectoryFile(final String label, final String filePath, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly) { + return validateDirectoryFileExistenceAndPermissions(label, filePath, + null, true, + permissionsToCheck, setPermissions, setMissingPermissionsOnly, + false, false); + } + + + + /** + * Create a symlink file at path. + * + * This function is a wrapper for + * {@link #createSymlinkFile(String, String, String, boolean, boolean, boolean)}. + * + * Dangling symlinks will be allowed. + * Symlink destination will be overwritten if it already exists but only if its a symlink. + * + * @param targetFilePath The {@code path} TO which the symlink file will be created. + * @param destFilePath The {@code path} AT which the symlink file will be created. + * @return Returns the {@code error} if path is not a symlink file, failed to create it, + * otherwise {@code null}. + */ + public static Error createSymlinkFile(final String targetFilePath, final String destFilePath) { + return createSymlinkFile(null, targetFilePath, destFilePath, + true, true, true); + } + + /** + * Create a symlink file at path. + * + * This function is a wrapper for + * {@link #createSymlinkFile(String, String, String, boolean, boolean, boolean)}. + * + * Dangling symlinks will be allowed. + * Symlink destination will be overwritten if it already exists but only if its a symlink. + * + * @param label The optional label for the symlink file. This can optionally be {@code null}. + * @param targetFilePath The {@code path} TO which the symlink file will be created. + * @param destFilePath The {@code path} AT which the symlink file will be created. + * @return Returns the {@code error} if path is not a symlink file, failed to create it, + * otherwise {@code null}. + */ + public static Error createSymlinkFile(String label, final String targetFilePath, final String destFilePath) { + return createSymlinkFile(label, targetFilePath, destFilePath, + true, true, true); + } + + /** + * Create a symlink file at path. + * + * @param label The optional label for the symlink file. This can optionally be {@code null}. + * @param targetFilePath The {@code path} TO which the symlink file will be created. + * @param destFilePath The {@code path} AT which the symlink file will be created. + * @param allowDangling The {@code boolean} that decides if it should be considered an + * error if source file doesn't exist. + * @param overwrite The {@code boolean} that decides if destination file should be overwritten if + * it already exists. If set to {@code true}, then destination file will be + * deleted before symlink is created. + * @param overwriteOnlyIfDestIsASymlink The {@code boolean} that decides if overwrite should + * only be done if destination file is also a symlink. + * @return Returns the {@code error} if path is not a symlink file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error createSymlinkFile(String label, final String targetFilePath, final String destFilePath, + final boolean allowDangling, final boolean overwrite, final boolean overwriteOnlyIfDestIsASymlink) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (targetFilePath == null || targetFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "target file path", "createSymlinkFile"); + if (destFilePath == null || destFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "destination file path", "createSymlinkFile"); + + Error error; + + try { + File destFile = new File(destFilePath); + + String targetFileAbsolutePath = targetFilePath; + // If target path is relative instead of absolute + if (!targetFilePath.startsWith("/")) { + String destFileParentPath = destFile.getParent(); + if (destFileParentPath != null) + targetFileAbsolutePath = destFileParentPath + "/" + targetFilePath; + } + + FileType targetFileType = getFileType(targetFileAbsolutePath, false); + FileType destFileType = getFileType(destFilePath, false); + + // If target file does not exist + if (targetFileType == FileType.NO_EXIST) { + // If dangling symlink should not be allowed, then return with error + if (!allowDangling) { + label += "symlink target file"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, targetFileAbsolutePath).setLabel(label); + } + } + + // If destination exists + if (destFileType != FileType.NO_EXIST) { + // If destination must not be overwritten + if (!overwrite) { + return null; + } + + // If overwriteOnlyIfDestIsASymlink is enabled but destination file is not a symlink + if (overwriteOnlyIfDestIsASymlink && destFileType != FileType.SYMLINK) + return FileUtilsErrno.ERRNO_CANNOT_OVERWRITE_A_NON_SYMLINK_FILE_TYPE.getError(label + " file", destFilePath, targetFilePath, destFileType.getName()); + + // Delete the destination file + error = deleteFile(label + "symlink destination", destFilePath, true); + if (error != null) + return error; + } else { + // Create the destination file parent directory + error = createParentDirectoryFile(label + "symlink destination file parent", destFilePath); + if (error != null) + return error; + } + + // create a symlink at destFilePath to targetFilePath + Logger.logVerbose(LOG_TAG, "Creating " + label + "symlink file at path \"" + destFilePath + "\" to \"" + targetFilePath + "\""); + Os.symlink(targetFilePath, destFilePath); + } catch (Exception e) { + return FileUtilsErrno.ERRNO_CREATING_SYMLINK_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "symlink file", destFilePath, targetFilePath, e.getMessage()); + } + + return null; + } + + + + /** + * Copy a regular file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a regular + * file, otherwise an error will be returned. + * + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code error} if copy was not successful, otherwise {@code null}. + */ + public static Error copyRegularFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileType.REGULAR.getValue(), + true, true); + } + + /** + * Move a regular file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a regular + * file, otherwise an error will be returned. + * + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code error} if move was not successful, otherwise {@code null}. + */ + public static Error moveRegularFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileType.REGULAR.getValue(), + true, true); + } + + /** + * Copy a directory file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a directory + * file, otherwise an error will be returned. + * + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code error} if copy was not successful, otherwise {@code null}. + */ + public static Error copyDirectoryFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileType.DIRECTORY.getValue(), + true, true); + } + + /** + * Move a directory file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a directory + * file, otherwise an error will be returned. + * + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code error} if move was not successful, otherwise {@code null}. + */ + public static Error moveDirectoryFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileType.DIRECTORY.getValue(), + true, true); + } + + /** + * Copy a symlink file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a symlink + * file, otherwise an error will be returned. + * + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code error} if copy was not successful, otherwise {@code null}. + */ + public static Error copySymlinkFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileType.SYMLINK.getValue(), + true, true); + } + + /** + * Move a symlink file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a symlink + * file, otherwise an error will be returned. + * + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code error} if move was not successful, otherwise {@code null}. + */ + public static Error moveSymlinkFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileType.SYMLINK.getValue(), + true, true); + } + + /** + * Copy a file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its the same file + * type as the source, otherwise an error will be returned. + * + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code error} if copy was not successful, otherwise {@code null}. + */ + public static Error copyFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileTypes.FILE_TYPE_NORMAL_FLAGS, + true, true); + } + + /** + * Move a file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its the same file + * type as the source, otherwise an error will be returned. + * + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code error} if move was not successful, otherwise {@code null}. + */ + public static Error moveFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileTypes.FILE_TYPE_NORMAL_FLAGS, + true, true); + } + + /** + * Copy or move a file from {@code sourceFilePath} to {@code destFilePath}. + * + * The {@code sourceFilePath} and {@code destFilePath} must be the canonical path to the source + * and destination since symlinks will not be followed. + * + * If the {@code sourceFilePath} or {@code destFilePath} is a canonical path to a directory, + * then any symlink files found under the directory will be deleted, but not their targets when + * deleting source after move and deleting destination before copy/move. + * + * @param label The optional label for file to copy or move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy or move. + * @param destFilePath The {@code destination path} for file to copy or move. + * @param moveFile The {@code boolean} that decides if source file needs to be copied or moved. + * If set to {@code true}, then source file will be moved, otherwise it will be + * copied. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied or moved doesn't exist. + * @param allowedFileTypeFlags The flags that are matched against the source file's {@link FileType} + * to see if it should be copied/moved or not. This is a safety measure + * to prevent accidental copy/move/delete of the wrong type of file, + * like a directory instead of a regular file. You can pass + * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow copy/move of any file type. + * @param overwrite The {@code boolean} that decides if destination file should be overwritten if + * it already exists. If set to {@code true}, then destination file will be + * deleted before source is copied or moved. + * @param overwriteOnlyIfDestSameFileTypeAsSrc The {@code boolean} that decides if overwrite should + * only be done if destination file is also the same file + * type as the source file. + * @return Returns the {@code error} if copy or move was not successful, otherwise {@code null}. + */ + public static Error copyOrMoveFile(String label, final String srcFilePath, final String destFilePath, + final boolean moveFile, final boolean ignoreNonExistentSrcFile, int allowedFileTypeFlags, + final boolean overwrite, final boolean overwriteOnlyIfDestSameFileTypeAsSrc) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (srcFilePath == null || srcFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "source file path", "copyOrMoveFile"); + if (destFilePath == null || destFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "destination file path", "copyOrMoveFile"); + + String mode = (moveFile ? "Moving" : "Copying"); + String modePast = (moveFile ? "moved" : "copied"); + + Error error; + + try { + Logger.logVerbose(LOG_TAG, mode + " " + label + "source file from \"" + srcFilePath + "\" to destination \"" + destFilePath + "\""); + + File srcFile = new File(srcFilePath); + File destFile = new File(destFilePath); + + FileType srcFileType = getFileType(srcFilePath, false); + FileType destFileType = getFileType(destFilePath, false); + + String srcFileCanonicalPath = srcFile.getCanonicalPath(); + String destFileCanonicalPath = destFile.getCanonicalPath(); + + // If source file does not exist + if (srcFileType == FileType.NO_EXIST) { + // If copy or move is to be ignored if source file is not found + if (ignoreNonExistentSrcFile) + return null; + // Else return with error + else { + label += "source file"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, srcFilePath).setLabel(label); + } + } + + // If the file type of the source file does not exist in the allowedFileTypeFlags, then return with error + if ((allowedFileTypeFlags & srcFileType.getValue()) <= 0) + return FileUtilsErrno.ERRNO_FILE_NOT_AN_ALLOWED_FILE_TYPE.getError(label + "source file meant to be " + modePast, srcFilePath, FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags)); + + // If source and destination file path are the same + if (srcFileCanonicalPath.equals(destFileCanonicalPath)) + return FileUtilsErrno.ERRNO_COPYING_OR_MOVING_FILE_TO_SAME_PATH.getError(mode + " " + label + "source file", srcFilePath, destFilePath); + + // If destination exists + if (destFileType != FileType.NO_EXIST) { + // If destination must not be overwritten + if (!overwrite) { + return null; + } + + // If overwriteOnlyIfDestSameFileTypeAsSrc is enabled but destination file does not match source file type + if (overwriteOnlyIfDestSameFileTypeAsSrc && destFileType != srcFileType) + return FileUtilsErrno.ERRNO_CANNOT_OVERWRITE_A_DIFFERENT_FILE_TYPE.getError(label + "source file", mode.toLowerCase(), srcFilePath, destFilePath, destFileType.getName(), srcFileType.getName()); + + // Delete the destination file + error = deleteFile(label + "destination", destFilePath, true); + if (error != null) + return error; + } + + + // Copy or move source file to dest + boolean copyFile = !moveFile; + + // If moveFile is true + if (moveFile) { + // We first try to rename source file to destination file to save a copy operation in case both source and destination are on the same filesystem + Logger.logVerbose(LOG_TAG, "Attempting to rename source to destination."); + + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/UnixFileSystem.java;l=358 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/android/system/Os.java;l=512 + // Uses File.getPath() to get the path of source and destination and not the canonical path + if (!srcFile.renameTo(destFile)) { + // If destination directory is a subdirectory of the source directory + // Copying is still allowed by copyDirectory() by excluding destination directory files + if (srcFileType == FileType.DIRECTORY && destFileCanonicalPath.startsWith(srcFileCanonicalPath + File.separator)) + return FileUtilsErrno.ERRNO_CANNOT_MOVE_DIRECTORY_TO_SUB_DIRECTORY_OF_ITSELF.getError(label + "source directory", srcFilePath, destFilePath); + + // If rename failed, then we copy + Logger.logVerbose(LOG_TAG, "Renaming " + label + "source file to destination failed, attempting to copy."); + copyFile = true; + } + } + + // If moveFile is false or renameTo failed while moving + if (copyFile) { + Logger.logVerbose(LOG_TAG, "Attempting to copy source to destination."); + + // Create the dest file parent directory + error = createParentDirectoryFile(label + "dest file parent", destFilePath); + if (error != null) + return error; + + if (srcFileType == FileType.DIRECTORY) { + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.copyDirectory(srcFile, destFile, true); + } else if (srcFileType == FileType.SYMLINK) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + java.nio.file.Files.copy(srcFile.toPath(), destFile.toPath(), LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING); + } else { + // read the target for the source file and create a symlink at dest + // source file metadata will be lost + error = createSymlinkFile(label + "dest", Os.readlink(srcFilePath), destFilePath); + if (error != null) + return error; + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + java.nio.file.Files.copy(srcFile.toPath(), destFile.toPath(), LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING); + } else { + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.copyFile(srcFile, destFile, true); + } + } + } + + // If source file had to be moved + if (moveFile) { + // Delete the source file since copying would have succeeded + error = deleteFile(label + "source", srcFilePath, true); + if (error != null) + return error; + } + + Logger.logVerbose(LOG_TAG, mode + " successful."); + } + catch (Exception e) { + return FileUtilsErrno.ERRNO_COPYING_OR_MOVING_FILE_FAILED_WITH_EXCEPTION.getError(e, mode + " " + label + "file", srcFilePath, destFilePath, e.getMessage()); + } + + return null; + } + + + + /** + * Delete regular file at path. + * + * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}. + * + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}. + */ + public static Error deleteRegularFile(String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.REGULAR.getValue()); + } + + /** + * Delete directory file at path. + * + * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}. + * + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}. + */ + public static Error deleteDirectoryFile(String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.DIRECTORY.getValue()); + } + + /** + * Delete symlink file at path. + * + * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}. + * + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}. + */ + public static Error deleteSymlinkFile(String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.SYMLINK.getValue()); + } + + /** + * Delete socket file at path. + * + * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}. + * + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}. + */ + public static Error deleteSocketFile(String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.SOCKET.getValue()); + } + + /** + * Delete regular, directory or symlink file at path. + * + * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}. + * + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}. + */ + public static Error deleteFile(String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(label, filePath, ignoreNonExistentFile, false, FileTypes.FILE_TYPE_NORMAL_FLAGS); + } + + /** + * Delete file at path. + * + * The {@code filePath} must be the canonical path to the file to be deleted since symlinks will + * not be followed. + * If the {@code filePath} is a canonical path to a directory, then any symlink files found under + * the directory will be deleted, but not their targets. + * + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @param ignoreWrongFileType The {@code boolean} that decides if it should be considered an + * error if file type is not one from {@code allowedFileTypeFlags}. + * @param allowedFileTypeFlags The flags that are matched against the file's {@link FileType} to + * see if it should be deleted or not. This is a safety measure to + * prevent accidental deletion of the wrong type of file, like a + * directory instead of a regular file. You can pass + * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow deletion of any file type. + * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}. + */ + public static Error deleteFile(String label, final String filePath, final boolean ignoreNonExistentFile, final boolean ignoreWrongFileType, int allowedFileTypeFlags) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "deleteFile"); + + try { + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + Logger.logVerbose(LOG_TAG, "Processing delete of " + label + "file at path \"" + filePath + "\" of type \"" + fileType.getName() + "\""); + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If delete is to be ignored if file does not exist + if (ignoreNonExistentFile) + return null; + // Else return with error + else { + label += "file meant to be deleted"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label); + } + } + + // If the file type of the file does not exist in the allowedFileTypeFlags + if ((allowedFileTypeFlags & fileType.getValue()) <= 0) { + // If wrong file type is to be ignored + if (ignoreWrongFileType) { + Logger.logVerbose(LOG_TAG, "Ignoring deletion of " + label + "file at path \"" + filePath + "\" of type \"" + fileType.getName() + "\" not matching allowed file types: " + FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags)); + return null; + } + + // Else return with error + return FileUtilsErrno.ERRNO_FILE_NOT_AN_ALLOWED_FILE_TYPE.getError(label + "file meant to be deleted", filePath, fileType.getName(), FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags)); + } + + Logger.logVerbose(LOG_TAG, "Deleting " + label + "file at path \"" + filePath + "\""); + PathsKt.deleteRecursively(file.toPath()); + + // If file still exists after deleting it + fileType = getFileType(filePath, false); + if (fileType != FileType.NO_EXIST) + return FileUtilsErrno.ERRNO_FILE_STILL_EXISTS_AFTER_DELETING.getError(label + "file meant to be deleted", filePath); + } + catch (Exception e) { + return FileUtilsErrno.ERRNO_DELETING_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()); + } + + return null; + } + + + + /** + * Clear contents of directory at path without deleting the directory. If directory does not exist + * it will be created automatically. + * + * This function is a wrapper for + * {@link #clearDirectory(String, String)}. + * + * @param filePath The {@code path} for directory to clear. + * @return Returns the {@code error} if clearing was not successful, otherwise {@code null}. + */ + public static Error clearDirectory(String filePath) { + return clearDirectory(null, filePath); + } + + /** + * Clear contents of directory at path without deleting the directory. If directory does not exist + * it will be created automatically. + * + * The {@code filePath} must be the canonical path to a directory since symlinks will not be followed. + * Any symlink files found under the directory will be deleted, but not their targets. + * + * @param label The optional label for directory to clear. This can optionally be {@code null}. + * @param filePath The {@code path} for directory to clear. + * @return Returns the {@code error} if clearing was not successful, otherwise {@code null}. + */ + public static Error clearDirectory(String label, final String filePath) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "clearDirectory"); + + Error error; + + try { + Logger.logVerbose(LOG_TAG, "Clearing " + label + "directory at path \"" + filePath + "\""); + + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a directory file + if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) { + return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory"); + } + + // If directory exists, clear its contents + if (fileType == FileType.DIRECTORY) { + /* If an exception is thrown, the exception message might not contain the full errors. + * Individual failures get added to suppressed throwables. */ + PathsKt.deleteRecursively(file.toPath()); + } + // Else create it + else { + error = createDirectoryFile(label, filePath); + if (error != null) + return error; + } + } catch (Exception e) { + return FileUtilsErrno.ERRNO_CLEARING_DIRECTORY_FAILED_WITH_EXCEPTION.getError(e, label + "directory", filePath, e.getMessage()); + } + + return null; + } + + /** + * Delete files under a directory older than x days. + * + * The {@code filePath} must be the canonical path to a directory since symlinks will not be followed. + * Any symlink files found under the directory will be deleted, but not their targets. + * + * @param label The optional label for directory to clear. This can optionally be {@code null}. + * @param filePath The {@code path} for directory to clear. + * @param dirFilter The optional filter to apply when finding subdirectories. + * If this parameter is {@code null}, subdirectories will not be included in the + * search. Use TrueFileFilter.INSTANCE to match all directories. + * @param days The x amount of days before which files should be deleted. This must be `>=0`. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @param allowedFileTypeFlags The flags that are matched against the file's {@link FileType} to + * see if it should be deleted or not. This is a safety measure to + * prevent accidental deletion of the wrong type of file, like a + * directory instead of a regular file. You can pass + * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow deletion of any file type. + * @return Returns the {@code error} if deleting was not successful, otherwise {@code null}. + */ + public static Error deleteFilesOlderThanXDays(String label, final String filePath, final IOFileFilter dirFilter, int days, final boolean ignoreNonExistentFile, int allowedFileTypeFlags) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "deleteFilesOlderThanXDays"); + if (days < 0) return FunctionErrno.ERRNO_INVALID_PARAMETER.getError(label + "days", "deleteFilesOlderThanXDays", " It must be >= 0."); + + Error error; + + try { + Logger.logVerbose(LOG_TAG, "Deleting files under " + label + "directory at path \"" + filePath + "\" older than " + days + " days"); + + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a directory file + if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) { + return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory"); + } + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If delete is to be ignored if file does not exist + if (ignoreNonExistentFile) + return null; + // Else return with error + else { + label += "directory under which files had to be deleted"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label); + } + } + + // TODO: Use FileAttributes with support for atime (default), mtime, ctime. Add regex for ignoring file and dir absolute paths. + // FIXME: iterateFiles() does not return subdirectories even with TrueFileFilter for file and dir. + // FIXME: Empty directories remain + + // If directory exists, delete its contents + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DATE, -(days)); + // AgeFileFilter seems to apply to symlink destination timestamp instead of symlink file itself + Iterator filesToDelete = + org.apache.commons.io.FileUtils.iterateFiles(file, new AgeFileFilter(calendar.getTime()), dirFilter); + while (filesToDelete.hasNext()) { + File subFile = filesToDelete.next(); + error = deleteFile(label + " directory sub", subFile.getAbsolutePath(), true, true, allowedFileTypeFlags); + if (error != null) + return error; + } + } catch (Exception e) { + return FileUtilsErrno.ERRNO_DELETING_FILES_OLDER_THAN_X_DAYS_FAILED_WITH_EXCEPTION.getError(e, label + "directory", filePath, days, e.getMessage()); + } + + return null; + + } + + + + + + /** + * Read a text {@link String} from file at path with a specific {@link Charset} into {@code dataString}. + * + * @param label The optional label for file to read. This can optionally be {@code null}. + * @param filePath The {@code path} for file to read. + * @param charset The {@link Charset} of the file. If this is {@code null}, + * then default {@link Charset} will be used. + * @param dataStringBuilder The {@code StringBuilder} to read data into. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to read doesn't exist. + * @return Returns the {@code error} if reading was not successful, otherwise {@code null}. + */ + public static Error readTextFromFile(String label, final String filePath, Charset charset, @NonNull final StringBuilder dataStringBuilder, final boolean ignoreNonExistentFile) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "readStringFromFile"); + + Logger.logVerbose(LOG_TAG, "Reading text from " + label + "file at path \"" + filePath + "\""); + + Error error; + + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file"); + } + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If reading is to be ignored if file does not exist + if (ignoreNonExistentFile) + return null; + // Else return with error + else { + label += "file meant to be read"; + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label); + } + } + + if (charset == null) charset = Charset.defaultCharset(); + + // Check if charset is supported + error = isCharsetSupported(charset); + if (error != null) + return error; + + FileInputStream fileInputStream = null; + BufferedReader bufferedReader = null; + try { + // Read text from file + fileInputStream = new FileInputStream(filePath); + bufferedReader = new BufferedReader(new InputStreamReader(fileInputStream, charset)); + + String receiveString; + + boolean firstLine = true; + while ((receiveString = bufferedReader.readLine()) != null ) { + if (!firstLine) dataStringBuilder.append("\n"); else firstLine = false; + dataStringBuilder.append(receiveString); + } + + Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("String", DataUtils.getTruncatedCommandOutput(dataStringBuilder.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD, true, false, true), "-")); + } catch (Exception e) { + return FileUtilsErrno.ERRNO_READING_TEXT_FROM_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()); + } finally { + closeCloseable(fileInputStream); + closeCloseable(bufferedReader); + } + + return null; + } + + public static class ReadSerializableObjectResult { + public final Error error; + public final Serializable serializableObject; + + ReadSerializableObjectResult(Error error, Serializable serializableObject) { + this.error = error; + this.serializableObject = serializableObject; + } + } + + /** + * Read a {@link Serializable} object from file at path. + * + * @param label The optional label for file to read. This can optionally be {@code null}. + * @param filePath The {@code path} for file to read. + * @param readObjectType The {@link Class} of the object. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to read doesn't exist. + * @return Returns the {@code error} if reading was not successful, otherwise {@code null}. + */ + @NonNull + public static ReadSerializableObjectResult readSerializableObjectFromFile(String label, final String filePath, Class readObjectType, final boolean ignoreNonExistentFile) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return new ReadSerializableObjectResult(FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "readSerializableObjectFromFile"), null); + + Logger.logVerbose(LOG_TAG, "Reading serializable object from " + label + "file at path \"" + filePath + "\""); + + T serializableObject; + + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return new ReadSerializableObjectResult(FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file"), null); + } + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If reading is to be ignored if file does not exist + if (ignoreNonExistentFile) + return new ReadSerializableObjectResult(null, null); + // Else return with error + else { + label += "file meant to be read"; + return new ReadSerializableObjectResult(FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label), null); + } + } + + FileInputStream fileInputStream = null; + ObjectInputStream objectInputStream = null; + try { + // Read serializable object from file + fileInputStream = new FileInputStream(filePath); + objectInputStream = new ObjectInputStream(fileInputStream); + //serializableObject = (T) objectInputStream.readObject(); + serializableObject = readObjectType.cast(objectInputStream.readObject()); + + //Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("String", DataUtils.getTruncatedCommandOutput(dataStringBuilder.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD, true, false, true), "-")); + } catch (Exception e) { + return new ReadSerializableObjectResult(FileUtilsErrno.ERRNO_READING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()), null); + } finally { + closeCloseable(fileInputStream); + closeCloseable(objectInputStream); + } + + return new ReadSerializableObjectResult(null, serializableObject); + } + + /** + * Write text {@code dataString} with a specific {@link Charset} to file at path. + * + * @param label The optional label for file to write. This can optionally be {@code null}. + * @param filePath The {@code path} for file to write. + * @param charset The {@link Charset} of the {@code dataString}. If this is {@code null}, + * then default {@link Charset} will be used. + * @param dataString The data to write to file. + * @param append The {@code boolean} that decides if file should be appended to or not. + * @return Returns the {@code error} if writing was not successful, otherwise {@code null}. + */ + public static Error writeTextToFile(String label, final String filePath, Charset charset, final String dataString, final boolean append) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "writeStringToFile"); + + Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("Writing text to " + label + "file at path \"" + filePath + "\"", DataUtils.getTruncatedCommandOutput(dataString, Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD, true, false, true), "-")); + + Error error; + + error = preWriteToFile(label, filePath); + if (error != null) + return error; + + if (charset == null) charset = Charset.defaultCharset(); + + // Check if charset is supported + error = isCharsetSupported(charset); + if (error != null) + return error; + + FileOutputStream fileOutputStream = null; + BufferedWriter bufferedWriter = null; + try { + // Write text to file + fileOutputStream = new FileOutputStream(filePath, append); + bufferedWriter = new BufferedWriter(new OutputStreamWriter(fileOutputStream, charset)); + + bufferedWriter.write(dataString); + bufferedWriter.flush(); + } catch (Exception e) { + return FileUtilsErrno.ERRNO_WRITING_TEXT_TO_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()); + } finally { + closeCloseable(fileOutputStream); + closeCloseable(bufferedWriter); + } + + return null; + } + + /** + * Write the {@link Serializable} {@code serializableObject} to file at path. + * + * @param label The optional label for file to write. This can optionally be {@code null}. + * @param filePath The {@code path} for file to write. + * @param serializableObject The object to write to file. + * @return Returns the {@code error} if writing was not successful, otherwise {@code null}. + */ + public static Error writeSerializableObjectToFile(String label, final String filePath, final T serializableObject) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "writeSerializableObjectToFile"); + + Logger.logVerbose(LOG_TAG, "Writing serializable object to " + label + "file at path \"" + filePath + "\""); + + Error error; + + error = preWriteToFile(label, filePath); + if (error != null) + return error; + + FileOutputStream fileOutputStream = null; + ObjectOutputStream objectOutputStream = null; + try { + // Write serializable object to file + fileOutputStream = new FileOutputStream(filePath); + objectOutputStream = new ObjectOutputStream(fileOutputStream); + + objectOutputStream.writeObject(serializableObject); + objectOutputStream.flush(); + } catch (Exception e) { + return FileUtilsErrno.ERRNO_WRITING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()); + } finally { + closeCloseable(fileOutputStream); + closeCloseable(objectOutputStream); + } + + return null; + } + + private static Error preWriteToFile(String label, String filePath) { + Error error; + + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file"); + } + + // Create the file parent directory + error = createParentDirectoryFile(label + "file parent", filePath); + if (error != null) + return error; + + return null; + } + + + + /** + * Check if a specific {@link Charset} is supported. + * + * @param charset The {@link Charset} to check. + * @return Returns the {@code error} if charset is not supported or failed to check it, otherwise {@code null}. + */ + public static Error isCharsetSupported(final Charset charset) { + if (charset == null) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError("charset", "isCharsetSupported"); + + try { + if (!Charset.isSupported(charset.name())) { + return FileUtilsErrno.ERRNO_UNSUPPORTED_CHARSET.getError(charset.name()); + } + } catch (Exception e) { + return FileUtilsErrno.ERRNO_CHECKING_IF_CHARSET_SUPPORTED_FAILED.getError(e, charset.name(), e.getMessage()); + } + + return null; + } + + + + /** + * Close a {@link Closeable} object if not {@code null} and ignore any exceptions raised. + * + * @param closeable The {@link Closeable} object to close. + */ + public static void closeCloseable(final Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } + catch (IOException e) { + // ignore + } + } + } + + + + /** + * Set permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will be removed. + * + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setFilePermissions(final String filePath, final String permissionsToSet) { + setFilePermissions(null, filePath, permissionsToSet); + } + + /** + * Set permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will be removed. + * + * @param label The optional label for the file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setFilePermissions(String label, final String filePath, final String permissionsToSet) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return; + + if (!isValidPermissionString(permissionsToSet)) { + Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setFilePermissions: \"" + permissionsToSet + "\""); + return; + } + + File file = new File(filePath); + + if (permissionsToSet.contains("r")) { + if (!file.canRead()) { + Logger.logVerbose(LOG_TAG, "Setting read permissions for " + label + "file at path \"" + filePath + "\""); + file.setReadable(true); + } + } else { + if (file.canRead()) { + Logger.logVerbose(LOG_TAG, "Removing read permissions for " + label + "file at path \"" + filePath + "\""); + file.setReadable(false); + } + } + + + if (permissionsToSet.contains("w")) { + if (!file.canWrite()) { + Logger.logVerbose(LOG_TAG, "Setting write permissions for " + label + "file at path \"" + filePath + "\""); + file.setWritable(true); + } + } else { + if (file.canWrite()) { + Logger.logVerbose(LOG_TAG, "Removing write permissions for " + label + "file at path \"" + filePath + "\""); + file.setWritable(false); + } + } + + + if (permissionsToSet.contains("x")) { + if (!file.canExecute()) { + Logger.logVerbose(LOG_TAG, "Setting execute permissions for " + label + "file at path \"" + filePath + "\""); + file.setExecutable(true); + } + } else { + if (file.canExecute()) { + Logger.logVerbose(LOG_TAG, "Removing execute permissions for " + label + "file at path \"" + filePath + "\""); + file.setExecutable(false); + } + } + } + + + + /** + * Set missing permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will not be removed. + * + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setMissingFilePermissions(final String filePath, final String permissionsToSet) { + setMissingFilePermissions(null, filePath, permissionsToSet); + } + + /** + * Set missing permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will not be removed. + * + * @param label The optional label for the file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setMissingFilePermissions(String label, final String filePath, final String permissionsToSet) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return; + + if (!isValidPermissionString(permissionsToSet)) { + Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setMissingFilePermissions: \"" + permissionsToSet + "\""); + return; + } + + File file = new File(filePath); + + if (permissionsToSet.contains("r") && !file.canRead()) { + Logger.logVerbose(LOG_TAG, "Setting missing read permissions for " + label + "file at path \"" + filePath + "\""); + file.setReadable(true); + } + + if (permissionsToSet.contains("w") && !file.canWrite()) { + Logger.logVerbose(LOG_TAG, "Setting missing write permissions for " + label + "file at path \"" + filePath + "\""); + file.setWritable(true); + } + + if (permissionsToSet.contains("x") && !file.canExecute()) { + Logger.logVerbose(LOG_TAG, "Setting missing execute permissions for " + label + "file at path \"" + filePath + "\""); + file.setExecutable(true); + } + } + + + + /** + * Checking missing permissions for file at path. + * + * @param filePath The {@code path} for file to check permissions for. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. + * @return Returns the {@code error} if validating permissions failed, otherwise {@code null}. + */ + public static Error checkMissingFilePermissions(final String filePath, final String permissionsToCheck, final boolean ignoreIfNotExecutable) { + return checkMissingFilePermissions(null, filePath, permissionsToCheck, ignoreIfNotExecutable); + } + + /** + * Checking missing permissions for file at path. + * + * @param label The optional label for the file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to check permissions for. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. + * @return Returns the {@code error} if validating permissions failed, otherwise {@code null}. + */ + public static Error checkMissingFilePermissions(String label, final String filePath, final String permissionsToCheck, final boolean ignoreIfNotExecutable) { + label = (label == null || label.isEmpty() ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "checkMissingFilePermissions"); + + if (!isValidPermissionString(permissionsToCheck)) { + Logger.logError(LOG_TAG, "Invalid permissionsToCheck passed to checkMissingFilePermissions: \"" + permissionsToCheck + "\""); + return FileUtilsErrno.ERRNO_INVALID_FILE_PERMISSIONS_STRING_TO_CHECK.getError(); + } + + File file = new File(filePath); + + // If file is not readable + if (permissionsToCheck.contains("r") && !file.canRead()) { + return FileUtilsErrno.ERRNO_FILE_NOT_READABLE.getError(label + "file", filePath).setLabel(label + "file"); + } + + // If file is not writable + if (permissionsToCheck.contains("w") && !file.canWrite()) { + return FileUtilsErrno.ERRNO_FILE_NOT_WRITABLE.getError(label + "file", filePath).setLabel(label + "file"); + } + // If file is not executable + // This canExecute() will give "avc: granted { execute }" warnings for target sdk 29 + else if (permissionsToCheck.contains("x") && !file.canExecute() && !ignoreIfNotExecutable) { + return FileUtilsErrno.ERRNO_FILE_NOT_EXECUTABLE.getError(label + "file", filePath).setLabel(label + "file"); + } + + return null; + } + + + + /** + * Checks whether string exactly matches the 3 character permission string that + * contains the "r", "w", "x" or "-" in-order. + * + * @param string The {@link String} to check. + * @return Returns {@code true} if string exactly matches a permission string, otherwise {@code false}. + */ + public static boolean isValidPermissionString(final String string) { + if (string == null || string.isEmpty()) return false; + return Pattern.compile("^([r-])[w-][x-]$", 0).matcher(string).matches(); + } + + + + /** + * Get a {@link Error} that contains a shorter version of {@link Errno} message. + * + * @param error The original {@link Error} returned by one of the {@link FileUtils} functions. + * @return Returns the shorter {@link Error} if one exists, otherwise original {@code error}. + */ + public static Error getShortFileUtilsError(final Error error) { + String type = error.getType(); + if (!FileUtilsErrno.TYPE.equals(type)) return error; + + Errno shortErrno = FileUtilsErrno.ERRNO_SHORT_MAPPING.get(Errno.valueOf(type, error.getCode())); + if (shortErrno == null) return error; + + List throwables = error.getThrowablesList(); + if (throwables.isEmpty()) + return shortErrno.getError(DataUtils.getDefaultIfNull(error.getLabel(), "file")); + else + return shortErrno.getError(throwables, error.getLabel(), "file"); + } + + + /** + * Get file dirname for file at {@code filePath}. + * + * @param filePath The {@code path} for file. + * @return Returns the file dirname if not {@code null}. + */ + public static String getFileDirname(String filePath) { + if (DataUtils.isNullOrEmpty(filePath)) return null; + int lastSlash = filePath.lastIndexOf('/'); + return (lastSlash == -1) ? null : filePath.substring(0, lastSlash); + } + + /** + * Get file basename for file at {@code filePath}. + * + * @param filePath The {@code path} for file. + * @return Returns the file basename if not {@code null}. + */ + public static String getFileBasename(String filePath) { + if (DataUtils.isNullOrEmpty(filePath)) return null; + int lastSlash = filePath.lastIndexOf('/'); + return (lastSlash == -1) ? filePath : filePath.substring(lastSlash + 1); + } + + /** + * Get file basename for file at {@code filePath} without extension. + * + * @param filePath The {@code path} for file. + * @return Returns the file basename without extension if not {@code null}. + */ + public static String getFileBasenameWithoutExtension(String filePath) { + String fileBasename = getFileBasename(filePath); + if (DataUtils.isNullOrEmpty(fileBasename)) return null; + int lastDot = fileBasename.lastIndexOf('.'); + return (lastDot == -1) ? fileBasename : fileBasename.substring(0, lastDot); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java new file mode 100644 index 000000000..7e336aad3 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java @@ -0,0 +1,111 @@ +package com.termux.shared.file; + +import com.termux.shared.errors.Errno; + +import java.util.HashMap; +import java.util.Map; + +/** The {@link Class} that defines FileUtils error messages and codes. */ +public class FileUtilsErrno extends Errno { + + public static final String TYPE = "FileUtils Error"; + + + /* Errors for null or empty paths (100-150) */ + public static final Errno ERRNO_EXECUTABLE_REQUIRED = new Errno(TYPE, 100, "Executable required."); + public static final Errno ERRNO_NULL_OR_EMPTY_REGULAR_FILE_PATH = new Errno(TYPE, 101, "The regular file path is null or empty."); + public static final Errno ERRNO_NULL_OR_EMPTY_REGULAR_FILE = new Errno(TYPE, 102, "The regular file is null or empty."); + public static final Errno ERRNO_NULL_OR_EMPTY_EXECUTABLE_FILE_PATH = new Errno(TYPE, 103, "The executable file path is null or empty."); + public static final Errno ERRNO_NULL_OR_EMPTY_EXECUTABLE_FILE = new Errno(TYPE, 104, "The executable file is null or empty."); + public static final Errno ERRNO_NULL_OR_EMPTY_DIRECTORY_FILE_PATH = new Errno(TYPE, 105, "The directory file path is null or empty."); + public static final Errno ERRNO_NULL_OR_EMPTY_DIRECTORY_FILE = new Errno(TYPE, 106, "The directory file is null or empty."); + + + + /* Errors for invalid or not found files at path (150-200) */ + public static final Errno ERRNO_FILE_NOT_FOUND_AT_PATH = new Errno(TYPE, 150, "The %1$s not found at path \"%2$s\"."); + public static final Errno ERRNO_FILE_NOT_FOUND_AT_PATH_SHORT = new Errno(TYPE, 151, "The %1$s not found at path."); + + public static final Errno ERRNO_NON_REGULAR_FILE_FOUND = new Errno(TYPE, 152, "Non-regular file found at %1$s path \"%2$s\"."); + public static final Errno ERRNO_NON_REGULAR_FILE_FOUND_SHORT = new Errno(TYPE, 153, "Non-regular file found at %1$s path."); + public static final Errno ERRNO_NON_DIRECTORY_FILE_FOUND = new Errno(TYPE, 154, "Non-directory file found at %1$s path \"%2$s\"."); + public static final Errno ERRNO_NON_DIRECTORY_FILE_FOUND_SHORT = new Errno(TYPE, 155, "Non-directory file found at %1$s path."); + public static final Errno ERRNO_NON_SYMLINK_FILE_FOUND = new Errno(TYPE, 156, "Non-symlink file found at %1$s path \"%2$s\"."); + public static final Errno ERRNO_NON_SYMLINK_FILE_FOUND_SHORT = new Errno(TYPE, 157, "Non-symlink file found at %1$s path."); + + public static final Errno ERRNO_FILE_NOT_AN_ALLOWED_FILE_TYPE = new Errno(TYPE, 158, "The %1$s found at path \"%2$s\" of type \"%3$s\" is not one of allowed file types \"%4$s\"."); + public static final Errno ERRNO_NON_EMPTY_DIRECTORY_FILE = new Errno(TYPE, 159, "The %1$s directory at path \"%2$s\" is not empty."); + + public static final Errno ERRNO_VALIDATE_FILE_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 160, "Validating file existence and permissions of %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_VALIDATE_DIRECTORY_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 161, "Validating directory existence and permissions of %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_VALIDATE_DIRECTORY_EMPTY_OR_ONLY_CONTAINS_SPECIFIC_FILES_FAILED_WITH_EXCEPTION = new Errno(TYPE, 162, "Validating directory is empty or only contains specific files of %1$s at path \"%2$s\" failed.\nException: %3$s"); + + + + /* Errors for file creation (200-250) */ + public static final Errno ERRNO_CREATING_FILE_FAILED = new Errno(TYPE, 200, "Creating %1$s at path \"%2$s\" failed."); + public static final Errno ERRNO_CREATING_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 201, "Creating %1$s at path \"%2$s\" failed.\nException: %3$s"); + + public static final Errno ERRNO_CANNOT_OVERWRITE_A_NON_SYMLINK_FILE_TYPE = new Errno(TYPE, 202, "Cannot overwrite %1$s while creating symlink at \"%2$s\" to \"%3$s\" since destination file type \"%4$s\" is not a symlink."); + public static final Errno ERRNO_CREATING_SYMLINK_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 203, "Creating %1$s at path \"%2$s\" to \"%3$s\" failed.\nException: %4$s"); + + + + /* Errors for file copying and moving (250-300) */ + public static final Errno ERRNO_COPYING_OR_MOVING_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 250, "%1$s from \"%2$s\" to \"%3$s\" failed.\nException: %4$s"); + public static final Errno ERRNO_COPYING_OR_MOVING_FILE_TO_SAME_PATH = new Errno(TYPE, 251, "%1$s from \"%2$s\" to \"%3$s\" cannot be done since they point to the same path."); + public static final Errno ERRNO_CANNOT_OVERWRITE_A_DIFFERENT_FILE_TYPE = new Errno(TYPE, 252, "Cannot overwrite %1$s while %2$s it from \"%3$s\" to \"%4$s\" since destination file type \"%5$s\" is different from source file type \"%6$s\"."); + public static final Errno ERRNO_CANNOT_MOVE_DIRECTORY_TO_SUB_DIRECTORY_OF_ITSELF = new Errno(TYPE, 253, "Cannot move %1$s from \"%2$s\" to \"%3$s\" since destination is a subdirectory of the source."); + + + + /* Errors for file deletion (300-350) */ + public static final Errno ERRNO_DELETING_FILE_FAILED = new Errno(TYPE, 300, "Deleting %1$s at path \"%2$s\" failed."); + public static final Errno ERRNO_DELETING_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 301, "Deleting %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_CLEARING_DIRECTORY_FAILED_WITH_EXCEPTION = new Errno(TYPE, 302, "Clearing %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_FILE_STILL_EXISTS_AFTER_DELETING = new Errno(TYPE, 303, "The %1$s still exists after deleting it from \"%2$s\"."); + public static final Errno ERRNO_DELETING_FILES_OLDER_THAN_X_DAYS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 304, "Deleting %1$s under directory at path \"%2$s\" old than %3$s days failed.\nException: %4$s"); + + + + /* Errors for file reading and writing (350-400) */ + public static final Errno ERRNO_READING_TEXT_FROM_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 350, "Reading text from %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_WRITING_TEXT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 351, "Writing text to %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_UNSUPPORTED_CHARSET = new Errno(TYPE, 352, "Unsupported charset \"%1$s\""); + public static final Errno ERRNO_CHECKING_IF_CHARSET_SUPPORTED_FAILED = new Errno(TYPE, 353, "Checking if charset \"%1$s\" is supported failed.\nException: %2$s"); + public static final Errno ERRNO_GET_CHARSET_FOR_NAME_FAILED = new Errno(TYPE, 354, "The \"%1$s\" charset is not supported.\nException: %2$s"); + public static final Errno ERRNO_READING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 355, "Reading serializable object from %1$s at path \"%2$s\" failed.\nException: %3$s"); + public static final Errno ERRNO_WRITING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 356, "Writing serializable object to %1$s at path \"%2$s\" failed.\nException: %3$s"); + + + + /* Errors for invalid file permissions (400-450) */ + public static final Errno ERRNO_INVALID_FILE_PERMISSIONS_STRING_TO_CHECK = new Errno(TYPE, 400, "The file permission string to check is invalid."); + public static final Errno ERRNO_FILE_NOT_READABLE = new Errno(TYPE, 401, "The %1$s at path \"%2$s\" is not readable. Permission Denied."); + public static final Errno ERRNO_FILE_NOT_READABLE_SHORT = new Errno(TYPE, 402, "The %1$s at path is not readable. Permission Denied."); + public static final Errno ERRNO_FILE_NOT_WRITABLE = new Errno(TYPE, 403, "The %1$s at path \"%2$s\" is not writable. Permission Denied."); + public static final Errno ERRNO_FILE_NOT_WRITABLE_SHORT = new Errno(TYPE, 404, "The %1$s at path is not writable. Permission Denied."); + public static final Errno ERRNO_FILE_NOT_EXECUTABLE = new Errno(TYPE, 405, "The %1$s at path \"%2$s\" is not executable. Permission Denied."); + public static final Errno ERRNO_FILE_NOT_EXECUTABLE_SHORT = new Errno(TYPE, 406, "The %1$s at path is not executable. Permission Denied."); + + + FileUtilsErrno(final String type, final int code, final String message) { + super(type, code, message); + } + + + + /** Defines the {@link Errno} mapping to get a shorter version of {@link FileUtilsErrno}. */ + public static final Map ERRNO_SHORT_MAPPING = new HashMap() {{ + put(ERRNO_FILE_NOT_FOUND_AT_PATH, ERRNO_FILE_NOT_FOUND_AT_PATH_SHORT); + + put(ERRNO_NON_REGULAR_FILE_FOUND, ERRNO_NON_REGULAR_FILE_FOUND_SHORT); + put(ERRNO_NON_DIRECTORY_FILE_FOUND, ERRNO_NON_DIRECTORY_FILE_FOUND_SHORT); + put(ERRNO_NON_SYMLINK_FILE_FOUND, ERRNO_NON_SYMLINK_FILE_FOUND_SHORT); + + put(ERRNO_FILE_NOT_READABLE, ERRNO_FILE_NOT_READABLE_SHORT); + put(ERRNO_FILE_NOT_WRITABLE, ERRNO_FILE_NOT_WRITABLE_SHORT); + put(ERRNO_FILE_NOT_EXECUTABLE, ERRNO_FILE_NOT_EXECUTABLE_SHORT); + }}; + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java new file mode 100644 index 000000000..4565acc3d --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2008, 2013, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.shared.file.filesystem; + +import android.os.Build; +import android.system.StructStat; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.Set; +import java.util.HashSet; + +/** + * Unix implementation of PosixFileAttributes. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixFileAttributes.java + */ + +public class FileAttributes { + private String filePath; + private FileDescriptor fileDescriptor; + + private int st_mode; + private long st_ino; + private long st_dev; + private long st_rdev; + private long st_nlink; + private int st_uid; + private int st_gid; + private long st_size; + private long st_blksize; + private long st_blocks; + private long st_atime_sec; + private long st_atime_nsec; + private long st_mtime_sec; + private long st_mtime_nsec; + private long st_ctime_sec; + private long st_ctime_nsec; + + // created lazily + private volatile String owner; + private volatile String group; + private volatile FileKey key; + + private FileAttributes(String filePath) { + this.filePath = filePath; + } + + private FileAttributes(FileDescriptor fileDescriptor) { + this.fileDescriptor = fileDescriptor; + } + + // get the FileAttributes for a given file + public static FileAttributes get(String filePath, boolean followLinks) throws IOException { + FileAttributes fileAttributes; + + if (filePath == null || filePath.isEmpty()) + fileAttributes = new FileAttributes((String) null); + else + fileAttributes = new FileAttributes(new File(filePath).getAbsolutePath()); + + if (followLinks) { + NativeDispatcher.stat(filePath, fileAttributes); + } else { + NativeDispatcher.lstat(filePath, fileAttributes); + } + + // Logger.logDebug(fileAttributes.toString()); + + return fileAttributes; + } + + // get the FileAttributes for an open file + public static FileAttributes get(FileDescriptor fileDescriptor) throws IOException { + FileAttributes fileAttributes = new FileAttributes(fileDescriptor); + NativeDispatcher.fstat(fileDescriptor, fileAttributes); + return fileAttributes; + } + + public String file() { + if (filePath != null) + return filePath; + else if (fileDescriptor != null) + return fileDescriptor.toString(); + else + return null; + } + + // package-private + public boolean isSameFile(FileAttributes attrs) { + return ((st_ino == attrs.st_ino) && (st_dev == attrs.st_dev)); + } + + // package-private + public int mode() { + return st_mode; + } + + public long blksize() { + return st_blksize; + } + + public long blocks() { + return st_blocks; + } + + public long ino() { + return st_ino; + } + + public long dev() { + return st_dev; + } + + public long rdev() { + return st_rdev; + } + + public long nlink() { + return st_nlink; + } + + public int uid() { + return st_uid; + } + + public int gid() { + return st_gid; + } + + private static FileTime toFileTime(long sec, long nsec) { + if (nsec == 0) { + return FileTime.from(sec, TimeUnit.SECONDS); + } else { + // truncate to microseconds to avoid overflow with timestamps + // way out into the future. We can re-visit this if FileTime + // is updated to define a from(secs,nsecs) method. + long micro = sec * 1000000L + nsec / 1000L; + return FileTime.from(micro, TimeUnit.MICROSECONDS); + } + } + + public FileTime lastAccessTime() { + return toFileTime(st_atime_sec, st_atime_nsec); + } + + public FileTime lastModifiedTime() { + return toFileTime(st_mtime_sec, st_mtime_nsec); + } + + public FileTime lastChangeTime() { + return toFileTime(st_ctime_sec, st_ctime_nsec); + } + + public FileTime creationTime() { + return lastModifiedTime(); + } + + public boolean isRegularFile() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFREG); + } + + public boolean isDirectory() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFDIR); + } + + public boolean isSymbolicLink() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFLNK); + } + + public boolean isCharacter() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFCHR); + } + + public boolean isFifo() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFIFO); + } + + public boolean isSocket() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFSOCK); + } + + public boolean isBlock() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFBLK); + } + + public boolean isOther() { + int type = st_mode & UnixConstants.S_IFMT; + return (type != UnixConstants.S_IFREG && + type != UnixConstants.S_IFDIR && + type != UnixConstants.S_IFLNK); + } + + public boolean isDevice() { + int type = st_mode & UnixConstants.S_IFMT; + return (type == UnixConstants.S_IFCHR || + type == UnixConstants.S_IFBLK || + type == UnixConstants.S_IFIFO); + } + + public long size() { + return st_size; + } + + public FileKey fileKey() { + if (key == null) { + synchronized (this) { + if (key == null) { + key = new FileKey(st_dev, st_ino); + } + } + } + return key; + } + + public String owner() { + if (owner == null) { + synchronized (this) { + if (owner == null) { + owner = Integer.toString(st_uid); + } + } + } + return owner; + } + + public String group() { + if (group == null) { + synchronized (this) { + if (group == null) { + group = Integer.toString(st_gid); + } + } + } + return group; + } + + public Set permissions() { + int bits = (st_mode & UnixConstants.S_IAMB); + HashSet perms = new HashSet<>(); + + if ((bits & UnixConstants.S_IRUSR) > 0) + perms.add(FilePermission.OWNER_READ); + if ((bits & UnixConstants.S_IWUSR) > 0) + perms.add(FilePermission.OWNER_WRITE); + if ((bits & UnixConstants.S_IXUSR) > 0) + perms.add(FilePermission.OWNER_EXECUTE); + + if ((bits & UnixConstants.S_IRGRP) > 0) + perms.add(FilePermission.GROUP_READ); + if ((bits & UnixConstants.S_IWGRP) > 0) + perms.add(FilePermission.GROUP_WRITE); + if ((bits & UnixConstants.S_IXGRP) > 0) + perms.add(FilePermission.GROUP_EXECUTE); + + if ((bits & UnixConstants.S_IROTH) > 0) + perms.add(FilePermission.OTHERS_READ); + if ((bits & UnixConstants.S_IWOTH) > 0) + perms.add(FilePermission.OTHERS_WRITE); + if ((bits & UnixConstants.S_IXOTH) > 0) + perms.add(FilePermission.OTHERS_EXECUTE); + + return perms; + } + + public void loadFromStructStat(StructStat structStat) { + this.st_mode = structStat.st_mode; + this.st_ino = structStat.st_ino; + this.st_dev = structStat.st_dev; + this.st_rdev = structStat.st_rdev; + this.st_nlink = structStat.st_nlink; + this.st_uid = structStat.st_uid; + this.st_gid = structStat.st_gid; + this.st_size = structStat.st_size; + this.st_blksize = structStat.st_blksize; + this.st_blocks = structStat.st_blocks; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + this.st_atime_sec = structStat.st_atim.tv_sec; + this.st_atime_nsec = structStat.st_atim.tv_nsec; + this.st_mtime_sec = structStat.st_mtim.tv_sec; + this.st_mtime_nsec = structStat.st_mtim.tv_nsec; + this.st_ctime_sec = structStat.st_ctim.tv_sec; + this.st_ctime_nsec = structStat.st_ctim.tv_nsec; + } else { + this.st_atime_sec = structStat.st_atime; + this.st_atime_nsec = 0; + this.st_mtime_sec = structStat.st_mtime; + this.st_mtime_nsec = 0; + this.st_ctime_sec = structStat.st_ctime; + this.st_ctime_nsec = 0; + } + } + + public String getFileString() { + return "File: `" + file() + "`"; + } + + public String getTypeString() { + return "Type: `" + FileTypes.getFileType(this).getName() + "`"; + } + + public String getSizeString() { + return "Size: `" + size() + "`"; + } + + public String getBlocksString() { + return "Blocks: `" + blocks() + "`"; + } + + public String getIOBlockString() { + return "IO Block: `" + blksize() + "`"; + } + + public String getDeviceString() { + return "Device: `" + Long.toHexString(st_dev) + "`"; + } + + public String getInodeString() { + return "Inode: `" + st_ino + "`"; + } + + public String getLinksString() { + return "Links: `" + nlink() + "`"; + } + + public String getDeviceTypeString() { + return "Device Type: `" + rdev() + "`"; + } + + public String getOwnerString() { + return "Owner: `" + owner() + "`"; + } + + public String getGroupString() { + return "Group: `" + group() + "`"; + } + + public String getPermissionString() { + return "Permissions: `" + FilePermissions.toString(permissions()) + "`"; + } + + public String getAccessTimeString() { + return "Access Time: `" + lastAccessTime() + "`"; + } + + public String getModifiedTimeString() { + return "Modified Time: `" + lastModifiedTime() + "`"; + } + + public String getChangeTimeString() { + return "Change Time: `" + lastChangeTime() + "`"; + } + + @NonNull + @Override + public String toString() { + return getFileAttributesLogString(this); + } + + public static String getFileAttributesLogString(final FileAttributes fileAttributes) { + if (fileAttributes == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + logString.append(fileAttributes.getFileString()); + + logString.append("\n").append(fileAttributes.getTypeString()); + + logString.append("\n").append(fileAttributes.getSizeString()); + logString.append("\n").append(fileAttributes.getBlocksString()); + logString.append("\n").append(fileAttributes.getIOBlockString()); + + logString.append("\n").append(fileAttributes.getDeviceString()); + logString.append("\n").append(fileAttributes.getInodeString()); + logString.append("\n").append(fileAttributes.getLinksString()); + + if (fileAttributes.isBlock() || fileAttributes.isCharacter()) + logString.append("\n").append(fileAttributes.getDeviceTypeString()); + + logString.append("\n").append(fileAttributes.getOwnerString()); + logString.append("\n").append(fileAttributes.getGroupString()); + logString.append("\n").append(fileAttributes.getPermissionString()); + + logString.append("\n").append(fileAttributes.getAccessTimeString()); + logString.append("\n").append(fileAttributes.getModifiedTimeString()); + logString.append("\n").append(fileAttributes.getChangeTimeString()); + + return logString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java new file mode 100644 index 000000000..bc65cf167 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.shared.file.filesystem; + +/** + * Container for device/inode to uniquely identify file. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixFileKey.java + */ + +public class FileKey { + private final long st_dev; + private final long st_ino; + + FileKey(long st_dev, long st_ino) { + this.st_dev = st_dev; + this.st_ino = st_ino; + } + + @Override + public int hashCode() { + return (int)(st_dev ^ (st_dev >>> 32)) + + (int)(st_ino ^ (st_ino >>> 32)); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof FileKey)) + return false; + FileKey other = (FileKey)obj; + return (this.st_dev == other.st_dev) && (this.st_ino == other.st_ino); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("(dev=") + .append(Long.toHexString(st_dev)) + .append(",ino=") + .append(st_ino) + .append(')'); + return sb.toString(); + } +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java new file mode 100644 index 000000000..b9b1ed0ea --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java @@ -0,0 +1,88 @@ + +/* + * Copyright (c) 2007, 2011, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.shared.file.filesystem; + +/** + * Defines the bits for use with the {@link FileAttributes#permissions() + * permissions} attribute. + * + *

The {@link FileAttributes} class defines methods for manipulating + * set of permissions. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/PosixFilePermission.java + * + * @since 1.7 + */ + +public enum FilePermission { + + /** + * Read permission, owner. + */ + OWNER_READ, + + /** + * Write permission, owner. + */ + OWNER_WRITE, + + /** + * Execute/search permission, owner. + */ + OWNER_EXECUTE, + + /** + * Read permission, group. + */ + GROUP_READ, + + /** + * Write permission, group. + */ + GROUP_WRITE, + + /** + * Execute/search permission, group. + */ + GROUP_EXECUTE, + + /** + * Read permission, others. + */ + OTHERS_READ, + + /** + * Write permission, others. + */ + OTHERS_WRITE, + + /** + * Execute/search permission, others. + */ + OTHERS_EXECUTE + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java new file mode 100644 index 000000000..e06916099 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2007, 2011, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.shared.file.filesystem; + +import static com.termux.shared.file.filesystem.FilePermission.*; + +import java.util.*; + +/** + * This class consists exclusively of static methods that operate on sets of + * {@link FilePermission} objects. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/PosixFilePermissions.java + * + * @since 1.7 + */ + +public final class FilePermissions { + private FilePermissions() { } + + // Write string representation of permission bits to {@code sb}. + private static void writeBits(StringBuilder sb, boolean r, boolean w, boolean x) { + if (r) { + sb.append('r'); + } else { + sb.append('-'); + } + if (w) { + sb.append('w'); + } else { + sb.append('-'); + } + if (x) { + sb.append('x'); + } else { + sb.append('-'); + } + } + + /** + * Returns the {@code String} representation of a set of permissions. It + * is guaranteed that the returned {@code String} can be parsed by the + * {@link #fromString} method. + * + *

If the set contains {@code null} or elements that are not of type + * {@code FilePermission} then these elements are ignored. + * + * @param perms + * the set of permissions + * + * @return the string representation of the permission set + */ + public static String toString(Set perms) { + StringBuilder sb = new StringBuilder(9); + writeBits(sb, perms.contains(OWNER_READ), perms.contains(OWNER_WRITE), + perms.contains(OWNER_EXECUTE)); + writeBits(sb, perms.contains(GROUP_READ), perms.contains(GROUP_WRITE), + perms.contains(GROUP_EXECUTE)); + writeBits(sb, perms.contains(OTHERS_READ), perms.contains(OTHERS_WRITE), + perms.contains(OTHERS_EXECUTE)); + return sb.toString(); + } + + private static boolean isSet(char c, char setValue) { + if (c == setValue) + return true; + if (c == '-') + return false; + throw new IllegalArgumentException("Invalid mode"); + } + private static boolean isR(char c) { return isSet(c, 'r'); } + private static boolean isW(char c) { return isSet(c, 'w'); } + private static boolean isX(char c) { return isSet(c, 'x'); } + + /** + * Returns the set of permissions corresponding to a given {@code String} + * representation. + * + *

The {@code perms} parameter is a {@code String} representing the + * permissions. It has 9 characters that are interpreted as three sets of + * three. The first set refers to the owner's permissions; the next to the + * group permissions and the last to others. Within each set, the first + * character is {@code 'r'} to indicate permission to read, the second + * character is {@code 'w'} to indicate permission to write, and the third + * character is {@code 'x'} for execute permission. Where a permission is + * not set then the corresponding character is set to {@code '-'}. + * + *

Usage Example: + * Suppose we require the set of permissions that indicate the owner has read, + * write, and execute permissions, the group has read and execute permissions + * and others have none. + *

+     *   Set<FilePermission> perms = FilePermissions.fromString("rwxr-x---");
+     * 
+ * + * @param perms + * string representing a set of permissions + * + * @return the resulting set of permissions + * + * @throws IllegalArgumentException + * if the string cannot be converted to a set of permissions + * + * @see #toString(Set) + */ + public static Set fromString(String perms) { + if (perms.length() != 9) + throw new IllegalArgumentException("Invalid mode"); + Set result = EnumSet.noneOf(FilePermission.class); + if (isR(perms.charAt(0))) result.add(OWNER_READ); + if (isW(perms.charAt(1))) result.add(OWNER_WRITE); + if (isX(perms.charAt(2))) result.add(OWNER_EXECUTE); + if (isR(perms.charAt(3))) result.add(GROUP_READ); + if (isW(perms.charAt(4))) result.add(GROUP_WRITE); + if (isX(perms.charAt(5))) result.add(GROUP_EXECUTE); + if (isR(perms.charAt(6))) result.add(OTHERS_READ); + if (isW(perms.charAt(7))) result.add(OTHERS_WRITE); + if (isX(perms.charAt(8))) result.add(OTHERS_EXECUTE); + return result; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java new file mode 100644 index 000000000..14c1d8b5a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2009, 2013, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.shared.file.filesystem; + +import androidx.annotation.NonNull; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Represents the value of a file's time stamp attribute. For example, it may + * represent the time that the file was last + * {@link FileAttributes#lastModifiedTime() modified}, + * {@link FileAttributes#lastAccessTime() accessed}, + * or {@link FileAttributes#creationTime() created}. + * + *

Instances of this class are immutable. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/FileTime.java + * + * @since 1.7 + * @see java.nio.file.Files#setLastModifiedTime + * @see java.nio.file.Files#getLastModifiedTime + */ + +public final class FileTime { + /** + * The unit of granularity to interpret the value. Null if + * this {@code FileTime} is converted from an {@code Instant}, + * the {@code value} and {@code unit} pair will not be used + * in this scenario. + */ + private final TimeUnit unit; + + /** + * The value since the epoch; can be negative. + */ + private final long value; + + + /** + * The value return by toString (created lazily) + */ + private String valueAsString; + + /** + * Initializes a new instance of this class. + */ + private FileTime(long value, TimeUnit unit) { + this.value = value; + this.unit = unit; + } + + /** + * Returns a {@code FileTime} representing a value at the given unit of + * granularity. + * + * @param value + * the value since the epoch (1970-01-01T00:00:00Z); can be + * negative + * @param unit + * the unit of granularity to interpret the value + * + * @return a {@code FileTime} representing the given value + */ + public static FileTime from(long value, @NonNull TimeUnit unit) { + Objects.requireNonNull(unit, "unit"); + return new FileTime(value, unit); + } + + /** + * Returns a {@code FileTime} representing the given value in milliseconds. + * + * @param value + * the value, in milliseconds, since the epoch + * (1970-01-01T00:00:00Z); can be negative + * + * @return a {@code FileTime} representing the given value + */ + public static FileTime fromMillis(long value) { + return new FileTime(value, TimeUnit.MILLISECONDS); + } + + /** + * Returns the value at the given unit of granularity. + * + *

Conversion from a coarser granularity that would numerically overflow + * saturate to {@code Long.MIN_VALUE} if negative or {@code Long.MAX_VALUE} + * if positive. + * + * @param unit + * the unit of granularity for the return value + * + * @return value in the given unit of granularity, since the epoch + * since the epoch (1970-01-01T00:00:00Z); can be negative + */ + public long to(TimeUnit unit) { + Objects.requireNonNull(unit, "unit"); + return unit.convert(this.value, this.unit); + } + + /** + * Returns the value in milliseconds. + * + *

Conversion from a coarser granularity that would numerically overflow + * saturate to {@code Long.MIN_VALUE} if negative or {@code Long.MAX_VALUE} + * if positive. + * + * @return the value in milliseconds, since the epoch (1970-01-01T00:00:00Z) + */ + public long toMillis() { + return unit.toMillis(value); + } + + @NonNull + @Override + public String toString() { + return getDate(toMillis(), "yyyy.MM.dd HH:mm:ss.SSS z"); + } + + public static String getDate(long milliSeconds, String format) { + try { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(milliSeconds); + return new SimpleDateFormat(format).format(calendar.getTime()); + } catch(Exception e) { + return Long.toString(milliSeconds); + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java new file mode 100644 index 000000000..ff9418fc4 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java @@ -0,0 +1,32 @@ +package com.termux.shared.file.filesystem; + +/** The {@link Enum} that defines file types. */ +public enum FileType { + + NO_EXIST("no exist", 0), // 00000000 + REGULAR("regular", 1), // 00000001 + DIRECTORY("directory", 2), // 00000010 + SYMLINK("symlink", 4), // 00000100 + SOCKET("socket", 8), // 00001000 + CHARACTER("character", 16), // 00010000 + FIFO("fifo", 32), // 00100000 + BLOCK("block", 64), // 01000000 + UNKNOWN("unknown", 128); // 10000000 + + private final String name; + private final int value; + + FileType(final String name, final int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public int getValue() { + return value; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java new file mode 100644 index 000000000..29f2b294d --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java @@ -0,0 +1,119 @@ +package com.termux.shared.file.filesystem; + +import android.system.Os; + +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; + +import java.io.File; + +public class FileTypes { + + /** Flags to represent regular, directory and symlink file types defined by {@link FileType} */ + public static final int FILE_TYPE_NORMAL_FLAGS = FileType.REGULAR.getValue() | FileType.DIRECTORY.getValue() | FileType.SYMLINK.getValue(); + + /** Flags to represent any file type defined by {@link FileType} */ + public static final int FILE_TYPE_ANY_FLAGS = Integer.MAX_VALUE; // 1111111111111111111111111111111 (31 1's) + + public static String convertFileTypeFlagsToNamesString(int fileTypeFlags) { + StringBuilder fileTypeFlagsStringBuilder = new StringBuilder(); + + FileType[] fileTypes = {FileType.REGULAR, FileType.DIRECTORY, FileType.SYMLINK, FileType.CHARACTER, FileType.FIFO, FileType.BLOCK, FileType.UNKNOWN}; + for (FileType fileType : fileTypes) { + if ((fileTypeFlags & fileType.getValue()) > 0) + fileTypeFlagsStringBuilder.append(fileType.getName()).append(","); + } + + String fileTypeFlagsString = fileTypeFlagsStringBuilder.toString(); + + if (fileTypeFlagsString.endsWith(",")) + fileTypeFlagsString = fileTypeFlagsString.substring(0, fileTypeFlagsString.lastIndexOf(",")); + + return fileTypeFlagsString; + } + + /** + * Checks the type of file that exists at {@code filePath}. + * + * Returns: + * - {@link FileType#NO_EXIST} if {@code filePath} is {@code null}, empty, an exception is raised + * or no file exists at {@code filePath}. + * - {@link FileType#REGULAR} if file at {@code filePath} is a regular file. + * - {@link FileType#DIRECTORY} if file at {@code filePath} is a directory file. + * - {@link FileType#SYMLINK} if file at {@code filePath} is a symlink file and {@code followLinks} is {@code false}. + * - {@link FileType#CHARACTER} if file at {@code filePath} is a character special file. + * - {@link FileType#FIFO} if file at {@code filePath} is a fifo special file. + * - {@link FileType#BLOCK} if file at {@code filePath} is a block special file. + * - {@link FileType#UNKNOWN} if file at {@code filePath} is of unknown type. + * + * The {@link File#isFile()} and {@link File#isDirectory()} uses {@link Os#stat(String)} system + * call (not {@link Os#lstat(String)}) to check file type and does follow symlinks. + * + * The {@link File#exists()} uses {@link Os#access(String, int)} system call to check if file is + * accessible and does not follow symlinks. However, it returns {@code false} for dangling symlinks, + * on android at least. Check https://stackoverflow.com/a/57747064/14686958 + * + * Basically {@link File} API is not reliable to check for symlinks. + * + * So we get the file type directly with {@link Os#lstat(String)} if {@code followLinks} is + * {@code false} and {@link Os#stat(String)} if {@code followLinks} is {@code true}. All exceptions + * are assumed as non-existence. + * + * The {@link org.apache.commons.io.FileUtils#isSymlink(File)} can also be used for checking + * symlinks but {@link FileAttributes} will provide access to more attributes if necessary, + * including getting other special file types considering that {@link File#exists()} can't be + * used to reliably check for non-existence and exclude the other 3 file types. commons.io is + * also not compatible with android < 8 for many things. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/File.java;l=793 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/UnixFileSystem.java;l=248 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/native/UnixFileSystem_md.c;l=121 + * https://cs.android.com/android/_/android/platform/libcore/+/001ac51d61ad7443ba518bf2cf7e086efe698c6d + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/libcore/io/Os.java;l=51 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/libcore/io/Libcore.java;l=45 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/app/ActivityThread.java;l=7530 + * + * @param filePath The {@code path} for file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding type. If set to {@code true}, then type of symlink target will + * be returned if file at {@code filePath} is a symlink. If set to + * {@code false}, then type of file at {@code filePath} itself will be + * returned. + * @return Returns the {@link FileType} of file. + */ + @NonNull + public static FileType getFileType(final String filePath, final boolean followLinks) { + if (filePath == null || filePath.isEmpty()) return FileType.NO_EXIST; + + try { + FileAttributes fileAttributes = FileAttributes.get(filePath, followLinks); + return getFileType(fileAttributes); + } catch (Exception e) { + // If not a ENOENT (No such file or directory) exception + if (e.getMessage() != null && !e.getMessage().contains("ENOENT")) + Logger.logError("Failed to get file type for file at path \"" + filePath + "\": " + e.getMessage()); + return FileType.NO_EXIST; + } + } + + public static FileType getFileType(@NonNull final FileAttributes fileAttributes) { + if (fileAttributes.isRegularFile()) + return FileType.REGULAR; + else if (fileAttributes.isDirectory()) + return FileType.DIRECTORY; + else if (fileAttributes.isSymbolicLink()) + return FileType.SYMLINK; + else if (fileAttributes.isSocket()) + return FileType.SOCKET; + else if (fileAttributes.isCharacter()) + return FileType.CHARACTER; + else if (fileAttributes.isFifo()) + return FileType.FIFO; + else if (fileAttributes.isBlock()) + return FileType.BLOCK; + else + return FileType.UNKNOWN; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java new file mode 100644 index 000000000..08ba80885 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java @@ -0,0 +1,58 @@ +package com.termux.shared.file.filesystem; + +import android.system.ErrnoException; +import android.system.Os; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.IOException; + +public class NativeDispatcher { + + public static void stat(String filePath, FileAttributes fileAttributes) throws IOException { + validateFileExistence(filePath); + + try { + fileAttributes.loadFromStructStat(Os.stat(filePath)); + } catch (ErrnoException e) { + throw new IOException("Failed to run Os.stat() on file at path \"" + filePath + "\": " + e.getMessage()); + } + } + + public static void lstat(String filePath, FileAttributes fileAttributes) throws IOException { + validateFileExistence(filePath); + + try { + fileAttributes.loadFromStructStat(Os.lstat(filePath)); + } catch (ErrnoException e) { + throw new IOException("Failed to run Os.lstat() on file at path \"" + filePath + "\": " + e.getMessage()); + } + } + + public static void fstat(FileDescriptor fileDescriptor, FileAttributes fileAttributes) throws IOException { + validateFileDescriptor(fileDescriptor); + + try { + fileAttributes.loadFromStructStat(Os.fstat(fileDescriptor)); + } catch (ErrnoException e) { + throw new IOException("Failed to run Os.fstat() on file descriptor \"" + fileDescriptor.toString() + "\": " + e.getMessage()); + } + } + + public static void validateFileExistence(String filePath) throws IOException { + if (filePath == null || filePath.isEmpty()) throw new IOException("The path is null or empty"); + + File file = new File(filePath); + + //if (!file.exists()) + // throw new IOException("No such file or directory: \"" + filePath + "\""); + } + + public static void validateFileDescriptor(FileDescriptor fileDescriptor) throws IOException { + if (fileDescriptor == null) throw new IOException("The file descriptor is null"); + + if (!fileDescriptor.valid()) + throw new IOException("No such file descriptor: \"" + fileDescriptor.toString() + "\""); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java new file mode 100644 index 000000000..9659be7e7 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java @@ -0,0 +1,158 @@ + +/* + * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved. + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + * + */ +// AUTOMATICALLY GENERATED FILE - DO NOT EDIT +package com.termux.shared.file.filesystem; + +// BEGIN Android-changed: Use constants from android.system.OsConstants. http://b/32203242 +// Those constants are initialized by native code to ensure correctness on different architectures. +// AT_SYMLINK_NOFOLLOW (used by fstatat) and AT_REMOVEDIR (used by unlinkat) as of July 2018 do not +// have equivalents in android.system.OsConstants so left unchanged. +import android.os.Build; +import android.system.OsConstants; + +import androidx.annotation.RequiresApi; + +/** + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixConstants.java + */ +public class UnixConstants { + private UnixConstants() { } + + static final int O_RDONLY = OsConstants.O_RDONLY; + + static final int O_WRONLY = OsConstants.O_WRONLY; + + static final int O_RDWR = OsConstants.O_RDWR; + + static final int O_APPEND = OsConstants.O_APPEND; + + static final int O_CREAT = OsConstants.O_CREAT; + + static final int O_EXCL = OsConstants.O_EXCL; + + static final int O_TRUNC = OsConstants.O_TRUNC; + + static final int O_SYNC = OsConstants.O_SYNC; + + // Crash on Android 5. + // No static field O_DSYNC of type I in class Landroid/system/OsConstants; or its superclasses + // (declaration of 'android.system.OsConstants' appears in /system/framework/core-libart.jar) + //@RequiresApi(Build.VERSION_CODES.O_MR1) + //static final int O_DSYNC = OsConstants.O_DSYNC; + + static final int O_NOFOLLOW = OsConstants.O_NOFOLLOW; + + static final int S_IAMB = get_S_IAMB(); + + static final int S_IRUSR = OsConstants.S_IRUSR; + + static final int S_IWUSR = OsConstants.S_IWUSR; + + static final int S_IXUSR = OsConstants.S_IXUSR; + + static final int S_IRGRP = OsConstants.S_IRGRP; + + static final int S_IWGRP = OsConstants.S_IWGRP; + + static final int S_IXGRP = OsConstants.S_IXGRP; + + static final int S_IROTH = OsConstants.S_IROTH; + + static final int S_IWOTH = OsConstants.S_IWOTH; + + static final int S_IXOTH = OsConstants.S_IXOTH; + + static final int S_IFMT = OsConstants.S_IFMT; + + static final int S_IFREG = OsConstants.S_IFREG; + + static final int S_IFDIR = OsConstants.S_IFDIR; + + static final int S_IFLNK = OsConstants.S_IFLNK; + + static final int S_IFSOCK = OsConstants.S_IFSOCK; + + static final int S_IFCHR = OsConstants.S_IFCHR; + + static final int S_IFBLK = OsConstants.S_IFBLK; + + static final int S_IFIFO = OsConstants.S_IFIFO; + + static final int R_OK = OsConstants.R_OK; + + static final int W_OK = OsConstants.W_OK; + + static final int X_OK = OsConstants.X_OK; + + static final int F_OK = OsConstants.F_OK; + + static final int ENOENT = OsConstants.ENOENT; + + static final int EACCES = OsConstants.EACCES; + + static final int EEXIST = OsConstants.EEXIST; + + static final int ENOTDIR = OsConstants.ENOTDIR; + + static final int EINVAL = OsConstants.EINVAL; + + static final int EXDEV = OsConstants.EXDEV; + + static final int EISDIR = OsConstants.EISDIR; + + static final int ENOTEMPTY = OsConstants.ENOTEMPTY; + + static final int ENOSPC = OsConstants.ENOSPC; + + static final int EAGAIN = OsConstants.EAGAIN; + + static final int ENOSYS = OsConstants.ENOSYS; + + static final int ELOOP = OsConstants.ELOOP; + + static final int EROFS = OsConstants.EROFS; + + static final int ENODATA = OsConstants.ENODATA; + + static final int ERANGE = OsConstants.ERANGE; + + static final int EMFILE = OsConstants.EMFILE; + + // S_IAMB are access mode bits, therefore, calculated by taking OR of all the read, write and + // execute permissions bits for owner, group and other. + private static int get_S_IAMB() { + return (OsConstants.S_IRUSR | OsConstants.S_IWUSR | OsConstants.S_IXUSR | + OsConstants.S_IRGRP | OsConstants.S_IWGRP | OsConstants.S_IXGRP | + OsConstants.S_IROTH | OsConstants.S_IWOTH | OsConstants.S_IXOTH); + } + // END Android-changed: Use constants from android.system.OsConstants. http://b/32203242 + + + static final int AT_SYMLINK_NOFOLLOW = 0x100; + static final int AT_REMOVEDIR = 0x200; +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java b/termux/termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java new file mode 100644 index 000000000..bf1c210c0 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java @@ -0,0 +1,396 @@ +package com.termux.shared.file.tests; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.errors.Errno; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.FileUtilsErrno; +import com.termux.shared.logger.Logger; +import com.termux.shared.errors.Error; + +import java.io.File; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.List; + +public class FileUtilsTests { + + private static final String LOG_TAG = "FileUtilsTests"; + + /** + * Run basic tests for {@link FileUtils} class. + * + * Move tests need to be written, specially for failures. + * + * The log level must be set to verbose. + * + * Run at app startup like in an activity + * FileUtilsTests.runTests(this, TermuxConstants.TERMUX_HOME_DIR_PATH + "/FileUtilsTests"); + * + * @param context The {@link Context} for operations. + */ + public static void runTests(@NonNull final Context context, @NonNull final String testRootDirectoryPath) { + try { + Logger.logInfo(LOG_TAG, "Running tests"); + Logger.logInfo(LOG_TAG, "testRootDirectoryPath: \"" + testRootDirectoryPath + "\""); + + String fileUtilsTestsDirectoryCanonicalPath = FileUtils.getCanonicalPath(testRootDirectoryPath, null); + assertEqual("FileUtilsTests directory path is not a canonical path", testRootDirectoryPath, fileUtilsTestsDirectoryCanonicalPath); + + runTestsInner(testRootDirectoryPath); + Logger.logInfo(LOG_TAG, "All tests successful"); + } catch (Exception e) { + Logger.logErrorExtended(LOG_TAG, e.getMessage()); + Logger.showToast(context, e.getMessage() != null ? e.getMessage().replaceAll("(?s)\nFull Error:\n.*", "") : null, true); + } + } + + private static void runTestsInner(@NonNull final String testRootDirectoryPath) throws Exception { + Error error; + String label; + String path; + + /* + * - dir1 + * - sub_dir1 + * - sub_reg1 + * - sub_sym1 (absolute symlink to dir2) + * - sub_sym2 (copy of sub_sym1 for symlink to dir2) + * - sub_sym3 (relative symlink to dir4) + * - dir2 + * - sub_reg1 + * - sub_reg2 (copy of dir2/sub_reg1) + * - dir3 (copy of dir1) + * - dir4 (moved from dir3) + */ + + String dir1_label = "dir1"; + String dir1_path = testRootDirectoryPath + "/dir1"; + + String dir1__sub_dir1_label = "dir1/sub_dir1"; + String dir1__sub_dir1_path = dir1_path + "/sub_dir1"; + + String dir1__sub_dir2_label = "dir1/sub_dir2"; + String dir1__sub_dir2_path = dir1_path + "/sub_dir2"; + + String dir1__sub_dir3_label = "dir1/sub_dir3"; + String dir1__sub_dir3_path = dir1_path + "/sub_dir3"; + + String dir1__sub_dir3__sub_reg1_label = "dir1/sub_dir3/sub_reg1"; + String dir1__sub_dir3__sub_reg1_path = dir1__sub_dir3_path + "/sub_reg1"; + + String dir1__sub_reg1_label = "dir1/sub_reg1"; + String dir1__sub_reg1_path = dir1_path + "/sub_reg1"; + + String dir1__sub_sym1_label = "dir1/sub_sym1"; + String dir1__sub_sym1_path = dir1_path + "/sub_sym1"; + + String dir1__sub_sym2_label = "dir1/sub_sym2"; + String dir1__sub_sym2_path = dir1_path + "/sub_sym2"; + + String dir1__sub_sym3_label = "dir1/sub_sym3"; + String dir1__sub_sym3_path = dir1_path + "/sub_sym3"; + + + String dir2_label = "dir2"; + String dir2_path = testRootDirectoryPath + "/dir2"; + + String dir2__sub_reg1_label = "dir2/sub_reg1"; + String dir2__sub_reg1_path = dir2_path + "/sub_reg1"; + + String dir2__sub_reg2_label = "dir2/sub_reg2"; + String dir2__sub_reg2_path = dir2_path + "/sub_reg2"; + + + String dir3_label = "dir3"; + String dir3_path = testRootDirectoryPath + "/dir3"; + + String dir4_label = "dir4"; + String dir4_path = testRootDirectoryPath + "/dir4"; + + + + + + // Create or clear test root directory file + label = "testRootDirectoryPath"; + error = FileUtils.clearDirectory(label, testRootDirectoryPath); + assertEqual("Failed to create " + label + " directory file", null, error); + + if (!FileUtils.directoryFileExists(testRootDirectoryPath, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + + // Create dir1 directory file + error = FileUtils.createDirectoryFile(dir1_label, dir1_path); + assertEqual("Failed to create " + dir1_label + " directory file", null, error); + + // Create dir2 directory file + error = FileUtils.createDirectoryFile(dir2_label, dir2_path); + assertEqual("Failed to create " + dir2_label + " directory file", null, error); + + + + + + // Create dir1/sub_dir1 directory file + label = dir1__sub_dir1_label; path = dir1__sub_dir1_path; + error = FileUtils.createDirectoryFile(label, path); + assertEqual("Failed to create " + label + " directory file", null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + // Create dir1/sub_reg1 regular file + label = dir1__sub_reg1_label; path = dir1__sub_reg1_path; + error = FileUtils.createRegularFile(label, path); + assertEqual("Failed to create " + label + " regular file", null, error); + if (!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " regular file does not exist as expected after creation"); + + // Create dir1/sub_sym1 -> dir2 absolute symlink file + label = dir1__sub_sym1_label; path = dir1__sub_sym1_path; + error = FileUtils.createSymlinkFile(label, dir2_path, path); + assertEqual("Failed to create " + label + " symlink file", null, error); + if (!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " symlink file does not exist as expected after creation"); + + // Copy dir1/sub_sym1 symlink file to dir1/sub_sym2 + label = dir1__sub_sym2_label; path = dir1__sub_sym2_path; + error = FileUtils.copySymlinkFile(label, dir1__sub_sym1_path, path, false); + assertEqual("Failed to copy " + dir1__sub_sym1_label + " symlink file to " + label, null, error); + if (!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " symlink file does not exist as expected after copying it from " + dir1__sub_sym1_label); + if (!new File(path).getCanonicalPath().equals(dir2_path)) + throwException("The " + label + " symlink file does not point to " + dir2_label); + + + + + + // Write "line1" to dir2/sub_reg1 regular file + label = dir2__sub_reg1_label; path = dir2__sub_reg1_path; + error = FileUtils.writeTextToFile(label, path, Charset.defaultCharset(), "line1", false); + assertEqual("Failed to write string to " + label + " file with append mode false", null, error); + if (!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " file does not exist as expected after writing to it with append mode false"); + + // Write "line2" to dir2/sub_reg1 regular file + error = FileUtils.writeTextToFile(label, path, Charset.defaultCharset(), "\nline2", true); + assertEqual("Failed to write string to " + label + " file with append mode true", null, error); + + // Read dir2/sub_reg1 regular file + StringBuilder dataStringBuilder = new StringBuilder(); + error = FileUtils.readTextFromFile(label, path, Charset.defaultCharset(), dataStringBuilder, false); + assertEqual("Failed to read from " + label + " file", null, error); + assertEqual("The data read from " + label + " file in not as expected", "line1\nline2", dataStringBuilder.toString()); + + // Copy dir2/sub_reg1 regular file to dir2/sub_reg2 file + label = dir2__sub_reg2_label; path = dir2__sub_reg2_path; + error = FileUtils.copyRegularFile(label, dir2__sub_reg1_path, path, false); + assertEqual("Failed to copy " + dir2__sub_reg1_label + " regular file to " + label, null, error); + if (!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " regular file does not exist as expected after copying it from " + dir2__sub_reg1_label); + + + + + + // Copy dir1 directory file to dir3 + label = dir3_label; path = dir3_path; + error = FileUtils.copyDirectoryFile(label, dir2_path, path, false); + assertEqual("Failed to copy " + dir2_label + " directory file to " + label, null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after copying it from " + dir2_label); + + // Copy dir1 directory file to dir3 again to test overwrite + label = dir3_label; path = dir3_path; + error = FileUtils.copyDirectoryFile(label, dir2_path, path, false); + assertEqual("Failed to copy " + dir2_label + " directory file to " + label, null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after copying it from " + dir2_label); + + // Move dir3 directory file to dir4 + label = dir4_label; path = dir4_path; + error = FileUtils.moveDirectoryFile(label, dir3_path, path, false); + assertEqual("Failed to move " + dir3_label + " directory file to " + label, null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after copying it from " + dir3_label); + + + + + + // Create dir1/sub_sym3 -> dir4 relative symlink file + label = dir1__sub_sym3_label; path = dir1__sub_sym3_path; + error = FileUtils.createSymlinkFile(label, "../dir4", path); + assertEqual("Failed to create " + label + " symlink file", null, error); + if (!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " symlink file does not exist as expected after creation"); + + // Create dir1/sub_sym3 -> dirX relative dangling symlink file + // This is to ensure that symlinkFileExists returns true if a symlink file exists but is dangling + label = dir1__sub_sym3_label; path = dir1__sub_sym3_path; + error = FileUtils.createSymlinkFile(label, "../dirX", path); + assertEqual("Failed to create " + label + " symlink file", null, error); + if (!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " dangling symlink file does not exist as expected after creation"); + + + + + + // Delete dir1/sub_sym2 symlink file + label = dir1__sub_sym2_label; path = dir1__sub_sym2_path; + error = FileUtils.deleteSymlinkFile(label, path, false); + assertEqual("Failed to delete " + label + " symlink file", null, error); + if (FileUtils.fileExists(path, false)) + throwException("The " + label + " symlink file still exist after deletion"); + + // Check if dir2 directory file still exists after deletion of dir1/sub_sym2 since it was a symlink to dir2 + // When deleting a symlink file, its target must not be deleted + label = dir2_label; path = dir2_path; + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file has unexpectedly been deleted after deletion of " + dir1__sub_sym2_label); + + + + + + // Delete dir1 directory file + label = dir1_label; path = dir1_path; + error = FileUtils.deleteDirectoryFile(label, path, false); + assertEqual("Failed to delete " + label + " directory file", null, error); + if (FileUtils.fileExists(path, false)) + throwException("The " + label + " directory file still exist after deletion"); + + + // Check if dir2 directory file and dir2/sub_reg1 regular file still exist after deletion of + // dir1 since there was a dir1/sub_sym1 symlink to dir2 in it + // When deleting a directory, any targets of symlinks must not be deleted when deleting symlink files + label = dir2_label; path = dir2_path; + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file has unexpectedly been deleted after deletion of " + dir1_label); + label = dir2__sub_reg1_label; path = dir2__sub_reg1_path; + if (!FileUtils.fileExists(path, false)) + throwException("The " + label + " regular file has unexpectedly been deleted after deletion of " + dir1_label); + + + + + + // Delete dir2/sub_reg1 regular file + label = dir2__sub_reg1_label; path = dir2__sub_reg1_path; + error = FileUtils.deleteRegularFile(label, path, false); + assertEqual("Failed to delete " + label + " regular file", null, error); + if (FileUtils.fileExists(path, false)) + throwException("The " + label + " regular file still exist after deletion"); + + + List ignoredSubFilePaths = Arrays.asList(dir1__sub_dir2_path, dir1__sub_dir3__sub_reg1_path); + + // Create dir1 directory file + error = FileUtils.createDirectoryFile(dir1_label, dir1_path); + assertEqual("Failed to create " + dir1_label + " directory file", null, error); + + // Test empty dir + error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false); + assertEqual("Failed to validate if " + dir1_label + " directory file is empty", null, error); + + + // Create dir1/sub_dir3 directory file + label = dir1__sub_dir3_label; path = dir1__sub_dir3_path; + error = FileUtils.createDirectoryFile(label, path); + assertEqual("Failed to create " + label + " directory file", null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + // Test parent dir existing of non existing ignored regular file + error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false); + assertErrnoEqual("Failed to validate if " + dir1_label + " directory file is empty with parent dir existing of non existing ignored regular file", FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE, error); + + + // Write "line1" to dir1/sub_dir3/sub_reg1 regular file + label = dir1__sub_dir3__sub_reg1_label; path = dir1__sub_dir3__sub_reg1_path; + error = FileUtils.writeTextToFile(label, path, Charset.defaultCharset(), "line1", false); + assertEqual("Failed to write string to " + label + " file with append mode false", null, error); + if (!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " file does not exist as expected after writing to it with append mode false"); + + // Test ignored regular file existing + error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false); + assertEqual("Failed to validate if " + dir1_label + " directory file is empty with ignored regular file existing", null, error); + + + // Create dir1/sub_dir2 directory file + label = dir1__sub_dir2_label; path = dir1__sub_dir2_path; + error = FileUtils.createDirectoryFile(label, path); + assertEqual("Failed to create " + label + " directory file", null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + // Test ignored dir file existing + error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false); + assertEqual("Failed to validate if " + dir1_label + " directory file is empty with ignored dir file existing", null, error); + + + // Create dir1/sub_dir1 directory file + label = dir1__sub_dir1_label; path = dir1__sub_dir1_path; + error = FileUtils.createDirectoryFile(label, path); + assertEqual("Failed to create " + label + " directory file", null, error); + if (!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + // Test non ignored dir file existing + error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false); + assertErrnoEqual("Failed to validate if " + dir1_label + " directory file is empty with non ignored dir file existing", FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE, error); + + + // Delete dir1 directory file + label = dir1_label; path = dir1_path; + error = FileUtils.deleteDirectoryFile(label, path, false); + assertEqual("Failed to delete " + label + " directory file", null, error); + + + FileUtils.getFileType("/dev/ptmx", false); + FileUtils.getFileType("/dev/null", false); + } + + + + public static void assertEqual(@NonNull final String message, final String expected, final Error actual) throws Exception { + String actualString = actual != null ? actual.getMessage() : null; + if (!equalsRegardingNull(expected, actualString)) + throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actualString + "\"\nFull Error:\n" + (actual != null ? actual.toString() : "")); + } + + public static void assertEqual(@NonNull final String message, final String expected, final String actual) throws Exception { + if (!equalsRegardingNull(expected, actual)) + throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actual + "\""); + } + + private static boolean equalsRegardingNull(final String expected, final String actual) { + if (expected == null) { + return actual == null; + } + + return isEquals(expected, actual); + } + + public static void assertErrnoEqual(@NonNull final String message, final Errno expected, final Error actual) throws Exception { + if ((expected == null && actual != null) || (expected != null && !expected.equalsErrorTypeAndCode(actual))) + throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actual + "\"\nFull Error:\n" + (actual != null ? actual.toString() : "")); + } + + + + private static boolean isEquals(String expected, String actual) { + return expected.equals(actual); + } + + public static void throwException(@NonNull final String message) throws Exception { + throw new Exception(message); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java new file mode 100644 index 000000000..d7543fc7c --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java @@ -0,0 +1,99 @@ +package com.termux.shared.interact; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Color; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import com.termux.shared.R; +import com.termux.shared.logger.Logger; + +public class MessageDialogUtils { + + /** + * Show a message in a dialog + * + * @param context The {@link Context} to use to start the dialog. An {@link Activity} {@link Context} + * must be passed, otherwise exceptions will be thrown. + * @param titleText The title text of the dialog. + * @param messageText The message text of the dialog. + * @param onDismiss The {@link DialogInterface.OnDismissListener} to run when dialog is dismissed. + */ + public static void showMessage(Context context, String titleText, String messageText, final DialogInterface.OnDismissListener onDismiss) { + showMessage(context, titleText, messageText, null, null, null, null, onDismiss); + } + + /** + * Show a message in a dialog + * + * @param context The {@link Context} to use to start the dialog. An {@link Activity} {@link Context} + * must be passed, otherwise exceptions will be thrown. + * @param titleText The title text of the dialog. + * @param messageText The message text of the dialog. + * @param positiveText The positive button text of the dialog. + * @param onPositiveButton The {@link DialogInterface.OnClickListener} to run when positive button + * is pressed. + * @param negativeText The negative button text of the dialog. If this is {@code null}, then + * negative button will not be shown. + * @param onNegativeButton The {@link DialogInterface.OnClickListener} to run when negative button + * is pressed. + * @param onDismiss The {@link DialogInterface.OnDismissListener} to run when dialog is dismissed. + */ + public static void showMessage(Context context, String titleText, String messageText, + String positiveText, + final DialogInterface.OnClickListener onPositiveButton, + String negativeText, + final DialogInterface.OnClickListener onNegativeButton, + final DialogInterface.OnDismissListener onDismiss) { + + AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.Theme_AppCompat_Light_Dialog); + + LayoutInflater inflater = (LayoutInflater) context.getSystemService( Context.LAYOUT_INFLATER_SERVICE ); + View view = inflater.inflate(R.layout.dialog_show_message, null); + if (view != null) { + builder.setView(view); + + TextView titleView = view.findViewById(R.id.dialog_title); + if (titleView != null) + titleView.setText(titleText); + + TextView messageView = view.findViewById(R.id.dialog_message); + if (messageView != null) + messageView.setText(messageText); + } + + if (positiveText == null) + positiveText = context.getString(android.R.string.ok); + builder.setPositiveButton(positiveText, onPositiveButton); + + if (negativeText != null) + builder.setNegativeButton(negativeText, onNegativeButton); + + if (onDismiss != null) + builder.setOnDismissListener(onDismiss); + + AlertDialog dialog = builder.create(); + + dialog.setOnShowListener(dialogInterface -> { + Logger.logError("dialog"); + Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (button != null) + button.setTextColor(Color.BLACK); + button = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); + if (button != null) + button.setTextColor(Color.BLACK); + }); + + dialog.show(); + } + + public static void exitAppWithErrorMessage(Context context, String titleText, String messageText) { + showMessage(context, titleText, messageText, dialog -> System.exit(0)); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java new file mode 100644 index 000000000..969502447 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java @@ -0,0 +1,225 @@ +package com.termux.shared.interact; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; + +import androidx.appcompat.app.AppCompatActivity; + +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.errors.Error; +import com.termux.shared.android.PermissionUtils; + +import java.nio.charset.Charset; + +import javax.annotation.Nullable; + +public class ShareUtils { + + private static final String LOG_TAG = "ShareUtils"; + + /** + * Open the system app chooser that allows the user to select which app to send the intent. + * + * @param context The context for operations. + * @param intent The intent that describes the choices that should be shown. + * @param title The title for choose menu. + */ + public static void openSystemAppChooser(final Context context, final Intent intent, final String title) { + if (context == null) return; + + final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.putExtra(Intent.EXTRA_TITLE, title); + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(chooserIntent); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to open system chooser for:\n" + IntentUtils.getIntentString(chooserIntent), e); + } + } + + /** + * Share text. + * + * @param context The context for operations. + * @param subject The subject for sharing. + * @param text The text to share. + */ + public static void shareText(final Context context, final String subject, final String text) { + shareText(context, subject, text, null); + } + + /** + * Share text. + * + * @param context The context for operations. + * @param subject The subject for sharing. + * @param text The text to share. + * @param title The title for share menu. + */ + public static void shareText(final Context context, final String subject, final String text, @Nullable final String title) { + if (context == null || text == null) return; + + final Intent shareTextIntent = new Intent(Intent.ACTION_SEND); + shareTextIntent.setType("text/plain"); + shareTextIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + shareTextIntent.putExtra(Intent.EXTRA_TEXT, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, true, false, false)); + + openSystemAppChooser(context, shareTextIntent, DataUtils.isNullOrEmpty(title) ? context.getString(R.string.title_share_with) : title); + } + + + + /** Wrapper for {@link #copyTextToClipboard(Context, String, String, String)} with `null` `clipDataLabel` and `toastString`. */ + public static void copyTextToClipboard(Context context, final String text) { + copyTextToClipboard(context, null, text, null); + } + + /** Wrapper for {@link #copyTextToClipboard(Context, String, String, String)} with `null` `clipDataLabel`. */ + public static void copyTextToClipboard(Context context, final String text, final String toastString) { + copyTextToClipboard(context, null, text, toastString); + } + + /** + * Copy the text to primary clip of the clipboard. + * + * @param context The context for operations. + * @param clipDataLabel The label to show to the user describing the copied text. + * @param text The text to copy. + * @param toastString If this is not {@code null} or empty, then a toast is shown if copying to + * clipboard is successful. + */ + public static void copyTextToClipboard(Context context, @Nullable final String clipDataLabel, + final String text, final String toastString) { + if (context == null || text == null) return; + + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboardManager == null) return; + + clipboardManager.setPrimaryClip(ClipData.newPlainText(clipDataLabel, + DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, + true, false, false))); + + if (toastString != null && !toastString.isEmpty()) + Logger.showToast(context, toastString, true); + } + + + + /** + * Wrapper for {@link #getTextFromClipboard(Context, boolean)} that returns primary text {@link String} + * if its set and not empty. + */ + @Nullable + public static String getTextStringFromClipboardIfSet(Context context, boolean coerceToText) { + CharSequence textCharSequence = getTextFromClipboard(context, coerceToText); + if (textCharSequence == null) return null; + String textString = textCharSequence.toString(); + return !textString.isEmpty() ? textString : null; + } + + /** + * Get the text from primary clip of the clipboard. + * + * @param context The context for operations. + * @param coerceToText Whether to call {@link ClipData.Item#coerceToText(Context)} to coerce + * non-text data to text. + * @return Returns the {@link CharSequence} of primary text. This will be `null` if failed to get it. + */ + @Nullable + public static CharSequence getTextFromClipboard(Context context, boolean coerceToText) { + if (context == null) return null; + + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboardManager == null) return null; + + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData == null) return null; + + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem == null) return null; + + return coerceToText ? clipItem.coerceToText(context) : clipItem.getText(); + } + + + + /** + * Open a url. + * + * @param context The context for operations. + * @param url The url to open. + */ + public static void openUrl(final Context context, final String url) { + if (context == null || url == null || url.isEmpty()) return; + Uri uri = Uri.parse(url); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + // If no activity found to handle intent, show system chooser + openSystemAppChooser(context, intent, context.getString(R.string.title_open_url_with)); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to open url \"" + url + "\"", e); + } + } + + /** + * Save a file at the path. + * + * If if path is under {@link Environment#getExternalStorageDirectory()} + * or `/sdcard` and storage permission is missing, it will be requested if {@code context} is an + * instance of {@link Activity} or {@link AppCompatActivity} and {@code storagePermissionRequestCode} + * is `>=0` and the function will automatically return. The caller should call this function again + * if user granted the permission. + * + * @param context The context for operations. + * @param label The label for file. + * @param filePath The path to save the file. + * @param text The text to write to file. + * @param showToast If set to {@code true}, then a toast is shown if saving to file is successful. + * @param storagePermissionRequestCode The request code to use while asking for permission. + */ + public static void saveTextToFile(final Context context, final String label, final String filePath, final String text, final boolean showToast, final int storagePermissionRequestCode) { + if (context == null || filePath == null || filePath.isEmpty() || text == null) return; + + // If path is under primary external storage directory, then check for missing permissions. + if ((FileUtils.isPathInDirPath(filePath, Environment.getExternalStorageDirectory().getAbsolutePath(), true) || + FileUtils.isPathInDirPath(filePath, "/sdcard", true)) && + !PermissionUtils.checkPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + Logger.logErrorAndShowToast(context, LOG_TAG, context.getString(R.string.msg_storage_permission_not_granted)); + + if (storagePermissionRequestCode >= 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (context instanceof AppCompatActivity) + PermissionUtils.requestPermission(((AppCompatActivity) context), Manifest.permission.WRITE_EXTERNAL_STORAGE, storagePermissionRequestCode); + else if (context instanceof Activity) + PermissionUtils.requestPermission(((Activity) context), Manifest.permission.WRITE_EXTERNAL_STORAGE, storagePermissionRequestCode); + } + + return; + } + + Error error = FileUtils.writeTextToFile(label, filePath, + Charset.defaultCharset(), text, false); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + Logger.showToast(context, Error.getMinimalErrorString(error), true); + } else { + if (showToast) + Logger.showToast(context, context.getString(R.string.msg_file_saved_successfully, label, filePath), true); + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java b/termux/termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java new file mode 100644 index 000000000..3621f9f3a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java @@ -0,0 +1,109 @@ +package com.termux.shared.jni.models; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; + +/** + * A class that can be used to return result for JNI calls with support for multiple fields to easily + * return success and error states. + * + * https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/functions.html + * https://developer.android.com/training/articles/perf-jni + */ +@Keep +public class JniResult { + + /** + * The return value for the JNI call. + * This should be 0 for success. + */ + public int retval; + + /** + * The errno value for any failed native system or library calls if {@link #retval} does not equal 0. + * This should be 0 if no errno was set. + * + * https://manpages.debian.org/testing/manpages-dev/errno.3.en.html + */ + public int errno; + + /** + * The error message for the failure if {@link #retval} does not equal 0. + * The message will contain errno message returned by strerror() if errno was set. + * + * https://manpages.debian.org/testing/manpages-dev/strerror.3.en.html + */ + public String errmsg; + + /** + * Optional additional int data that needs to be returned by JNI call, like bytes read on success. + */ + public int intData; + + /** + * Create an new instance of {@link JniResult}. + * + * @param retval The {@link #retval} value. + * @param errno The {@link #errno} value. + * @param errmsg The {@link #errmsg} value. + */ + public JniResult(int retval, int errno, String errmsg) { + this.retval = retval; + this.errno = errno; + this.errmsg = errmsg; + } + + /** + * Create an new instance of {@link JniResult}. + * + * @param retval The {@link #retval} value. + * @param errno The {@link #errno} value. + * @param errmsg The {@link #errmsg} value. + * @param intData The {@link #intData} value. + */ + public JniResult(int retval, int errno, String errmsg, int intData) { + this(retval, errno, errmsg); + this.intData = intData; + } + + /** + * Create an new instance of {@link JniResult} from a {@link Throwable} with {@link #retval} -1. + * + * @param message The error message. + * @param throwable The {@link Throwable} value. + */ + public JniResult(String message, Throwable throwable) { + this(-1, 0, Logger.getMessageAndStackTraceString(message, throwable)); + } + + /** + * Get error {@link String} for {@link JniResult}. + * + * @param result The {@link JniResult} to get error from. + * @return Returns the error {@link String}. + */ + @NonNull + public static String getErrorString(final JniResult result) { + if (result == null) return "null"; + return result.getErrorString(); + } + + /** Get error {@link String} for {@link JniResult}. */ + @NonNull + public String getErrorString() { + StringBuilder logString = new StringBuilder(); + + logString.append(Logger.getSingleLineLogStringEntry("Retval", retval, "-")); + + if (errno != 0) + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Errno", errno, "-")); + + if (errmsg != null && !errmsg.isEmpty()) + logString.append("\n").append(Logger.getMultiLineLogStringEntry("Errmsg", errmsg, "-")); + + return logString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/logger/Logger.java b/termux/termux-shared/src/main/java/com/termux/shared/logger/Logger.java new file mode 100644 index 000000000..e5075b1f9 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/logger/Logger.java @@ -0,0 +1,502 @@ +package com.termux.shared.logger; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Logger { + + private static String DEFAULT_LOG_TAG = "Logger"; + + public static final int LOG_LEVEL_OFF = 0; // log nothing + public static final int LOG_LEVEL_NORMAL = 1; // start logging error, warn and info messages and stacktraces + public static final int LOG_LEVEL_DEBUG = 2; // start logging debug messages + public static final int LOG_LEVEL_VERBOSE = 3; // start logging verbose messages + + public static final int DEFAULT_LOG_LEVEL = LOG_LEVEL_NORMAL; + public static final int MAX_LOG_LEVEL = LOG_LEVEL_VERBOSE; + private static int CURRENT_LOG_LEVEL = DEFAULT_LOG_LEVEL; + + /** + * The maximum size of the log entry payload that can be written to the logger. An attempt to + * write more than this amount will result in a truncated log entry. + * + * The limit is 4068 but this includes log tag and log level prefix "D/" before log tag and ": " + * suffix after it. + * + * #define LOGGER_ENTRY_MAX_PAYLOAD 4068 + * https://cs.android.com/android/_/android/platform/system/core/+/android10-release:liblog/include/log/log_read.h;l=127 + */ + public static final int LOGGER_ENTRY_MAX_PAYLOAD = 4068; // 4068 bytes + + /** + * The maximum safe size of the log entry payload that can be written to the logger, based on + * {@link #LOGGER_ENTRY_MAX_PAYLOAD}. Using 4000 as a safe limit to give log tag and its + * prefix/suffix max 68 characters for itself. Use "log*Extended()" functions to use max possible + * limit if tag is already known. + */ + public static final int LOGGER_ENTRY_MAX_SAFE_PAYLOAD = 4000; // 4000 bytes + + + + public static void logMessage(int logPriority, String tag, String message) { + if (logPriority == Log.ERROR && CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) + Log.e(getFullTag(tag), message); + else if (logPriority == Log.WARN && CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) + Log.w(getFullTag(tag), message); + else if (logPriority == Log.INFO && CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) + Log.i(getFullTag(tag), message); + else if (logPriority == Log.DEBUG && CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) + Log.d(getFullTag(tag), message); + else if (logPriority == Log.VERBOSE && CURRENT_LOG_LEVEL >= LOG_LEVEL_VERBOSE) + Log.v(getFullTag(tag), message); + } + + public static void logExtendedMessage(int logLevel, String tag, String message) { + if (message == null) return; + + int cutOffIndex; + int nextNewlineIndex; + String prefix = ""; + + // -8 for prefix "(xx/xx)" (max 99 sections), - log tag length, -4 for log tag prefix "D/" and suffix ": " + int maxEntrySize = LOGGER_ENTRY_MAX_PAYLOAD - 8 - getFullTag(tag).length() - 4; + + List messagesList = new ArrayList<>(); + + while(!message.isEmpty()) { + if (message.length() > maxEntrySize) { + cutOffIndex = maxEntrySize; + nextNewlineIndex = message.lastIndexOf('\n', cutOffIndex); + if (nextNewlineIndex != -1) { + cutOffIndex = nextNewlineIndex + 1; + } + messagesList.add(message.substring(0, cutOffIndex)); + message = message.substring(cutOffIndex); + } else { + messagesList.add(message); + break; + } + } + + for(int i=0; i 1) + prefix = "(" + (i + 1) + "/" + messagesList.size() + ")\n"; + logMessage(logLevel, tag, prefix + messagesList.get(i)); + } + } + + + + public static void logError(String tag, String message) { + logMessage(Log.ERROR, tag, message); + } + + public static void logError(String message) { + logMessage(Log.ERROR, DEFAULT_LOG_TAG, message); + } + + public static void logErrorExtended(String tag, String message) { + logExtendedMessage(Log.ERROR, tag, message); + } + + public static void logErrorExtended(String message) { + logExtendedMessage(Log.ERROR, DEFAULT_LOG_TAG, message); + } + + + + public static void logErrorPrivate(String tag, String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) + logMessage(Log.ERROR, tag, message); + } + + public static void logErrorPrivate(String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) + logMessage(Log.ERROR, DEFAULT_LOG_TAG, message); + } + + public static void logErrorPrivateExtended(String tag, String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) + logExtendedMessage(Log.ERROR, tag, message); + } + + public static void logErrorPrivateExtended(String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) + logExtendedMessage(Log.ERROR, DEFAULT_LOG_TAG, message); + } + + + + public static void logWarn(String tag, String message) { + logMessage(Log.WARN, tag, message); + } + + public static void logWarn(String message) { + logMessage(Log.WARN, DEFAULT_LOG_TAG, message); + } + + public static void logWarnExtended(String tag, String message) { + logExtendedMessage(Log.WARN, tag, message); + } + + public static void logWarnExtended(String message) { + logExtendedMessage(Log.WARN, DEFAULT_LOG_TAG, message); + } + + + + public static void logInfo(String tag, String message) { + logMessage(Log.INFO, tag, message); + } + + public static void logInfo(String message) { + logMessage(Log.INFO, DEFAULT_LOG_TAG, message); + } + + public static void logInfoExtended(String tag, String message) { + logExtendedMessage(Log.INFO, tag, message); + } + + public static void logInfoExtended(String message) { + logExtendedMessage(Log.INFO, DEFAULT_LOG_TAG, message); + } + + + + public static void logDebug(String tag, String message) { + logMessage(Log.DEBUG, tag, message); + } + + public static void logDebug(String message) { + logMessage(Log.DEBUG, DEFAULT_LOG_TAG, message); + } + + public static void logDebugExtended(String tag, String message) { + logExtendedMessage(Log.DEBUG, tag, message); + } + + public static void logDebugExtended(String message) { + logExtendedMessage(Log.DEBUG, DEFAULT_LOG_TAG, message); + } + + + + public static void logVerbose(String tag, String message) { + logMessage(Log.VERBOSE, tag, message); + } + + public static void logVerbose(String message) { + logMessage(Log.VERBOSE, DEFAULT_LOG_TAG, message); + } + + public static void logVerboseExtended(String tag, String message) { + logExtendedMessage(Log.VERBOSE, tag, message); + } + + public static void logVerboseExtended(String message) { + logExtendedMessage(Log.VERBOSE, DEFAULT_LOG_TAG, message); + } + + public static void logVerboseForce(String tag, String message) { + Log.v(tag, message); + } + + + + public static void logInfoAndShowToast(Context context, String tag, String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) { + logInfo(tag, message); + showToast(context, message, true); + } + } + + public static void logInfoAndShowToast(Context context, String message) { + logInfoAndShowToast(context, DEFAULT_LOG_TAG, message); + } + + + + public static void logErrorAndShowToast(Context context, String tag, String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) { + logError(tag, message); + showToast(context, message, true); + } + } + + public static void logErrorAndShowToast(Context context, String message) { + logErrorAndShowToast(context, DEFAULT_LOG_TAG, message); + } + + + + public static void logDebugAndShowToast(Context context, String tag, String message) { + if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) { + logDebug(tag, message); + showToast(context, message, true); + } + } + + public static void logDebugAndShowToast(Context context, String message) { + logDebugAndShowToast(context, DEFAULT_LOG_TAG, message); + } + + + + public static void logStackTraceWithMessage(String tag, String message, Throwable throwable) { + Logger.logErrorExtended(tag, getMessageAndStackTraceString(message, throwable)); + } + + public static void logStackTraceWithMessage(String message, Throwable throwable) { + logStackTraceWithMessage(DEFAULT_LOG_TAG, message, throwable); + } + + public static void logStackTrace(String tag, Throwable throwable) { + logStackTraceWithMessage(tag, null, throwable); + } + + public static void logStackTrace(Throwable throwable) { + logStackTraceWithMessage(DEFAULT_LOG_TAG, null, throwable); + } + + + + public static void logStackTracesWithMessage(String tag, String message, List throwablesList) { + Logger.logErrorExtended(tag, getMessageAndStackTracesString(message, throwablesList)); + } + + + + public static String getMessageAndStackTraceString(String message, Throwable throwable) { + if (message == null && throwable == null) + return null; + else if (message != null && throwable != null) + return message + ":\n" + getStackTraceString(throwable); + else if (throwable == null) + return message; + else + return getStackTraceString(throwable); + } + + public static String getMessageAndStackTracesString(String message, List throwablesList) { + if (message == null && (throwablesList == null || throwablesList.size() == 0)) + return null; + else if (message != null && (throwablesList != null && throwablesList.size() != 0)) + return message + ":\n" + getStackTracesString(null, getStackTracesStringArray(throwablesList)); + else if (throwablesList == null || throwablesList.size() == 0) + return message; + else + return getStackTracesString(null, getStackTracesStringArray(throwablesList)); + } + + + + public static String getStackTraceString(Throwable throwable) { + if (throwable == null) return null; + + String stackTraceString = null; + + try { + StringWriter errors = new StringWriter(); + PrintWriter pw = new PrintWriter(errors); + throwable.printStackTrace(pw); + pw.close(); + stackTraceString = errors.toString(); + errors.close(); + } catch (IOException e) { + e.printStackTrace(); + } + + return stackTraceString; + } + + + + public static String[] getStackTracesStringArray(Throwable throwable) { + return getStackTracesStringArray(Collections.singletonList(throwable)); + } + + public static String[] getStackTracesStringArray(List throwablesList) { + if (throwablesList == null) return null; + final String[] stackTraceStringArray = new String[throwablesList.size()]; + for (int i = 0; i < throwablesList.size(); i++) { + stackTraceStringArray[i] = getStackTraceString(throwablesList.get(i)); + } + return stackTraceStringArray; + } + + + + public static String getStackTracesString(String label, String[] stackTraceStringArray) { + if (label == null) label = "StackTraces:"; + StringBuilder stackTracesString = new StringBuilder(label); + + if (stackTraceStringArray == null || stackTraceStringArray.length == 0) { + stackTracesString.append(" -"); + } else { + for (int i = 0; i != stackTraceStringArray.length; i++) { + if (stackTraceStringArray.length > 1) + stackTracesString.append("\n\nStacktrace ").append(i + 1); + + stackTracesString.append("\n```\n").append(stackTraceStringArray[i]).append("\n```\n"); + } + } + + return stackTracesString.toString(); + } + + public static String getStackTracesMarkdownString(String label, String[] stackTraceStringArray) { + if (label == null) label = "StackTraces"; + StringBuilder stackTracesString = new StringBuilder("### " + label); + + if (stackTraceStringArray == null || stackTraceStringArray.length == 0) { + stackTracesString.append("\n\n`-`"); + } else { + for (int i = 0; i != stackTraceStringArray.length; i++) { + if (stackTraceStringArray.length > 1) + stackTracesString.append("\n\n\n#### Stacktrace ").append(i + 1); + + stackTracesString.append("\n\n```\n").append(stackTraceStringArray[i]).append("\n```"); + } + } + + stackTracesString.append("\n##\n"); + + return stackTracesString.toString(); + } + + public static String getSingleLineLogStringEntry(String label, Object object, String def) { + if (object != null) + return label + ": `" + object + "`"; + else + return label + ": " + def; + } + + public static String getMultiLineLogStringEntry(String label, Object object, String def) { + if (object != null) + return label + ":\n```\n" + object + "\n```\n"; + else + return label + ": " + def; + } + + + + public static void showToast(final Context context, final String toastText, boolean longDuration) { + if (context == null || DataUtils.isNullOrEmpty(toastText)) return; + + new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, toastText, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show()); + } + + + + public static CharSequence[] getLogLevelsArray() { + return new CharSequence[]{ + String.valueOf(LOG_LEVEL_OFF), + String.valueOf(LOG_LEVEL_NORMAL), + String.valueOf(LOG_LEVEL_DEBUG), + String.valueOf(LOG_LEVEL_VERBOSE) + }; + } + + public static CharSequence[] getLogLevelLabelsArray(Context context, CharSequence[] logLevels, boolean addDefaultTag) { + if (logLevels == null) return null; + + CharSequence[] logLevelLabels = new CharSequence[logLevels.length]; + + for(int i=0; i 23 for Nougat (7.0) and prior releases. + * https://developer.android.com/reference/android/util/Log#isLoggable(java.lang.String,%20int) */ + public static void setDefaultLogTag(@NonNull String defaultLogTag) { + DEFAULT_LOG_TAG = defaultLogTag.length() >= 23 ? defaultLogTag.substring(0, 22) : defaultLogTag; + } + + + + public static int getLogLevel() { + return CURRENT_LOG_LEVEL; + } + + public static int setLogLevel(Context context, int logLevel) { + if (isLogLevelValid(logLevel)) + CURRENT_LOG_LEVEL = logLevel; + else + CURRENT_LOG_LEVEL = DEFAULT_LOG_LEVEL; + + if (context != null) + showToast(context, context.getString(R.string.log_level_value, getLogLevelLabel(context, CURRENT_LOG_LEVEL, false)),true); + + return CURRENT_LOG_LEVEL; + } + + /** The colon character ":" must not exist inside the tag, otherwise the `logcat` command + * filterspecs arguments `[:priority]` will not work and will throw `Invalid filter expression` + * error. + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/logging/liblog/logprint.cpp;l=363 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/logging/logcat/logcat.cpp;l=884 + * */ + public static String getFullTag(String tag) { + if (DEFAULT_LOG_TAG.equals(tag)) + return tag; + else + return DEFAULT_LOG_TAG + "." + tag; + } + + public static boolean isLogLevelValid(Integer logLevel) { + return (logLevel != null && logLevel >= LOG_LEVEL_OFF && logLevel <= MAX_LOG_LEVEL); + } + + /** Check if custom log level is valid and >= {@link #CURRENT_LOG_LEVEL}. If custom log level is + * not valid then {@link #LOG_LEVEL_VERBOSE} must be >= {@link #CURRENT_LOG_LEVEL}. */ + public static boolean shouldEnableLoggingForCustomLogLevel(Integer customLogLevel) { + if (CURRENT_LOG_LEVEL <= LOG_LEVEL_OFF) return false; + if (customLogLevel == null) return CURRENT_LOG_LEVEL >= LOG_LEVEL_VERBOSE; // Use default app log level + if (customLogLevel <= LOG_LEVEL_OFF) return false; + customLogLevel = Logger.isLogLevelValid(customLogLevel) ? customLogLevel: Logger.LOG_LEVEL_VERBOSE; + return (customLogLevel >= CURRENT_LOG_LEVEL); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java new file mode 100644 index 000000000..4e1b482ec --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java @@ -0,0 +1,207 @@ +package com.termux.shared.markdown; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.Spanned; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.BulletSpan; +import android.text.style.QuoteSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.text.util.Linkify; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import com.google.common.base.Strings; +import com.termux.shared.R; +import com.termux.shared.theme.ThemeUtils; + +import org.commonmark.ext.gfm.strikethrough.Strikethrough; +import org.commonmark.node.BlockQuote; +import org.commonmark.node.Code; +import org.commonmark.node.Emphasis; +import org.commonmark.node.FencedCodeBlock; +import org.commonmark.node.ListItem; +import org.commonmark.node.StrongEmphasis; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.linkify.LinkifyPlugin; + +public class MarkdownUtils { + + public static final String backtick = "`"; + public static final Pattern backticksPattern = Pattern.compile("(" + backtick + "+)"); + + /** + * Get the markdown code {@link String} for a {@link String}. This ensures all backticks "`" are + * properly escaped so that markdown does not break. + * + * @param string The {@link String} to convert. + * @param codeBlock If the {@link String} is to be converted to a code block or inline code. + * @return Returns the markdown code {@link String}. + */ + public static String getMarkdownCodeForString(String string, boolean codeBlock) { + if (string == null) return null; + if (string.isEmpty()) return ""; + + int maxConsecutiveBackTicksCount = getMaxConsecutiveBackTicksCount(string); + + // markdown requires surrounding backticks count to be at least one more than the count + // of consecutive ticks in the string itself + int backticksCountToUse; + if (codeBlock) + backticksCountToUse = maxConsecutiveBackTicksCount + 3; + else + backticksCountToUse = maxConsecutiveBackTicksCount + 1; + + // create a string with n backticks where n==backticksCountToUse + String backticksToUse = Strings.repeat(backtick, backticksCountToUse); + + if (codeBlock) + return backticksToUse + "\n" + string + "\n" + backticksToUse; + else { + // add a space to any prefixed or suffixed backtick characters + if (string.startsWith(backtick)) + string = " " + string; + if (string.endsWith(backtick)) + string = string + " "; + + return backticksToUse + string + backticksToUse; + } + } + + /** + * Get the max consecutive backticks "`" in a {@link String}. + * + * @param string The {@link String} to check. + * @return Returns the max consecutive backticks count. + */ + public static int getMaxConsecutiveBackTicksCount(String string) { + if (string == null || string.isEmpty()) return 0; + + int maxCount = 0; + int matchCount; + String match; + + Matcher matcher = backticksPattern.matcher(string); + while(matcher.find()) { + match = matcher.group(1); + matchCount = match != null ? match.length() : 0; + if (matchCount > maxCount) + maxCount = matchCount; + } + + return maxCount; + } + + + + public static String getLiteralSingleLineMarkdownStringEntry(String label, Object object, String def) { + return "**" + label + "**: " + (object != null ? object.toString() : def) + " "; + } + + public static String getSingleLineMarkdownStringEntry(String label, Object object, String def) { + if (object != null) + return "**" + label + "**: " + getMarkdownCodeForString(object.toString(), false) + " "; + else + return "**" + label + "**: " + def + " "; + } + + public static String getMultiLineMarkdownStringEntry(String label, Object object, String def) { + if (object != null) + return "**" + label + "**:\n" + getMarkdownCodeForString(object.toString(), true) + "\n"; + else + return "**" + label + "**: " + def + "\n"; + } + + public static String getLinkMarkdownString(String label, String url) { + if (url != null) + return "[" + label.replaceAll("]", "\\\\]") + "](" + url.replaceAll("\\)", "\\\\)") + ")"; + else + return label; + } + + + /** Check following for more info: + * https://github.com/noties/Markwon/tree/v4.6.2/app-sample + * https://noties.io/Markwon/docs/v4/recycler/ + * https://github.com/noties/Markwon/blob/v4.6.2/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeActivity.kt + */ + public static Markwon getRecyclerMarkwonBuilder(Context context) { + return Markwon.builder(context) + .usePlugin(LinkifyPlugin.create(Linkify.EMAIL_ADDRESSES | Linkify.WEB_URLS)) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(FencedCodeBlock.class, (visitor, fencedCodeBlock) -> { + // we actually won't be applying code spans here, as our custom xml view will + // draw background and apply mono typeface + // + // NB the `trim` operation on literal (as code will have a new line at the end) + final CharSequence code = visitor.configuration() + .syntaxHighlight() + .highlight(fencedCodeBlock.getInfo(), fencedCodeBlock.getLiteral().trim()); + visitor.builder().append(code); + }); + } + + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + // Do not change color for night themes + if (!ThemeUtils.isNightModeEnabled(context)) { + builder + // set color for inline code + .setFactory(Code.class, (configuration, props) -> new Object[]{ + new BackgroundColorSpan(ContextCompat.getColor(context, R.color.background_markdown_code_inline)), + }); + } + } + }) + .build(); + } + + /** Check following for more info: + * https://github.com/noties/Markwon/tree/v4.6.2/app-sample + * https://github.com/noties/Markwon/blob/v4.6.2/app-sample/src/main/java/io/noties/markwon/app/samples/notification/NotificationSample.java + */ + public static Markwon getSpannedMarkwonBuilder(Context context) { + return Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder + .setFactory(Emphasis.class, (configuration, props) -> new StyleSpan(Typeface.ITALIC)) + .setFactory(StrongEmphasis.class, (configuration, props) -> new StyleSpan(Typeface.BOLD)) + .setFactory(BlockQuote.class, (configuration, props) -> new QuoteSpan()) + .setFactory(Strikethrough.class, (configuration, props) -> new StrikethroughSpan()) + // NB! notification does not handle background color + .setFactory(Code.class, (configuration, props) -> new Object[]{ + new BackgroundColorSpan(ContextCompat.getColor(context, R.color.background_markdown_code_inline)), + new TypefaceSpan("monospace"), + new AbsoluteSizeSpan(48) + }) + // NB! both ordered and bullet list items + .setFactory(ListItem.class, (configuration, props) -> new BulletSpan()); + } + }) + .build(); + } + + public static Spanned getSpannedMarkdownText(Context context, String string) { + if (context == null || string == null) return null; + final Markwon markwon = getSpannedMarkwonBuilder(context); + return markwon.toMarkdown(string); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java b/termux/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java new file mode 100644 index 000000000..0f70fb562 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java @@ -0,0 +1,98 @@ +package com.termux.shared.models; + +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.android.AndroidUtils; + +import java.io.Serializable; + +/** + * An object that stored info for {@link com.termux.shared.activities.ReportActivity}. + */ +public class ReportInfo implements Serializable { + + /** The user action that was being processed for which the report was generated. */ + public final String userAction; + /** The internal app component that sent the report. */ + public final String sender; + /** The report title. */ + public final String reportTitle; + /** The timestamp for the report. */ + public final String reportTimestamp; + + /** The markdown report text prefix. Will not be part of copy and share operations, etc. */ + public String reportStringPrefix; + /** The markdown report text. */ + public String reportString; + /** The markdown report text suffix. Will not be part of copy and share operations, etc. */ + public String reportStringSuffix; + + /** If set to {@code true}, then report header info will be added to the report when markdown is + * generated. */ + public boolean addReportInfoHeaderToMarkdown = false; + + /** The label for the report file to save if user selects menu_item_save_report_to_file. */ + public String reportSaveFileLabel; + /** The path for the report file to save if user selects menu_item_save_report_to_file. */ + public String reportSaveFilePath; + + public ReportInfo(String userAction, String sender, String reportTitle) { + this.userAction = userAction; + this.sender = sender; + this.reportTitle = reportTitle; + this.reportTimestamp = AndroidUtils.getCurrentMilliSecondUTCTimeStamp(); + } + + public void setReportStringPrefix(String reportStringPrefix) { + this.reportStringPrefix = reportStringPrefix; + } + + public void setReportString(String reportString) { + this.reportString = reportString; + } + + public void setReportStringSuffix(String reportStringSuffix) { + this.reportStringSuffix = reportStringSuffix; + } + + public void setAddReportInfoHeaderToMarkdown(boolean addReportInfoHeaderToMarkdown) { + this.addReportInfoHeaderToMarkdown = addReportInfoHeaderToMarkdown; + } + + public void setReportSaveFileLabelAndPath(String reportSaveFileLabel, String reportSaveFilePath) { + setReportSaveFileLabel(reportSaveFileLabel); + setReportSaveFilePath(reportSaveFilePath); + } + + public void setReportSaveFileLabel(String reportSaveFileLabel) { + this.reportSaveFileLabel = reportSaveFileLabel; + } + + public void setReportSaveFilePath(String reportSaveFilePath) { + this.reportSaveFilePath = reportSaveFilePath; + } + + /** + * Get a markdown {@link String} for {@link ReportInfo}. + * + * @param reportInfo The {@link ReportInfo} to convert. + * @return Returns the markdown {@link String}. + */ + public static String getReportInfoMarkdownString(final ReportInfo reportInfo) { + if (reportInfo == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + if (reportInfo.addReportInfoHeaderToMarkdown) { + markdownString.append("## Report Info\n\n"); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User Action", reportInfo.userAction, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Sender", reportInfo.sender, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Report Timestamp", reportInfo.reportTimestamp, "-")); + markdownString.append("\n##\n\n"); + } + + markdownString.append(reportInfo.reportString); + + return markdownString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java b/termux/termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java new file mode 100644 index 000000000..df9993afc --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java @@ -0,0 +1,234 @@ +package com.termux.shared.models; + +import android.graphics.Color; +import android.graphics.Typeface; + +import androidx.annotation.NonNull; + +import com.termux.shared.activities.TextIOActivity; +import com.termux.shared.data.DataUtils; + +import java.io.Serializable; + +/** + * An object that stored info for {@link TextIOActivity}. + * Max text limit is 95KB to prevent TransactionTooLargeException as per + * {@link DataUtils#TRANSACTION_SIZE_LIMIT_IN_BYTES}. Larger size can be supported for in-app + * transactions by storing {@link TextIOInfo} as a serialized object in a file like + * {@link com.termux.shared.activities.ReportActivity} does. + */ +public class TextIOInfo implements Serializable { + + public static final int GENERAL_DATA_SIZE_LIMIT_IN_BYTES = 1000; + public static final int LABEL_SIZE_LIMIT_IN_BYTES = 4000; + public static final int TEXT_SIZE_LIMIT_IN_BYTES = 100000 - GENERAL_DATA_SIZE_LIMIT_IN_BYTES - LABEL_SIZE_LIMIT_IN_BYTES; // < 100KB + + /** The action for which {@link TextIOActivity} will be started. */ + private final String mAction; + /** The internal app component that is will start the {@link TextIOActivity}. */ + private final String mSender; + + /** The activity title. */ + private String mTitle; + + /** If back button should be shown in {@link android.app.ActionBar}. */ + private boolean mShowBackButtonInActionBar = false; + + + /** If label is enabled. */ + private boolean mLabelEnabled = false; + /** + * The label of text input set in {@link android.widget.TextView} that can be updated by user. + * Max allowed length is {@link #LABEL_SIZE_LIMIT_IN_BYTES}. + */ + private String mLabel; + /** The text size of label. Defaults to 14sp. */ + private int mLabelSize = 14; + /** The text color of label. Defaults to {@link Color#BLACK}. */ + private int mLabelColor = Color.BLACK; + /** The {@link Typeface} family of label. Defaults to "sans-serif". */ + private String mLabelTypeFaceFamily = "sans-serif"; + /** The {@link Typeface} style of label. Defaults to {@link Typeface#BOLD}. */ + private int mLabelTypeFaceStyle = Typeface.BOLD; + + + /** + * The text of text input set in {@link android.widget.EditText} that can be updated by user. + * Max allowed length is {@link #TEXT_SIZE_LIMIT_IN_BYTES}. + */ + private String mText; + /** The text size for text. Defaults to 12sp. */ + private int mTextSize = 12; + /** The text size for text. Defaults to {@link #TEXT_SIZE_LIMIT_IN_BYTES}. */ + private int mTextLengthLimit = TEXT_SIZE_LIMIT_IN_BYTES; + /** The text color of text. Defaults to {@link Color#BLACK}. */ + private int mTextColor = Color.BLACK; + /** The {@link Typeface} family for text. Defaults to "sans-serif". */ + private String mTextTypeFaceFamily = "sans-serif"; + /** The {@link Typeface} style for text. Defaults to {@link Typeface#NORMAL}. */ + private int mTextTypeFaceStyle = Typeface.NORMAL; + /** If horizontal scrolling should be enabled for text. */ + private boolean mTextHorizontallyScrolling = false; + /** If character usage should be enabled for text. */ + private boolean mShowTextCharacterUsage = false; + /** If editing text should be disabled so that text acts like its in a {@link android.widget.TextView}. */ + private boolean mEditingTextDisabled = false; + + + public TextIOInfo(@NonNull String action, @NonNull String sender) { + mAction = action; + mSender = sender; + } + + + public String getAction() { + return mAction; + } + + public String getSender() { + return mSender; + } + + + public String getTitle() { + return mTitle; + } + + public void setTitle(String title) { + mTitle = title; + } + + public boolean shouldShowBackButtonInActionBar() { + return mShowBackButtonInActionBar; + } + + public void setShowBackButtonInActionBar(boolean showBackButtonInActionBar) { + mShowBackButtonInActionBar = showBackButtonInActionBar; + } + + + public boolean isLabelEnabled() { + return mLabelEnabled; + } + + public void setLabelEnabled(boolean labelEnabled) { + mLabelEnabled = labelEnabled; + } + + public String getLabel() { + return mLabel; + } + + public void setLabel(String label) { + mLabel = DataUtils.getTruncatedCommandOutput(label, LABEL_SIZE_LIMIT_IN_BYTES, true, false, false); + } + + public int getLabelSize() { + return mLabelSize; + } + + public void setLabelSize(int labelSize) { + if (labelSize > 0) + mLabelSize = labelSize; + } + + public int getLabelColor() { + return mLabelColor; + } + + public void setLabelColor(int labelColor) { + mLabelColor = labelColor; + } + + public String getLabelTypeFaceFamily() { + return mLabelTypeFaceFamily; + } + + public void setLabelTypeFaceFamily(String labelTypeFaceFamily) { + mLabelTypeFaceFamily = labelTypeFaceFamily; + } + + public int getLabelTypeFaceStyle() { + return mLabelTypeFaceStyle; + } + + public void setLabelTypeFaceStyle(int labelTypeFaceStyle) { + mLabelTypeFaceStyle = labelTypeFaceStyle; + } + + + public String getText() { + return mText; + } + + public void setText(String text) { + mText = DataUtils.getTruncatedCommandOutput(text, TEXT_SIZE_LIMIT_IN_BYTES, true, false, false); + } + + public int getTextSize() { + return mTextSize; + } + + public void setTextSize(int textSize) { + if (textSize > 0) + mTextSize = textSize; + } + + public int getTextLengthLimit() { + return mTextLengthLimit; + } + + public void setTextLengthLimit(int textLengthLimit) { + if (textLengthLimit < TEXT_SIZE_LIMIT_IN_BYTES) + mTextLengthLimit = textLengthLimit; + } + + public int getTextColor() { + return mTextColor; + } + + public void setTextColor(int textColor) { + mTextColor = textColor; + } + + public String getTextTypeFaceFamily() { + return mTextTypeFaceFamily; + } + + public void setTextTypeFaceFamily(String textTypeFaceFamily) { + mTextTypeFaceFamily = textTypeFaceFamily; + } + + public int getTextTypeFaceStyle() { + return mTextTypeFaceStyle; + } + + public void setTextTypeFaceStyle(int textTypeFaceStyle) { + mTextTypeFaceStyle = textTypeFaceStyle; + } + + public boolean isHorizontallyScrollable() { + return mTextHorizontallyScrolling; + } + + public void setTextHorizontallyScrolling(boolean textHorizontallyScrolling) { + mTextHorizontallyScrolling = textHorizontallyScrolling; + } + + public boolean shouldShowTextCharacterUsage() { + return mShowTextCharacterUsage; + } + + public void setShowTextCharacterUsage(boolean showTextCharacterUsage) { + mShowTextCharacterUsage = showTextCharacterUsage; + } + + public boolean isEditingTextDisabled() { + return mEditingTextDisabled; + } + + public void setEditingTextDisabled(boolean editingTextDisabled) { + mEditingTextDisabled = editingTextDisabled; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java new file mode 100644 index 000000000..a520254ed --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java @@ -0,0 +1,72 @@ +package com.termux.shared.net.socket.local; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.errors.Error; + +/** + * The interface for the {@link LocalSocketManager} for callbacks to manager client/server starter. + */ +public interface ILocalSocketManager { + + /** + * This should return the {@link Thread.UncaughtExceptionHandler} that should be used for the + * client socket listener and client logic runner threads started for other interface methods. + * + * @param localSocketManager The {@link LocalSocketManager} for the server. + * @return Should return {@link Thread.UncaughtExceptionHandler} or {@code null}, if default + * handler should be used which just logs the exception. + */ + @Nullable + Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH( + @NonNull LocalSocketManager localSocketManager); + + /** + * This is called if any error is raised by {@link LocalSocketManager}, {@link LocalServerSocket} + * or {@link LocalClientSocket}. The server will automatically close the client socket + * with a call to {@link LocalClientSocket#closeClientSocket(boolean)} if the error occurred due + * to the client. + * + * The {@link LocalClientSocket#getPeerCred()} can be used to get the {@link PeerCred} object + * containing info for the connected client/peer. + * + * @param localSocketManager The {@link LocalSocketManager} for the server. + * @param clientSocket The {@link LocalClientSocket} that connected. This will be {@code null} + * if error is not for a {@link LocalClientSocket}. + * @param error The {@link Error} auto generated that can be used for logging purposes. + */ + void onError(@NonNull LocalSocketManager localSocketManager, + @Nullable LocalClientSocket clientSocket, @NonNull Error error); + + /** + * This is called if a {@link LocalServerSocket} connects to the server which **does not** have + * the server app's user id or root user id. The server will automatically close the client socket + * with a call to {@link LocalClientSocket#closeClientSocket(boolean)}. + * + * The {@link LocalClientSocket#getPeerCred()} can be used to get the {@link PeerCred} object + * containing info for the connected client/peer. + * + * @param localSocketManager The {@link LocalSocketManager} for the server. + * @param clientSocket The {@link LocalClientSocket} that connected. + * @param error The {@link Error} auto generated that can be used for logging purposes. + */ + void onDisallowedClientConnected(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket, @NonNull Error error); + + /** + * This is called if a {@link LocalServerSocket} connects to the server which has the + * the server app's user id or root user id. It is the responsibility of the interface + * implementation to close the client socket with a call to + * {@link LocalClientSocket#closeClientSocket(boolean)} once its done processing. + * + * The {@link LocalClientSocket#getPeerCred()} can be used to get the {@link PeerCred} object + * containing info for the connected client/peer. + * + * @param localSocketManager The {@link LocalSocketManager} for the server. + * @param clientSocket The {@link LocalClientSocket} that connected. + */ + void onClientAccepted(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java new file mode 100644 index 000000000..75a7e6a8c --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java @@ -0,0 +1,483 @@ +package com.termux.shared.net.socket.local; + +import androidx.annotation.NonNull; + +import com.termux.shared.data.DataUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.jni.models.JniResult; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; + +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; + +/** The client socket for {@link LocalSocketManager}. */ +public class LocalClientSocket implements Closeable { + + public static final String LOG_TAG = "LocalClientSocket"; + + /** The {@link LocalSocketManager} instance for the local socket. */ + @NonNull protected final LocalSocketManager mLocalSocketManager; + + /** The {@link LocalSocketRunConfig} containing run config for the {@link LocalClientSocket}. */ + @NonNull protected final LocalSocketRunConfig mLocalSocketRunConfig; + + /** + * The {@link LocalClientSocket} file descriptor. + * Value will be `>= 0` if socket has been connected and `-1` if closed. + */ + protected int mFD; + + /** The creation time of {@link LocalClientSocket}. This is also used for deadline. */ + protected final long mCreationTime; + + /** The {@link PeerCred} of the {@link LocalClientSocket} containing info of client/peer. */ + @NonNull protected final PeerCred mPeerCred; + + /** The {@link OutputStream} implementation for the {@link LocalClientSocket}. */ + @NonNull protected final SocketOutputStream mOutputStream; + + /** The {@link InputStream} implementation for the {@link LocalClientSocket}. */ + @NonNull protected final SocketInputStream mInputStream; + + /** + * Create an new instance of {@link LocalClientSocket}. + * + * @param localSocketManager The {@link #mLocalSocketManager} value. + * @param fd The {@link #mFD} value. + * @param peerCred The {@link #mPeerCred} value. + */ + LocalClientSocket(@NonNull LocalSocketManager localSocketManager, int fd, @NonNull PeerCred peerCred) { + mLocalSocketManager = localSocketManager; + mLocalSocketRunConfig = localSocketManager.getLocalSocketRunConfig(); + mCreationTime = System.currentTimeMillis(); + mOutputStream = new SocketOutputStream(); + mInputStream = new SocketInputStream(); + mPeerCred = peerCred; + + setFD(fd); + mPeerCred.fillPeerCred(localSocketManager.getContext()); + } + + + /** Close client socket. */ + public synchronized Error closeClientSocket(boolean logErrorMessage) { + try { + close(); + } catch (IOException e) { + Error error = LocalSocketErrno.ERRNO_CLOSE_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError(e, mLocalSocketRunConfig.getTitle(), e.getMessage()); + if (logErrorMessage) + Logger.logErrorExtended(LOG_TAG, error.getErrorLogString()); + return error; + } + + return null; + } + + /** Close client socket that exists at fd. */ + public static void closeClientSocket(@NonNull LocalSocketManager localSocketManager, int fd) { + new LocalClientSocket(localSocketManager, fd, new PeerCred()).closeClientSocket(true); + } + + /** Implementation for {@link Closeable#close()} to close client socket. */ + @Override + public void close() throws IOException { + if (mFD >= 0) { + Logger.logVerbose(LOG_TAG, "Client socket close for \"" + mLocalSocketRunConfig.getTitle() + "\" server: " + getPeerCred().getMinimalString()); + JniResult result = LocalSocketManager.closeSocket(mLocalSocketRunConfig.getLogTitle() + " (client)", mFD); + if (result == null || result.retval != 0) { + throw new IOException(JniResult.getErrorString(result)); + } + // Update fd to signify that client socket has been closed + setFD(-1); + } + } + + + /** + * Attempts to read up to data buffer length bytes from file descriptor into the data buffer. + * On success, the number of bytes read is returned (zero indicates end of file) in bytesRead. + * It is not an error if bytesRead is smaller than the number of bytes requested; this may happen + * for example because fewer bytes are actually available right now (maybe because we were close + * to end-of-file, or because we are reading from a pipe), or because read() was interrupted by + * a signal. + * + * If while reading the {@link #mCreationTime} + the milliseconds returned by + * {@link LocalSocketRunConfig#getDeadline()} elapses but all the data has not been read, an + * error would be returned. + * + * This is a wrapper for {@link LocalSocketManager#read(String, int, byte[], long)}, which can + * be called instead if you want to get access to errno int value instead of {@link JniResult} + * error {@link String}. + * + * @param data The data buffer to read bytes into. + * @param bytesRead The actual bytes read. + * @return Returns the {@code error} if reading was not successful containing {@link JniResult} + * error {@link String}, otherwise {@code null}. + */ + public Error read(@NonNull byte[] data, MutableInt bytesRead) { + bytesRead.value = 0; + + if (mFD < 0) { + return LocalSocketErrno.ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD.getError(mFD, + mLocalSocketRunConfig.getTitle()); + } + + JniResult result = LocalSocketManager.read(mLocalSocketRunConfig.getLogTitle() + " (client)", + mFD, data, + mLocalSocketRunConfig.getDeadline() > 0 ? mCreationTime + mLocalSocketRunConfig.getDeadline() : 0); + if (result == null || result.retval != 0) { + return LocalSocketErrno.ERRNO_READ_DATA_FROM_CLIENT_SOCKET_FAILED.getError( + mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result)); + } + + bytesRead.value = result.intData; + return null; + } + + /** + * Attempts to send data buffer to the file descriptor. + * + * If while sending the {@link #mCreationTime} + the milliseconds returned by + * {@link LocalSocketRunConfig#getDeadline()} elapses but all the data has not been sent, an + * error would be returned. + * + * This is a wrapper for {@link LocalSocketManager#send(String, int, byte[], long)}, which can + * be called instead if you want to get access to errno int value instead of {@link JniResult} + * error {@link String}. + * + * @param data The data buffer containing bytes to send. + * @return Returns the {@code error} if sending was not successful containing {@link JniResult} + * error {@link String}, otherwise {@code null}. + */ + public Error send(@NonNull byte[] data) { + if (mFD < 0) { + return LocalSocketErrno.ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD.getError(mFD, + mLocalSocketRunConfig.getTitle()); + } + + JniResult result = LocalSocketManager.send(mLocalSocketRunConfig.getLogTitle() + " (client)", + mFD, data, + mLocalSocketRunConfig.getDeadline() > 0 ? mCreationTime + mLocalSocketRunConfig.getDeadline() : 0); + if (result == null || result.retval != 0) { + return LocalSocketErrno.ERRNO_SEND_DATA_TO_CLIENT_SOCKET_FAILED.getError( + mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result)); + } + + return null; + } + + /** + * Attempts to read all the bytes available on {@link SocketInputStream} and appends them to + * {@code data} {@link StringBuilder}. + * + * This is a wrapper for {@link #read(byte[], MutableInt)} called via {@link SocketInputStream#read()}. + * + * @param data The data {@link StringBuilder} to append the bytes read into. + * @param closeStreamOnFinish If set to {@code true}, then underlying input stream will closed + * and further attempts to read from socket will fail. + * @return Returns the {@code error} if reading was not successful containing {@link JniResult} + * error {@link String}, otherwise {@code null}. + */ + public Error readDataOnInputStream(@NonNull StringBuilder data, boolean closeStreamOnFinish) { + int c; + InputStreamReader inputStreamReader = getInputStreamReader(); + try { + while ((c = inputStreamReader.read()) > 0) { + data.append((char) c); + } + } catch (IOException e) { + // The SocketInputStream.read() throws the Error message in an IOException, + // so just read the exception message and not the stack trace, otherwise it would result + // in a messy nested error message. + return LocalSocketErrno.ERRNO_READ_DATA_FROM_INPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError( + mLocalSocketRunConfig.getTitle(), DataUtils.getSpaceIndentedString(e.getMessage(), 1)); + } catch (Exception e) { + return LocalSocketErrno.ERRNO_READ_DATA_FROM_INPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError( + e, mLocalSocketRunConfig.getTitle(), e.getMessage()); + } finally { + if (closeStreamOnFinish) { + try { inputStreamReader.close(); + } catch (IOException e) { + // Ignore + } + } + } + + return null; + } + + /** + * Attempts to send all the bytes passed to {@link SocketOutputStream} . + * + * This is a wrapper for {@link #send(byte[])} called via {@link SocketOutputStream#write(int)}. + * + * @param data The {@link String} bytes to send. + * @param closeStreamOnFinish If set to {@code true}, then underlying output stream will closed + * and further attempts to send to socket will fail. + * @return Returns the {@code error} if sending was not successful containing {@link JniResult} + * error {@link String}, otherwise {@code null}. + */ + public Error sendDataToOutputStream(@NonNull String data, boolean closeStreamOnFinish) { + + OutputStreamWriter outputStreamWriter = getOutputStreamWriter(); + + try (BufferedWriter byteStreamWriter = new BufferedWriter(outputStreamWriter)) { + byteStreamWriter.write(data); + byteStreamWriter.flush(); + } catch (IOException e) { + // The SocketOutputStream.write() throws the Error message in an IOException, + // so just read the exception message and not the stack trace, otherwise it would result + // in a messy nested error message. + return LocalSocketErrno.ERRNO_SEND_DATA_TO_OUTPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError( + mLocalSocketRunConfig.getTitle(), DataUtils.getSpaceIndentedString(e.getMessage(), 1)); + } catch (Exception e) { + return LocalSocketErrno.ERRNO_SEND_DATA_TO_OUTPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError( + e, mLocalSocketRunConfig.getTitle(), e.getMessage()); + } finally { + if (closeStreamOnFinish) { + try { + outputStreamWriter.close(); + } catch (IOException e) { + // Ignore + } + } + } + + return null; + } + + /** Wrapper for {@link #available(MutableInt, boolean)} that checks deadline. The + * {@link SocketInputStream} calls this. */ + public Error available(MutableInt available) { + return available(available, true); + } + + /** + * Get available bytes on {@link #mInputStream} and optionally check if value returned by + * {@link LocalSocketRunConfig#getDeadline()} has passed. + */ + public Error available(MutableInt available, boolean checkDeadline) { + available.value = 0; + + if (mFD < 0) { + return LocalSocketErrno.ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD.getError(mFD, + mLocalSocketRunConfig.getTitle()); + } + + if (checkDeadline && mLocalSocketRunConfig.getDeadline() > 0 && System.currentTimeMillis() > (mCreationTime + mLocalSocketRunConfig.getDeadline())) { + return null; + } + + JniResult result = LocalSocketManager.available(mLocalSocketRunConfig.getLogTitle() + " (client)", mLocalSocketRunConfig.getFD()); + if (result == null || result.retval != 0) { + return LocalSocketErrno.ERRNO_CHECK_AVAILABLE_DATA_ON_CLIENT_SOCKET_FAILED.getError( + mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result)); + } + + available.value = result.intData; + return null; + } + + + + /** Set {@link LocalClientSocket} receiving (SO_RCVTIMEO) timeout to value returned by {@link LocalSocketRunConfig#getReceiveTimeout()}. */ + public Error setReadTimeout() { + if (mFD >= 0) { + JniResult result = LocalSocketManager.setSocketReadTimeout(mLocalSocketRunConfig.getLogTitle() + " (client)", + mFD, mLocalSocketRunConfig.getReceiveTimeout()); + if (result == null || result.retval != 0) { + return LocalSocketErrno.ERRNO_SET_CLIENT_SOCKET_READ_TIMEOUT_FAILED.getError( + mLocalSocketRunConfig.getTitle(), mLocalSocketRunConfig.getReceiveTimeout(), JniResult.getErrorString(result)); + } + } + return null; + } + + /** Set {@link LocalClientSocket} sending (SO_SNDTIMEO) timeout to value returned by {@link LocalSocketRunConfig#getSendTimeout()}. */ + public Error setWriteTimeout() { + if (mFD >= 0) { + JniResult result = LocalSocketManager.setSocketSendTimeout(mLocalSocketRunConfig.getLogTitle() + " (client)", + mFD, mLocalSocketRunConfig.getSendTimeout()); + if (result == null || result.retval != 0) { + return LocalSocketErrno.ERRNO_SET_CLIENT_SOCKET_SEND_TIMEOUT_FAILED.getError( + mLocalSocketRunConfig.getTitle(), mLocalSocketRunConfig.getSendTimeout(), JniResult.getErrorString(result)); + } + } + return null; + } + + + + /** Get {@link #mFD} for the client socket. */ + public int getFD() { + return mFD; + } + + /** Set {@link #mFD}. Value must be greater than 0 or -1. */ + private void setFD(int fd) { + if (fd >= 0) + mFD = fd; + else + mFD = -1; + } + + /** Get {@link #mPeerCred} for the client socket. */ + public PeerCred getPeerCred() { + return mPeerCred; + } + + /** Get {@link #mCreationTime} for the client socket. */ + public long getCreationTime() { + return mCreationTime; + } + + /** Get {@link #mOutputStream} for the client socket. The stream will automatically close when client socket is closed. */ + public OutputStream getOutputStream() { + return mOutputStream; + } + + /** Get {@link OutputStreamWriter} for {@link #mOutputStream} for the client socket. The stream will automatically close when client socket is closed. */ + @NonNull + public OutputStreamWriter getOutputStreamWriter() { + return new OutputStreamWriter(getOutputStream()); + } + + /** Get {@link #mInputStream} for the client socket. The stream will automatically close when client socket is closed. */ + public InputStream getInputStream() { + return mInputStream; + } + + /** Get {@link InputStreamReader} for {@link #mInputStream} for the client socket. The stream will automatically close when client socket is closed. */ + @NonNull + public InputStreamReader getInputStreamReader() { + return new InputStreamReader(getInputStream()); + } + + + + /** Get a log {@link String} for the {@link LocalClientSocket}. */ + @NonNull + public String getLogString() { + StringBuilder logString = new StringBuilder(); + + logString.append("Client Socket:"); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("FD", mFD, "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Creation Time", mCreationTime, "-")); + logString.append("\n\n\n"); + + logString.append(mPeerCred.getLogString()); + + return logString.toString(); + } + + /** Get a markdown {@link String} for the {@link LocalClientSocket}. */ + @NonNull + public String getMarkdownString() { + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## ").append("Client Socket"); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("FD", mFD, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Creation Time", mCreationTime, "-")); + markdownString.append("\n\n\n"); + + markdownString.append(mPeerCred.getMarkdownString()); + + return markdownString.toString(); + } + + + + + + /** Wrapper class to allow pass by reference of int values. */ + public static final class MutableInt { + public int value; + + public MutableInt(int value) { + this.value = value; + } + } + + + + /** The {@link InputStream} implementation for the {@link LocalClientSocket}. */ + protected class SocketInputStream extends InputStream { + private final byte[] mBytes = new byte[1]; + + @Override + public int read() throws IOException { + MutableInt bytesRead = new MutableInt(0); + Error error = LocalClientSocket.this.read(mBytes, bytesRead); + if (error != null) { + throw new IOException(error.getErrorMarkdownString()); + } + + if (bytesRead.value == 0) { + return -1; + } + + return mBytes[0]; + } + + @Override + public int read(byte[] bytes) throws IOException { + if (bytes == null) { + throw new NullPointerException("Read buffer can't be null"); + } + + MutableInt bytesRead = new MutableInt(0); + Error error = LocalClientSocket.this.read(bytes, bytesRead); + if (error != null) { + throw new IOException(error.getErrorMarkdownString()); + } + + if (bytesRead.value == 0) { + return -1; + } + + return bytesRead.value; + } + + @Override + public int available() throws IOException { + MutableInt available = new MutableInt(0); + Error error = LocalClientSocket.this.available(available); + if (error != null) { + throw new IOException(error.getErrorMarkdownString()); + } + return available.value; + } + } + + + + /** The {@link OutputStream} implementation for the {@link LocalClientSocket}. */ + protected class SocketOutputStream extends OutputStream { + private final byte[] mBytes = new byte[1]; + + @Override + public void write(int b) throws IOException { + mBytes[0] = (byte) b; + + Error error = LocalClientSocket.this.send(mBytes); + if (error != null) { + throw new IOException(error.getErrorMarkdownString()); + } + } + + @Override + public void write(byte[] bytes) throws IOException { + Error error = LocalClientSocket.this.send(bytes); + if (error != null) { + throw new IOException(error.getErrorMarkdownString()); + } + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java new file mode 100644 index 000000000..385c20346 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java @@ -0,0 +1,303 @@ +package com.termux.shared.net.socket.local; + +import androidx.annotation.NonNull; + +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.jni.models.JniResult; +import com.termux.shared.logger.Logger; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** The server socket for {@link LocalSocketManager}. */ +public class LocalServerSocket implements Closeable { + + public static final String LOG_TAG = "LocalServerSocket"; + + /** The {@link LocalSocketManager} instance for the local socket. */ + @NonNull protected final LocalSocketManager mLocalSocketManager; + + /** The {@link LocalSocketRunConfig} containing run config for the {@link LocalServerSocket}. */ + @NonNull protected final LocalSocketRunConfig mLocalSocketRunConfig; + + /** The {@link ILocalSocketManager} client for the {@link LocalSocketManager}. */ + @NonNull protected final ILocalSocketManager mLocalSocketManagerClient; + + /** The {@link ClientSocketListener} {@link Thread} for the {@link LocalServerSocket}. */ + @NonNull protected final Thread mClientSocketListener; + + /** + * The required permissions for server socket file parent directory. + * Creation of a new socket will fail if the server starter app process does not have + * write and search (execute) permission on the directory in which the socket is created. + */ + public static final String SERVER_SOCKET_PARENT_DIRECTORY_PERMISSIONS = "rwx"; // Default: "rwx" + + /** + * Create an new instance of {@link LocalServerSocket}. + * + * @param localSocketManager The {@link #mLocalSocketManager} value. + */ + protected LocalServerSocket(@NonNull LocalSocketManager localSocketManager) { + mLocalSocketManager = localSocketManager; + mLocalSocketRunConfig = localSocketManager.getLocalSocketRunConfig(); + mLocalSocketManagerClient = mLocalSocketRunConfig.getLocalSocketManagerClient(); + mClientSocketListener = new Thread(new ClientSocketListener()); + } + + /** Start server by creating server socket. */ + public synchronized Error start() { + Logger.logDebug(LOG_TAG, "start"); + + String path = mLocalSocketRunConfig.getPath(); + if (path == null || path.isEmpty()) { + return LocalSocketErrno.ERRNO_SERVER_SOCKET_PATH_NULL_OR_EMPTY.getError(mLocalSocketRunConfig.getTitle()); + } + if (!mLocalSocketRunConfig.isAbstractNamespaceSocket()) { + path = FileUtils.getCanonicalPath(path, null); + } + + // On Linux, sun_path is 108 bytes (UNIX_PATH_MAX) in size, so do an early check here to + // prevent useless parent directory creation since createServerSocket() call will fail since + // there is a native check as well. + if (path.getBytes(StandardCharsets.UTF_8).length > 108) { + return LocalSocketErrno.ERRNO_SERVER_SOCKET_PATH_TOO_LONG.getError(mLocalSocketRunConfig.getTitle(), path); + } + + int backlog = mLocalSocketRunConfig.getBacklog(); + if (backlog <= 0) { + return LocalSocketErrno.ERRNO_SERVER_SOCKET_BACKLOG_INVALID.getError(mLocalSocketRunConfig.getTitle(), backlog); + } + + Error error; + + // If server socket is not in abstract namespace + if (!mLocalSocketRunConfig.isAbstractNamespaceSocket()) { + if (!path.startsWith("/")) + return LocalSocketErrno.ERRNO_SERVER_SOCKET_PATH_NOT_ABSOLUTE.getError(mLocalSocketRunConfig.getTitle(), path); + + // Create the server socket file parent directory and set SERVER_SOCKET_PARENT_DIRECTORY_PERMISSIONS if missing + String socketParentPath = new File(path).getParent(); + error = FileUtils.validateDirectoryFileExistenceAndPermissions(mLocalSocketRunConfig.getTitle() + " server socket file parent", + socketParentPath, + null, true, + SERVER_SOCKET_PARENT_DIRECTORY_PERMISSIONS, true, true, + false, false); + if (error != null) + return error; + + + // Delete the server socket file to stop any existing servers and for bind() to succeed + error = deleteServerSocketFile(); + if (error != null) + return error; + } + + // Create the server socket + JniResult result = LocalSocketManager.createServerSocket(mLocalSocketRunConfig.getLogTitle() + " (server)", + path.getBytes(StandardCharsets.UTF_8), backlog); + if (result == null || result.retval != 0) { + return LocalSocketErrno.ERRNO_CREATE_SERVER_SOCKET_FAILED.getError(mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result)); + } + + int fd = result.intData; + if (fd < 0) { + return LocalSocketErrno.ERRNO_SERVER_SOCKET_FD_INVALID.getError(fd, mLocalSocketRunConfig.getTitle()); + } + + // Update fd to signify that server socket has been created successfully + mLocalSocketRunConfig.setFD(fd); + + mClientSocketListener.setUncaughtExceptionHandler(mLocalSocketManager.getLocalSocketManagerClientThreadUEH()); + + try { + // Start listening to server clients + mClientSocketListener.start(); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "mClientSocketListener start failed", e); + } + + return null; + } + + /** Stop server. */ + public synchronized Error stop() { + Logger.logDebug(LOG_TAG, "stop"); + + try { + // Stop the LocalClientSocket listener. + mClientSocketListener.interrupt(); + } catch (Exception ignored) {} + + Error error = closeServerSocket(false); + if (error != null) + return error; + + return deleteServerSocketFile(); + } + + /** Close server socket. */ + public synchronized Error closeServerSocket(boolean logErrorMessage) { + Logger.logDebug(LOG_TAG, "closeServerSocket"); + + try { + close(); + } catch (IOException e) { + Error error = LocalSocketErrno.ERRNO_CLOSE_SERVER_SOCKET_FAILED_WITH_EXCEPTION.getError(e, mLocalSocketRunConfig.getTitle(), e.getMessage()); + if (logErrorMessage) + Logger.logErrorExtended(LOG_TAG, error.getErrorLogString()); + return error; + } + + return null; + } + + /** Implementation for {@link Closeable#close()} to close server socket. */ + @Override + public synchronized void close() throws IOException { + Logger.logDebug(LOG_TAG, "close"); + + int fd = mLocalSocketRunConfig.getFD(); + + if (fd >= 0) { + JniResult result = LocalSocketManager.closeSocket(mLocalSocketRunConfig.getLogTitle() + " (server)", fd); + if (result == null || result.retval != 0) { + throw new IOException(JniResult.getErrorString(result)); + } + // Update fd to signify that server socket has been closed + mLocalSocketRunConfig.setFD(-1); + } + } + + /** + * Delete server socket file if not an abstract namespace socket. This will cause any existing + * running server to stop. + */ + private Error deleteServerSocketFile() { + if (!mLocalSocketRunConfig.isAbstractNamespaceSocket()) + return FileUtils.deleteSocketFile(mLocalSocketRunConfig.getTitle() + " server socket file", mLocalSocketRunConfig.getPath(), true); + else + return null; + } + + /** Listen and accept new {@link LocalClientSocket}. */ + public LocalClientSocket accept() { + Logger.logVerbose(LOG_TAG, "accept"); + + int clientFD; + while (true) { + // If server socket closed + int fd = mLocalSocketRunConfig.getFD(); + if (fd < 0) { + return null; + } + + JniResult result = LocalSocketManager.accept(mLocalSocketRunConfig.getLogTitle() + " (client)", fd); + if (result == null || result.retval != 0) { + mLocalSocketManager.onError( + LocalSocketErrno.ERRNO_ACCEPT_CLIENT_SOCKET_FAILED.getError(mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result))); + continue; + } + + clientFD = result.intData; + if (clientFD < 0) { + mLocalSocketManager.onError( + LocalSocketErrno.ERRNO_CLIENT_SOCKET_FD_INVALID.getError(clientFD, mLocalSocketRunConfig.getTitle())); + continue; + } + + PeerCred peerCred = new PeerCred(); + result = LocalSocketManager.getPeerCred(mLocalSocketRunConfig.getLogTitle() + " (client)", clientFD, peerCred); + if (result == null || result.retval != 0) { + mLocalSocketManager.onError( + LocalSocketErrno.ERRNO_GET_CLIENT_SOCKET_PEER_UID_FAILED.getError(mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result))); + LocalClientSocket.closeClientSocket(mLocalSocketManager, clientFD); + continue; + } + + int peerUid = peerCred.uid; + if (peerUid < 0) { + mLocalSocketManager.onError( + LocalSocketErrno.ERRNO_CLIENT_SOCKET_PEER_UID_INVALID.getError(peerUid, mLocalSocketRunConfig.getTitle())); + LocalClientSocket.closeClientSocket(mLocalSocketManager, clientFD); + continue; + } + + LocalClientSocket clientSocket = new LocalClientSocket(mLocalSocketManager, clientFD, peerCred); + Logger.logVerbose(LOG_TAG, "Client socket accept for \"" + mLocalSocketRunConfig.getTitle() + "\" server\n" + clientSocket.getLogString()); + + // Only allow connection if the peer has the same uid as server app's user id or root user id + if (peerUid != mLocalSocketManager.getContext().getApplicationInfo().uid && peerUid != 0) { + mLocalSocketManager.onDisallowedClientConnected(clientSocket, + LocalSocketErrno.ERRNO_CLIENT_SOCKET_PEER_UID_DISALLOWED.getError(clientSocket.getPeerCred().getMinimalString(), + mLocalSocketManager.getLocalSocketRunConfig().getTitle())); + clientSocket.closeClientSocket(true); + continue; + } + + return clientSocket; + } + } + + + + + /** The {@link LocalClientSocket} listener {@link java.lang.Runnable} for {@link LocalServerSocket}. */ + protected class ClientSocketListener implements Runnable { + + @Override + public void run() { + try { + Logger.logVerbose(LOG_TAG, "ClientSocketListener start"); + + while (!Thread.currentThread().isInterrupted()) { + LocalClientSocket clientSocket = null; + try { + // Listen for new client socket connections + clientSocket = null; + clientSocket = accept(); + // If server socket is closed, then stop listener thread. + if (clientSocket == null) + break; + + Error error; + + error = clientSocket.setReadTimeout(); + if (error != null) { + mLocalSocketManager.onError(clientSocket, error); + clientSocket.closeClientSocket(true); + continue; + } + + error = clientSocket.setWriteTimeout(); + if (error != null) { + mLocalSocketManager.onError(clientSocket, error); + clientSocket.closeClientSocket(true); + continue; + } + + // Start new thread for client logic and pass control to ILocalSocketManager implementation + mLocalSocketManager.onClientAccepted(clientSocket); + } catch (Throwable t) { + mLocalSocketManager.onError(clientSocket, + LocalSocketErrno.ERRNO_CLIENT_SOCKET_LISTENER_FAILED_WITH_EXCEPTION.getError(t, mLocalSocketRunConfig.getTitle(), t.getMessage())); + if (clientSocket != null) + clientSocket.closeClientSocket(true); + } + } + } catch (Exception ignored) { + } finally { + try { + close(); + } catch (Exception ignored) {} + } + + Logger.logVerbose(LOG_TAG, "ClientSocketListener end"); + } + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java new file mode 100644 index 000000000..251f5c67e --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java @@ -0,0 +1,43 @@ +package com.termux.shared.net.socket.local; + +import com.termux.shared.errors.Errno; + +public class LocalSocketErrno extends Errno { + + public static final String TYPE = "LocalSocket Error"; + + + /** Errors for {@link LocalSocketManager} (100-150) */ + public static final Errno ERRNO_START_LOCAL_SOCKET_LIB_LOAD_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Failed to load \"%1$s\" library.\nException: %2$s"); + + /** Errors for {@link LocalServerSocket} (150-200) */ + public static final Errno ERRNO_SERVER_SOCKET_PATH_NULL_OR_EMPTY = new Errno(TYPE, 150, "The \"%1$s\" server socket path is null or empty."); + public static final Errno ERRNO_SERVER_SOCKET_PATH_TOO_LONG = new Errno(TYPE, 151, "The \"%1$s\" server socket path \"%2$s\" is greater than 108 bytes."); + public static final Errno ERRNO_SERVER_SOCKET_PATH_NOT_ABSOLUTE = new Errno(TYPE, 152, "The \"%1$s\" server socket path \"%2$s\" is not an absolute file path."); + public static final Errno ERRNO_SERVER_SOCKET_BACKLOG_INVALID = new Errno(TYPE, 153, "The \"%1$s\" server socket backlog \"%2$s\" is not greater than 0."); + public static final Errno ERRNO_CREATE_SERVER_SOCKET_FAILED = new Errno(TYPE, 154, "Create \"%1$s\" server socket failed.\n%2$s"); + public static final Errno ERRNO_SERVER_SOCKET_FD_INVALID = new Errno(TYPE, 155, "Invalid file descriptor \"%1$s\" returned when creating \"%2$s\" server socket."); + public static final Errno ERRNO_ACCEPT_CLIENT_SOCKET_FAILED = new Errno(TYPE, 156, "Accepting client socket for \"%1$s\" server failed.\n%2$s"); + public static final Errno ERRNO_CLIENT_SOCKET_FD_INVALID = new Errno(TYPE, 157, "Invalid file descriptor \"%1$s\" returned when accept new client for \"%2$s\" server."); + public static final Errno ERRNO_GET_CLIENT_SOCKET_PEER_UID_FAILED = new Errno(TYPE, 158, "Getting peer uid for client socket for \"%1$s\" server failed.\n%2$s"); + public static final Errno ERRNO_CLIENT_SOCKET_PEER_UID_INVALID = new Errno(TYPE, 158, "Invalid peer uid \"%1$s\" returned for new client for \"%2$s\" server."); + public static final Errno ERRNO_CLIENT_SOCKET_PEER_UID_DISALLOWED = new Errno(TYPE, 160, "Disallowed peer %1$s tried to connect with \"%2$s\" server."); + public static final Errno ERRNO_CLOSE_SERVER_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 161, "Close \"%1$s\" server socket failed.\nException: %2$s"); + public static final Errno ERRNO_CLIENT_SOCKET_LISTENER_FAILED_WITH_EXCEPTION = new Errno(TYPE, 162, "Exception in client socket listener for \"%1$s\" server.\nException: %2$s"); + + /** Errors for {@link LocalClientSocket} (200-250) */ + public static final Errno ERRNO_SET_CLIENT_SOCKET_READ_TIMEOUT_FAILED = new Errno(TYPE, 200, "Set \"%1$s\" client socket read (SO_RCVTIMEO) timeout to \"%2$s\" failed.\n%3$s"); + public static final Errno ERRNO_SET_CLIENT_SOCKET_SEND_TIMEOUT_FAILED = new Errno(TYPE, 201, "Set \"%1$s\" client socket send (SO_SNDTIMEO) timeout \"%2$s\" failed.\n%3$s"); + public static final Errno ERRNO_READ_DATA_FROM_CLIENT_SOCKET_FAILED = new Errno(TYPE, 202, "Read data from \"%1$s\" client socket failed.\n%2$s"); + public static final Errno ERRNO_READ_DATA_FROM_INPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 203, "Read data from \"%1$s\" client socket input stream failed.\n%2$s"); + public static final Errno ERRNO_SEND_DATA_TO_CLIENT_SOCKET_FAILED = new Errno(TYPE, 204, "Send data to \"%1$s\" client socket failed.\n%2$s"); + public static final Errno ERRNO_SEND_DATA_TO_OUTPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 205, "Send data to \"%1$s\" client socket output stream failed.\n%2$s"); + public static final Errno ERRNO_CHECK_AVAILABLE_DATA_ON_CLIENT_SOCKET_FAILED = new Errno(TYPE, 206, "Check available data on \"%1$s\" client socket failed.\n%2$s"); + public static final Errno ERRNO_CLOSE_CLIENT_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 207, "Close \"%1$s\" client socket failed.\n%2$s"); + public static final Errno ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD = new Errno(TYPE, 208, "Trying to use client socket with invalid file descriptor \"%1$s\" for \"%2$s\" server."); + + LocalSocketErrno(final String type, final int code, final String message) { + super(type, code, message); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java new file mode 100644 index 000000000..3cd959c34 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java @@ -0,0 +1,450 @@ +package com.termux.shared.net.socket.local; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.errors.Error; +import com.termux.shared.jni.models.JniResult; +import com.termux.shared.logger.Logger; + +/** + * Manager for an AF_UNIX/SOCK_STREAM local server. + * + * Usage: + * 1. Implement the {@link ILocalSocketManager} that will receive call backs from the server including + * when client connects via {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)}. + * Optionally extend the {@link LocalSocketManagerClientBase} class that provides base implementation. + * 2. Create a {@link LocalSocketRunConfig} instance with the run config of the server. + * 3. Create a {@link LocalSocketManager} instance and call {@link #start()}. + * 4. Stop server if needed with a call to {@link #stop()}. + */ +public class LocalSocketManager { + + public static final String LOG_TAG = "LocalSocketManager"; + + /** The native JNI local socket library. */ + protected static String LOCAL_SOCKET_LIBRARY = "local-socket"; + + /** Whether {@link #LOCAL_SOCKET_LIBRARY} has been loaded or not. */ + protected static boolean localSocketLibraryLoaded; + + /** The {@link Context} that may needed for various operations. */ + @NonNull protected final Context mContext; + + /** The {@link LocalSocketRunConfig} containing run config for the {@link LocalSocketManager}. */ + @NonNull protected final LocalSocketRunConfig mLocalSocketRunConfig; + + /** The {@link LocalServerSocket} for the {@link LocalSocketManager}. */ + @NonNull protected final LocalServerSocket mServerSocket; + + /** The {@link ILocalSocketManager} client for the {@link LocalSocketManager}. */ + @NonNull protected final ILocalSocketManager mLocalSocketManagerClient; + + /** The {@link Thread.UncaughtExceptionHandler} used for client thread started by {@link LocalSocketManager}. */ + @NonNull protected final Thread.UncaughtExceptionHandler mLocalSocketManagerClientThreadUEH; + + /** Whether the {@link LocalServerSocket} managed by {@link LocalSocketManager} in running or not. */ + protected boolean mIsRunning; + + + /** + * Create an new instance of {@link LocalSocketManager}. + * + * @param context The {@link #mContext} value. + * @param localSocketRunConfig The {@link #mLocalSocketRunConfig} value. + */ + public LocalSocketManager(@NonNull Context context, @NonNull LocalSocketRunConfig localSocketRunConfig) { + mContext = context.getApplicationContext(); + mLocalSocketRunConfig = localSocketRunConfig; + mServerSocket = new LocalServerSocket(this); + mLocalSocketManagerClient = mLocalSocketRunConfig.getLocalSocketManagerClient(); + mLocalSocketManagerClientThreadUEH = getLocalSocketManagerClientThreadUEHOrDefault(); + mIsRunning = false; + } + + /** + * Create the {@link LocalServerSocket} and start listening for new {@link LocalClientSocket}. + */ + public synchronized Error start() { + Logger.logDebugExtended(LOG_TAG, "start\n" + mLocalSocketRunConfig); + + if (!localSocketLibraryLoaded) { + try { + Logger.logDebug(LOG_TAG, "Loading \"" + LOCAL_SOCKET_LIBRARY + "\" library"); + System.loadLibrary(LOCAL_SOCKET_LIBRARY); + localSocketLibraryLoaded = true; + } catch (Throwable t) { + Error error = LocalSocketErrno.ERRNO_START_LOCAL_SOCKET_LIB_LOAD_FAILED_WITH_EXCEPTION.getError(t, LOCAL_SOCKET_LIBRARY, t.getMessage()); + Logger.logErrorExtended(LOG_TAG, error.getErrorLogString()); + return error; + } + } + + mIsRunning = true; + return mServerSocket.start(); + } + + /** + * Stop the {@link LocalServerSocket} and stop listening for new {@link LocalClientSocket}. + */ + public synchronized Error stop() { + if (mIsRunning) { + Logger.logDebugExtended(LOG_TAG, "stop\n" + mLocalSocketRunConfig); + mIsRunning = false; + return mServerSocket.stop(); + } + return null; + } + + + + + /* + Note: Exceptions thrown from JNI must be caught with Throwable class instead of Exception, + otherwise exception will be sent to UncaughtExceptionHandler of the thread. + */ + + /** + * Creates an AF_UNIX/SOCK_STREAM local server socket at {@code path}, with the specified backlog. + * + * @param serverTitle The server title used for logging and errors. + * @param path The path at which to create the socket. + * For a filesystem socket, this must be an absolute path to the socket file. + * For an abstract namespace socket, the first byte must be a null `\0` character. + * Max allowed length is 108 bytes as per sun_path size (UNIX_PATH_MAX) on Linux. + * @param backlog The maximum length to which the queue of pending connections for the socket + * may grow. This value may be ignored or may not have one-to-one mapping + * in kernel implementation. Value must be greater than 0. + * @return Returns the {@link JniResult}. If server creation was successful, then + * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the server socket + * fd. + */ + @Nullable + public static JniResult createServerSocket(@NonNull String serverTitle, @NonNull byte[] path, int backlog) { + try { + return createServerSocketNative(serverTitle, path, backlog); + } catch (Throwable t) { + String message = "Exception in createServerSocketNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Closes the socket with fd. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @return Returns the {@link JniResult}. If closing socket was successful, then + * {@link JniResult#retval} will be 0. + */ + @Nullable + public static JniResult closeSocket(@NonNull String serverTitle, int fd) { + try { + return closeSocketNative(serverTitle, fd); + } catch (Throwable t) { + String message = "Exception in closeSocketNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Accepts a connection on the supplied server socket fd. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The server socket fd. + * @return Returns the {@link JniResult}. If accepting socket was successful, then + * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the client socket + * fd. + */ + @Nullable + public static JniResult accept(@NonNull String serverTitle, int fd) { + try { + return acceptNative(serverTitle, fd); + } catch (Throwable t) { + String message = "Exception in acceptNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Attempts to read up to data buffer length bytes from file descriptor fd into the data buffer. + * On success, the number of bytes read is returned (zero indicates end of file). + * It is not an error if bytes read is smaller than the number of bytes requested; this may happen + * for example because fewer bytes are actually available right now (maybe because we were close + * to end-of-file, or because we are reading from a pipe), or because read() was interrupted by + * a signal. On error, the {@link JniResult#errno} and {@link JniResult#errmsg} will be set. + * + * If while reading the deadline elapses but all the data has not been read, the call will fail. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @param data The data buffer to read bytes into. + * @param deadline The deadline milliseconds since epoch. + * @return Returns the {@link JniResult}. If reading was successful, then {@link JniResult#retval} + * will be 0 and {@link JniResult#intData} will contain the bytes read. + */ + @Nullable + public static JniResult read(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline) { + try { + return readNative(serverTitle, fd, data, deadline); + } catch (Throwable t) { + String message = "Exception in readNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Attempts to send data buffer to the file descriptor. On error, the {@link JniResult#errno} and + * {@link JniResult#errmsg} will be set. + * + * If while sending the deadline elapses but all the data has not been sent, the call will fail. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @param data The data buffer containing bytes to send. + * @param deadline The deadline milliseconds since epoch. + * @return Returns the {@link JniResult}. If sending was successful, then {@link JniResult#retval} + * will be 0. + */ + @Nullable + public static JniResult send(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline) { + try { + return sendNative(serverTitle, fd, data, deadline); + } catch (Throwable t) { + String message = "Exception in sendNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Gets the number of bytes available to read on the socket. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @return Returns the {@link JniResult}. If checking availability was successful, then + * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the bytes available. + */ + @Nullable + public static JniResult available(@NonNull String serverTitle, int fd) { + try { + return availableNative(serverTitle, fd); + } catch (Throwable t) { + String message = "Exception in availableNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Set receiving (SO_RCVTIMEO) timeout in milliseconds for socket. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @param timeout The timeout value in milliseconds. + * @return Returns the {@link JniResult}. If setting timeout was successful, then + * {@link JniResult#retval} will be 0. + */ + @Nullable + public static JniResult setSocketReadTimeout(@NonNull String serverTitle, int fd, int timeout) { + try { + return setSocketReadTimeoutNative(serverTitle, fd, timeout); + } catch (Throwable t) { + String message = "Exception in setSocketReadTimeoutNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Set sending (SO_SNDTIMEO) timeout in milliseconds for fd. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @param timeout The timeout value in milliseconds. + * @return Returns the {@link JniResult}. If setting timeout was successful, then + * {@link JniResult#retval} will be 0. + */ + @Nullable + public static JniResult setSocketSendTimeout(@NonNull String serverTitle, int fd, int timeout) { + try { + return setSocketSendTimeoutNative(serverTitle, fd, timeout); + } catch (Throwable t) { + String message = "Exception in setSocketSendTimeoutNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + /** + * Get the {@link PeerCred} for the socket. + * + * @param serverTitle The server title used for logging and errors. + * @param fd The socket fd. + * @param peerCred The {@link PeerCred} object that should be filled. + * @return Returns the {@link JniResult}. If setting timeout was successful, then + * {@link JniResult#retval} will be 0. + */ + @Nullable + public static JniResult getPeerCred(@NonNull String serverTitle, int fd, PeerCred peerCred) { + try { + return getPeerCredNative(serverTitle, fd, peerCred); + } catch (Throwable t) { + String message = "Exception in getPeerCredNative()"; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + return new JniResult(message, t); + } + } + + + + /** Wrapper for {@link #onError(LocalClientSocket, Error)} for {@code null} {@link LocalClientSocket}. */ + public void onError(@NonNull Error error) { + onError(null, error); + } + + /** Wrapper to call {@link ILocalSocketManager#onError(LocalSocketManager, LocalClientSocket, Error)} in a new thread. */ + public void onError(@Nullable LocalClientSocket clientSocket, @NonNull Error error) { + startLocalSocketManagerClientThread(() -> + mLocalSocketManagerClient.onError(this, clientSocket, error)); + } + + /** Wrapper to call {@link ILocalSocketManager#onDisallowedClientConnected(LocalSocketManager, LocalClientSocket, Error)} in a new thread. */ + public void onDisallowedClientConnected(@NonNull LocalClientSocket clientSocket, @NonNull Error error) { + startLocalSocketManagerClientThread(() -> + mLocalSocketManagerClient.onDisallowedClientConnected(this, clientSocket, error)); + } + + /** Wrapper to call {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)} in a new thread. */ + public void onClientAccepted(@NonNull LocalClientSocket clientSocket) { + startLocalSocketManagerClientThread(() -> + mLocalSocketManagerClient.onClientAccepted(this, clientSocket)); + } + + /** All client accept logic must be run on separate threads so that incoming client acceptance is not blocked. */ + public void startLocalSocketManagerClientThread(@NonNull Runnable runnable) { + Thread thread = new Thread(runnable); + thread.setUncaughtExceptionHandler(getLocalSocketManagerClientThreadUEH()); + try { + thread.start(); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "LocalSocketManagerClientThread start failed", e); + } + } + + + + /** Get {@link #mContext}. */ + public Context getContext() { + return mContext; + } + + /** Get {@link #mLocalSocketRunConfig}. */ + public LocalSocketRunConfig getLocalSocketRunConfig() { + return mLocalSocketRunConfig; + } + + /** Get {@link #mLocalSocketManagerClient}. */ + public ILocalSocketManager getLocalSocketManagerClient() { + return mLocalSocketManagerClient; + } + + /** Get {@link #mServerSocket}. */ + public LocalServerSocket getServerSocket() { + return mServerSocket; + } + + /** Get {@link #mLocalSocketManagerClientThreadUEH}. */ + public Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH() { + return mLocalSocketManagerClientThreadUEH; + } + + /** + * Get {@link Thread.UncaughtExceptionHandler} returned by call to + * {@link ILocalSocketManager#getLocalSocketManagerClientThreadUEH(LocalSocketManager)} + * or the default handler that just logs the exception. + */ + protected Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEHOrDefault() { + Thread.UncaughtExceptionHandler uncaughtExceptionHandler = + mLocalSocketManagerClient.getLocalSocketManagerClientThreadUEH(this); + if (uncaughtExceptionHandler == null) + uncaughtExceptionHandler = (t, e) -> + Logger.logStackTraceWithMessage(LOG_TAG, "Uncaught exception for " + t + " in " + mLocalSocketRunConfig.getTitle() + " server", e); + return uncaughtExceptionHandler; + } + + /** Get {@link #mIsRunning}. */ + public boolean isRunning() { + return mIsRunning; + } + + + + /** Get an error log {@link String} for the {@link LocalSocketManager}. */ + public static String getErrorLogString(@NonNull Error error, + @NonNull LocalSocketRunConfig localSocketRunConfig, + @Nullable LocalClientSocket clientSocket) { + StringBuilder logString = new StringBuilder(); + + logString.append(localSocketRunConfig.getTitle()).append(" Socket Server Error:\n"); + logString.append(error.getErrorLogString()); + logString.append("\n\n\n"); + + logString.append(localSocketRunConfig.getLogString()); + + if (clientSocket != null) { + logString.append("\n\n\n"); + logString.append(clientSocket.getLogString()); + } + + return logString.toString(); + } + + /** Get an error markdown {@link String} for the {@link LocalSocketManager}. */ + public static String getErrorMarkdownString(@NonNull Error error, + @NonNull LocalSocketRunConfig localSocketRunConfig, + @Nullable LocalClientSocket clientSocket) { + StringBuilder markdownString = new StringBuilder(); + + markdownString.append(error.getErrorMarkdownString()); + markdownString.append("\n##\n\n\n"); + + markdownString.append(localSocketRunConfig.getMarkdownString()); + + if (clientSocket != null) { + markdownString.append("\n\n\n"); + markdownString.append(clientSocket.getMarkdownString()); + } + + return markdownString.toString(); + } + + + + + + @Nullable private static native JniResult createServerSocketNative(@NonNull String serverTitle, @NonNull byte[] path, int backlog); + + @Nullable private static native JniResult closeSocketNative(@NonNull String serverTitle, int fd); + + @Nullable private static native JniResult acceptNative(@NonNull String serverTitle, int fd); + + @Nullable private static native JniResult readNative(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline); + + @Nullable private static native JniResult sendNative(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline); + + @Nullable private static native JniResult availableNative(@NonNull String serverTitle, int fd); + + private static native JniResult setSocketReadTimeoutNative(@NonNull String serverTitle, int fd, int timeout); + + @Nullable private static native JniResult setSocketSendTimeoutNative(@NonNull String serverTitle, int fd, int timeout); + + @Nullable private static native JniResult getPeerCredNative(@NonNull String serverTitle, int fd, PeerCred peerCred); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java new file mode 100644 index 000000000..4d7657c45 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java @@ -0,0 +1,47 @@ +package com.termux.shared.net.socket.local; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.errors.Error; +import com.termux.shared.logger.Logger; + +/** Base helper implementation for {@link ILocalSocketManager}. */ +public abstract class LocalSocketManagerClientBase implements ILocalSocketManager { + + @Nullable + @Override + public Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH( + @NonNull LocalSocketManager localSocketManager) { + return null; + } + + @Override + public void onError(@NonNull LocalSocketManager localSocketManager, + @Nullable LocalClientSocket clientSocket, @NonNull Error error) { + // Only log if log level is debug or higher since PeerCred.cmdline may contain private info + Logger.logErrorPrivate(getLogTag(), "onError"); + Logger.logErrorPrivateExtended(getLogTag(), LocalSocketManager.getErrorLogString(error, + localSocketManager.getLocalSocketRunConfig(), clientSocket)); + } + + @Override + public void onDisallowedClientConnected(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket, @NonNull Error error) { + Logger.logWarn(getLogTag(), "onDisallowedClientConnected"); + Logger.logWarnExtended(getLogTag(), LocalSocketManager.getErrorLogString(error, + localSocketManager.getLocalSocketRunConfig(), clientSocket)); + } + + @Override + public void onClientAccepted(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket) { + // Just close socket and let child class handle any required communication + clientSocket.closeClientSocket(true); + } + + + + protected abstract String getLogTag(); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java new file mode 100644 index 000000000..c09f5bef3 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java @@ -0,0 +1,265 @@ +package com.termux.shared.net.socket.local; + +import androidx.annotation.NonNull; + +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; + + +/** + * Run config for {@link LocalSocketManager}. + */ +public class LocalSocketRunConfig implements Serializable { + + /** The {@link LocalSocketManager} title. */ + protected final String mTitle; + + /** + * The {@link LocalServerSocket} path. + * + * For a filesystem socket, this must be an absolute path to the socket file. Creation of a new + * socket will fail if the server starter app process does not have write and search (execute) + * permission on the directory in which the socket is created. The client process must have write + * permission on the socket to connect to it. Other app will not be able to connect to socket + * if its created in private app data directory. + * + * For an abstract namespace socket, the first byte must be a null `\0` character. Note that on + * Android 9+, if server app is using `targetSdkVersion` `28`, then other apps will not be able + * to connect to it due to selinux restrictions. + * > Per-app SELinux domains + * > Apps that target Android 9 or higher cannot share data with other apps using world-accessible + * Unix permissions. This change improves the integrity of the Android Application Sandbox, + * particularly the requirement that an app's private data is accessible only by that app. + * https://developer.android.com/about/versions/pie/android-9.0-changes-28 + * https://github.com/android/ndk/issues/1469 + * https://stackoverflow.com/questions/63806516/avc-denied-connectto-when-using-uds-on-android-10 + * + * Max allowed length is 108 bytes as per sun_path size (UNIX_PATH_MAX) on Linux. + */ + protected final String mPath; + + /** If abstract namespace {@link LocalServerSocket} instead of filesystem. */ + protected final boolean mAbstractNamespaceSocket; + + /** The {@link ILocalSocketManager} client for the {@link LocalSocketManager}. */ + protected final ILocalSocketManager mLocalSocketManagerClient; + + /** + * The {@link LocalServerSocket} file descriptor. + * Value will be `>= 0` if socket has been created successfully and `-1` if not created or closed. + */ + protected int mFD = -1; + + /** + * The {@link LocalClientSocket} receiving (SO_RCVTIMEO) timeout in milliseconds. + * + * https://manpages.debian.org/testing/manpages/socket.7.en.html + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/NativeCrashListener.java;l=55 + * Defaults to {@link #DEFAULT_RECEIVE_TIMEOUT}. + */ + protected Integer mReceiveTimeout; + public static final int DEFAULT_RECEIVE_TIMEOUT = 10000; + + /** + * The {@link LocalClientSocket} sending (SO_SNDTIMEO) timeout in milliseconds. + * + * https://manpages.debian.org/testing/manpages/socket.7.en.html + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/NativeCrashListener.java;l=55 + * Defaults to {@link #DEFAULT_SEND_TIMEOUT}. + */ + protected Integer mSendTimeout; + public static final int DEFAULT_SEND_TIMEOUT = 10000; + + /** + * The {@link LocalClientSocket} deadline in milliseconds. When the deadline has elapsed after + * creation time of client socket, all reads and writes will error out. Set to 0, for no + * deadline. + * Defaults to {@link #DEFAULT_DEADLINE}. + */ + protected Long mDeadline; + public static final int DEFAULT_DEADLINE = 0; + + /** + * The {@link LocalServerSocket} backlog for the maximum length to which the queue of pending connections + * for the socket may grow. This value may be ignored or may not have one-to-one mapping + * in kernel implementation. Value must be greater than 0. + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/net/LocalSocketManager.java;l=31 + * Defaults to {@link #DEFAULT_BACKLOG}. + */ + protected Integer mBacklog; + public static final int DEFAULT_BACKLOG = 50; + + + /** + * Create an new instance of {@link LocalSocketRunConfig}. + * + * @param title The {@link #mTitle} value. + * @param path The {@link #mPath} value. + * @param localSocketManagerClient The {@link #mLocalSocketManagerClient} value. + */ + public LocalSocketRunConfig(@NonNull String title, @NonNull String path, @NonNull ILocalSocketManager localSocketManagerClient) { + mTitle = title; + mLocalSocketManagerClient = localSocketManagerClient; + mAbstractNamespaceSocket = path.getBytes(StandardCharsets.UTF_8)[0] == 0; + + if (mAbstractNamespaceSocket) + mPath = path; + else + mPath = FileUtils.getCanonicalPath(path, null); + } + + + + /** Get {@link #mTitle}. */ + public String getTitle() { + return mTitle; + } + + /** Get log title that should be used for {@link LocalSocketManager}. */ + public String getLogTitle() { + return Logger.getDefaultLogTag() + "." + mTitle; + } + + /** Get {@link #mPath}. */ + public String getPath() { + return mPath; + } + + /** Get {@link #mAbstractNamespaceSocket}. */ + public boolean isAbstractNamespaceSocket() { + return mAbstractNamespaceSocket; + } + + /** Get {@link #mLocalSocketManagerClient}. */ + public ILocalSocketManager getLocalSocketManagerClient() { + return mLocalSocketManagerClient; + } + + /** Get {@link #mFD}. */ + public Integer getFD() { + return mFD; + } + + /** Set {@link #mFD}. Value must be greater than 0 or -1. */ + public void setFD(int fd) { + if (fd >= 0) + mFD = fd; + else + mFD = -1; + } + + /** Get {@link #mReceiveTimeout} if set, otherwise {@link #DEFAULT_RECEIVE_TIMEOUT}. */ + public Integer getReceiveTimeout() { + return mReceiveTimeout != null ? mReceiveTimeout : DEFAULT_RECEIVE_TIMEOUT; + } + + /** Set {@link #mReceiveTimeout}. */ + public void setReceiveTimeout(Integer receiveTimeout) { + mReceiveTimeout = receiveTimeout; + } + + /** Get {@link #mSendTimeout} if set, otherwise {@link #DEFAULT_SEND_TIMEOUT}. */ + public Integer getSendTimeout() { + return mSendTimeout != null ? mSendTimeout : DEFAULT_SEND_TIMEOUT; + } + + /** Set {@link #mSendTimeout}. */ + public void setSendTimeout(Integer sendTimeout) { + mSendTimeout = sendTimeout; + } + + /** Get {@link #mDeadline} if set, otherwise {@link #DEFAULT_DEADLINE}. */ + public Long getDeadline() { + return mDeadline != null ? mDeadline : DEFAULT_DEADLINE; + } + + /** Set {@link #mDeadline}. */ + public void setDeadline(Long deadline) { + mDeadline = deadline; + } + + /** Get {@link #mBacklog} if set, otherwise {@link #DEFAULT_BACKLOG}. */ + public Integer getBacklog() { + return mBacklog != null ? mBacklog : DEFAULT_BACKLOG; + } + + /** Set {@link #mBacklog}. Value must be greater than 0. */ + public void setBacklog(Integer backlog) { + if (backlog > 0) + mBacklog = backlog; + } + + + /** + * Get a log {@link String} for {@link LocalSocketRunConfig}. + * + * @param config The {@link LocalSocketRunConfig} to get info of. + * @return Returns the log {@link String}. + */ + @NonNull + public static String getRunConfigLogString(final LocalSocketRunConfig config) { + if (config == null) return "null"; + return config.getLogString(); + } + + /** Get a log {@link String} for the {@link LocalSocketRunConfig}. */ + @NonNull + public String getLogString() { + StringBuilder logString = new StringBuilder(); + + logString.append(mTitle).append(" Socket Server Run Config:"); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Path", mPath, "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("AbstractNamespaceSocket", mAbstractNamespaceSocket, "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("LocalSocketManagerClient", mLocalSocketManagerClient.getClass().getName(), "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("FD", mFD, "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("ReceiveTimeout", getReceiveTimeout(), "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("SendTimeout", getSendTimeout(), "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Deadline", getDeadline(), "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Backlog", getBacklog(), "-")); + + return logString.toString(); + } + + /** + * Get a markdown {@link String} for {@link LocalSocketRunConfig}. + * + * @param config The {@link LocalSocketRunConfig} to get info of. + * @return Returns the markdown {@link String}. + */ + public static String getRunConfigMarkdownString(final LocalSocketRunConfig config) { + if (config == null) return "null"; + return config.getMarkdownString(); + } + + /** Get a markdown {@link String} for the {@link LocalSocketRunConfig}. */ + @NonNull + public String getMarkdownString() { + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## ").append(mTitle).append(" Socket Server Run Config"); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Path", mPath, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("AbstractNamespaceSocket", mAbstractNamespaceSocket, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("LocalSocketManagerClient", mLocalSocketManagerClient.getClass().getName(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("FD", mFD, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("ReceiveTimeout", getReceiveTimeout(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("SendTimeout", getSendTimeout(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Deadline", getDeadline(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Backlog", getBacklog(), "-")); + + return markdownString.toString(); + } + + + + @NonNull + @Override + public String toString() { + return getLogString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java new file mode 100644 index 000000000..6674eee0f --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java @@ -0,0 +1,142 @@ +package com.termux.shared.net.socket.local; + +import android.content.Context; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; + +import com.termux.shared.android.ProcessUtils; +import com.termux.shared.android.UserUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; + +/** The {@link PeerCred} of the {@link LocalClientSocket} containing info of client/peer. */ +@Keep +public class PeerCred { + + public static final String LOG_TAG = "PeerCred"; + + /** Process Id. */ + public int pid; + /** Process Name. */ + public String pname; + + /** User Id. */ + public int uid; + /** User name. */ + public String uname; + + /** Group Id. */ + public int gid; + /** Group name. */ + public String gname; + + /** Command line that started the process. */ + public String cmdline; + + PeerCred() { + // Initialize to -1 instead of 0 in case a failed getPeerCred()/getsockopt() call somehow doesn't report failure and returns the uid of root + pid = -1; uid = -1; gid = -1; + } + + /** Set data that was not set by JNI. */ + public void fillPeerCred(@NonNull Context context) { + fillUnameAndGname(context); + fillPname(context); + } + + /** Set {@link #uname} and {@link #gname} if not set. */ + public void fillUnameAndGname(@NonNull Context context) { + uname = UserUtils.getNameForUid(context, uid); + + if (gid != uid) + gname = UserUtils.getNameForUid(context, gid); + else + gname = uname; + } + + /** Set {@link #pname} if not set. */ + public void fillPname(@NonNull Context context) { + // If jni did not set process name since it wouldn't be able to access /proc/ of other + // users/apps, then try to see if any app has that pid, but this wouldn't check child + // processes of the app. + if (pid > 0 && pname == null) + pname = ProcessUtils.getAppProcessNameForPid(context, pid); + } + + /** + * Get a log {@link String} for {@link PeerCred}. + * + * @param peerCred The {@link PeerCred} to get info of. + * @return Returns the log {@link String}. + */ + @NonNull + public static String getPeerCredLogString(final PeerCred peerCred) { + if (peerCred == null) return "null"; + return peerCred.getLogString(); + } + + /** Get a log {@link String} for the {@link PeerCred}. */ + @NonNull + public String getLogString() { + StringBuilder logString = new StringBuilder(); + + logString.append("Peer Cred:"); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Process", getProcessString(), "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("User", getUserString(), "-")); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("Group", getGroupString(), "-")); + + if (cmdline != null) + logString.append("\n").append(Logger.getMultiLineLogStringEntry("Cmdline", cmdline, "-")); + + return logString.toString(); + } + + /** + * Get a markdown {@link String} for {@link PeerCred}. + * + * @param peerCred The {@link PeerCred} to get info of. + * @return Returns the markdown {@link String}. + */ + public static String getPeerCredMarkdownString(final PeerCred peerCred) { + if (peerCred == null) return "null"; + return peerCred.getMarkdownString(); + } + + /** Get a markdown {@link String} for the {@link PeerCred}. */ + @NonNull + public String getMarkdownString() { + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## ").append("Peer Cred"); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Process", getProcessString(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User", getUserString(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Group", getGroupString(), "-")); + + if (cmdline != null) + markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Cmdline", cmdline, "-")); + + return markdownString.toString(); + } + + @NonNull + public String getMinimalString() { + return "process=" + getProcessString() + ", user=" + getUserString() + ", group=" + getGroupString(); + } + + @NonNull + public String getProcessString() { + return pname != null && !pname.isEmpty() ? pid + " (" + pname + ")" : String.valueOf(pid); + } + + @NonNull + public String getUserString() { + return uname != null ? uid + " (" + uname + ")" : String.valueOf(uid); + } + + @NonNull + public String getGroupString() { + return gname != null ? gid + " (" + gname + ")" : String.valueOf(gid); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java new file mode 100644 index 000000000..c32be0d5a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java @@ -0,0 +1,28 @@ +package com.termux.shared.net.uri; + +import android.net.Uri; + +/** + * The {@link Uri} schemes. + * + * https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml + * https://en.wikipedia.org/wiki/List_of_URI_schemes + */ +public class UriScheme { + + /** Android app resource. */ + public static final String SCHEME_ANDROID_RESOURCE = "android.resource"; + + /** Android content provider. https://www.iana.org/assignments/uri-schemes/prov/content. */ + public static final String SCHEME_CONTENT = "content"; + + /** Filesystem or android app asset. https://www.rfc-editor.org/rfc/rfc8089.html. */ + public static final String SCHEME_FILE = "file"; + + /* Hypertext Transfer Protocol. */ + public static final String SCHEME_HTTP = "http"; + + /* Hypertext Transfer Protocol Secure. */ + public static final String SCHEME_HTTPS = "https"; + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java new file mode 100644 index 000000000..2254a92bd --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java @@ -0,0 +1,102 @@ +package com.termux.shared.net.uri; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.data.DataUtils; +import com.termux.shared.file.FileUtils; + +public class UriUtils { + + /** + * Get the full file path from a {@link Uri} including the fragment. + * + * If the {@link Uri} was created from file path with {@link Uri#parse(String)}, like "am" + * command "-d" option does, and the path contained a "#", then anything after it would become + * the fragment and {@link Uri#getPath()} will only return the path before it, which would be + * invalid. The fragment must be manually appended to the path to get the full path. + * + * If the {@link Uri} was created with {@link Uri.Builder} and path was set + * with {@link Uri.Builder#path(String)}, then "#" will automatically be encoded to "%23" + * and separate fragment will not exist. + * + * @param uri The {@link Uri} to get file path from. + * @return Returns the file path if found, otherwise {@code null}. + */ + @Nullable + public static String getUriFilePathWithFragment(Uri uri) { + if (uri == null) return null; + String path = uri.getPath(); + if (DataUtils.isNullOrEmpty(path)) return null; + String fragment = uri.getFragment(); + return path + (DataUtils.isNullOrEmpty(fragment) ? "" : "#" + fragment); + } + + /** + * Get the file basename from a {@link Uri}. The file basename is anything after last forward + * slash "/" in the path, or the path itself if its not found. + * + * @param uri The {@link Uri} to get basename from. + * @param withFragment If the {@link Uri} fragment should be included in basename. + * @return Returns the file basename if found, otherwise {@code null}. + */ + @Nullable + public static String getUriFileBasename(Uri uri, boolean withFragment) { + if (uri == null) return null; + + String path; + if (withFragment) { + path = getUriFilePathWithFragment(uri); + } else { + path = uri.getPath(); + if (DataUtils.isNullOrEmpty(path)) return null; + } + + return FileUtils.getFileBasename(path); + } + + /** + * Get {@link UriScheme#SCHEME_FILE} {@link Uri} for path. + * + * @param path The path for the {@link Uri}. + * @return Returns the {@link Uri}. + */ + public static Uri getFileUri(@NonNull String path) { + return new Uri.Builder().scheme(UriScheme.SCHEME_FILE).path(path).build(); + } + + /** + * Get {@link UriScheme#SCHEME_FILE} {@link Uri} for path. + * + * @param authority The authority for the {@link Uri}. + * @param path The path for the {@link Uri}. + * @return Returns the {@link Uri}. + */ + public static Uri getFileUri(@NonNull String authority, @NonNull String path) { + return new Uri.Builder().scheme(UriScheme.SCHEME_FILE).authority(authority).path(path).build(); + } + + /** + * Get {@link UriScheme#SCHEME_CONTENT} {@link Uri} for path. + * + * @param path The path for the {@link Uri}. + * @return Returns the {@link Uri}. + */ + public static Uri getContentUri(@NonNull String path) { + return new Uri.Builder().scheme(UriScheme.SCHEME_CONTENT).path(path).build(); + } + + /** + * Get {@link UriScheme#SCHEME_CONTENT} {@link Uri} for path. + * + * @param authority The authority for the {@link Uri}. + * @param path The path for the {@link Uri}. + * @return Returns the {@link Uri}. + */ + public static Uri getContentUri(@NonNull String authority, @NonNull String path) { + return new Uri.Builder().scheme(UriScheme.SCHEME_CONTENT).authority(authority).path(path).build(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java new file mode 100644 index 000000000..63ca3a051 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java @@ -0,0 +1,113 @@ +package com.termux.shared.net.url; + +import androidx.annotation.Nullable; + +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; + +import java.net.MalformedURLException; +import java.net.URL; + +public class UrlUtils { + + /** The parts of a {@link URL}. */ + public enum UrlPart { + AUTHORITY, + FILE, + HOST, + REF, + FRAGMENT, + PATH, + PORT, + PROTOCOL, + QUERY, + USER_INFO + } + + private static final String LOG_TAG = "UrlUtils"; + + /** + * Join a url base and destination. + * + * @param base The base url to open. + * @param destination The destination url to open. + * @param logError If an error message should be logged. + * @return Returns the joined {@link String} Url, otherwise {@code null}. + */ + @Nullable + public static String joinUrl(final String base, String destination, boolean logError) { + if (DataUtils.isNullOrEmpty(base)) return null; + try { + return new URL(new URL(base), destination).toString(); + } catch (MalformedURLException e) { + if (logError) + Logger.logError(LOG_TAG, "Failed to join url base \"" + base + "\" and destination \"" + destination + "\": " + e.getMessage()); + return null; + } + } + + /** + * Get {@link URL} from url string. + * + * @param urlString The urlString string. + * @return Returns the {@link URL} if a valid urlString, otherwise {@code null}. + */ + @Nullable + public static URL getUrl(String urlString) { + if (DataUtils.isNullOrEmpty(urlString)) return null; + try { + return new URL(urlString); + } catch (MalformedURLException e) { + return null; + } + } + + /** + * Get a {@link URL} part from url string. + * + * @param urlString The urlString string. + * @param urlPart The part to get. + * @return Returns the {@link URL} part if a valid urlString and part, otherwise {@code null}. + */ + @Nullable + public static String getUrlPart(String urlString, UrlPart urlPart) { + URL url = getUrl(urlString); + if (url == null) return null; + switch (urlPart) { + case AUTHORITY: + return url.getAuthority(); + case FILE: + return url.getFile(); + case HOST: + return url.getHost(); + case REF: + case FRAGMENT: + return url.getRef(); + case PATH: + return url.getPath(); + case PORT: + return String.valueOf(url.getPort()); + case PROTOCOL: + return url.getProtocol(); + case QUERY: + return url.getQuery(); + case USER_INFO: + return url.getUserInfo(); + default: + return null; + } + } + + /** Remove "https://www.", "https://", "www.", etc */ + public static String removeProtocol(String urlString) { + if (urlString == null) return null; + return urlString.replaceFirst("^(http[s]?://www\\.|http[s]?://|www\\.)",""); + } + + public static boolean areUrlsEqual(String url1, String url2) { + if (url1 == null && url2 == null) return true; + if (url1 == null || url2 == null) return false; + return UrlUtils.removeProtocol(url1).replaceAll("/+$", "").equals(UrlUtils.removeProtocol(url2).replaceAll("/+$", "")); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java new file mode 100644 index 000000000..e21a83f40 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java @@ -0,0 +1,146 @@ +package com.termux.shared.notification; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Build; + +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; + +public class NotificationUtils { + + /** Do not show notification */ + public static final int NOTIFICATION_MODE_NONE = 0; + /** Show notification without sound, vibration or lights */ + public static final int NOTIFICATION_MODE_SILENT = 1; + /** Show notification with sound */ + public static final int NOTIFICATION_MODE_SOUND = 2; + /** Show notification with vibration */ + public static final int NOTIFICATION_MODE_VIBRATE = 3; + /** Show notification with lights */ + public static final int NOTIFICATION_MODE_LIGHTS = 4; + /** Show notification with sound and vibration */ + public static final int NOTIFICATION_MODE_SOUND_AND_VIBRATE = 5; + /** Show notification with sound and lights */ + public static final int NOTIFICATION_MODE_SOUND_AND_LIGHTS = 6; + /** Show notification with vibration and lights */ + public static final int NOTIFICATION_MODE_VIBRATE_AND_LIGHTS = 7; + /** Show notification with sound, vibration and lights */ + public static final int NOTIFICATION_MODE_ALL = 8; + + private static final String LOG_TAG = "NotificationUtils"; + + /** + * Get the {@link NotificationManager}. + * + * @param context The {@link Context} for operations. + * @return Returns the {@link NotificationManager}. + */ + @Nullable + public static NotificationManager getNotificationManager(final Context context) { + if (context == null) return null; + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + /** + * Get {@link Notification.Builder}. + * + * @param context The {@link Context} for operations. + * @param title The title for the notification. + * @param channelId The channel id for the notification. + * @param priority The priority for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * The builder returned will be {@code null} if {@link #NOTIFICATION_MODE_NONE} + * is passed. That case should ideally be handled before calling this function. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder geNotificationBuilder( + final Context context, final String channelId, final int priority, final CharSequence title, + final CharSequence notificationText, final CharSequence notificationBigText, + final PendingIntent contentIntent, final PendingIntent deleteIntent, final int notificationMode) { + if (context == null) return null; + Notification.Builder builder = new Notification.Builder(context); + builder.setContentTitle(title); + builder.setContentText(notificationText); + builder.setStyle(new Notification.BigTextStyle().bigText(notificationBigText)); + builder.setContentIntent(contentIntent); + builder.setDeleteIntent(deleteIntent); + + builder.setPriority(priority); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + builder.setChannelId(channelId); + + builder = setNotificationDefaults(builder, notificationMode); + + return builder; + } + + /** + * Setup the notification channel if Android version is greater than or equal to + * {@link Build.VERSION_CODES#O}. + * + * @param context The {@link Context} for operations. + * @param channelId The id of the channel. Must be unique per package. + * @param channelName The user visible name of the channel. + * @param importance The importance of the channel. This controls how interruptive notifications + * posted to this channel are. + */ + public static void setupNotificationChannel(final Context context, final String channelId, final CharSequence channelName, final int importance) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + + NotificationChannel channel = new NotificationChannel(channelId, channelName, importance); + + NotificationManager notificationManager = getNotificationManager(context); + if (notificationManager != null) + notificationManager.createNotificationChannel(channel); + } + + public static Notification.Builder setNotificationDefaults(Notification.Builder builder, final int notificationMode) { + + // TODO: setDefaults() is deprecated and should also implement setting notification mode via notification channel + switch (notificationMode) { + case NOTIFICATION_MODE_NONE: + Logger.logWarn(LOG_TAG, "The NOTIFICATION_MODE_NONE passed to setNotificationDefaults(), force setting builder to null."); + return null; // return null since notification is not supposed to be shown + case NOTIFICATION_MODE_SILENT: + break; + case NOTIFICATION_MODE_SOUND: + builder.setDefaults(Notification.DEFAULT_SOUND); + break; + case NOTIFICATION_MODE_VIBRATE: + builder.setDefaults(Notification.DEFAULT_VIBRATE); + break; + case NOTIFICATION_MODE_LIGHTS: + builder.setDefaults(Notification.DEFAULT_LIGHTS); + break; + case NOTIFICATION_MODE_SOUND_AND_VIBRATE: + builder.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE); + break; + case NOTIFICATION_MODE_SOUND_AND_LIGHTS: + builder.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_LIGHTS); + break; + case NOTIFICATION_MODE_VIBRATE_AND_LIGHTS: + builder.setDefaults(Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS); + break; + case NOTIFICATION_MODE_ALL: + builder.setDefaults(Notification.DEFAULT_ALL); + break; + default: + Logger.logError(LOG_TAG, "Invalid notificationMode: \"" + notificationMode + "\" passed to setNotificationDefaults()"); + break; + } + + return builder; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java new file mode 100644 index 000000000..c57f103b4 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java @@ -0,0 +1,282 @@ +package com.termux.shared.reflection; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; + +import org.lsposed.hiddenapibypass.HiddenApiBypass; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; + +public class ReflectionUtils { + + private static boolean HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED = Build.VERSION.SDK_INT < Build.VERSION_CODES.P; + + private static final String LOG_TAG = "ReflectionUtils"; + + /** + * Bypass android hidden API reflection restrictions. + * https://github.com/LSPosed/AndroidHiddenApiBypass + * https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces + */ + public static void bypassHiddenAPIReflectionRestrictions() { + if (!HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Logger.logDebug(LOG_TAG, "Bypassing android hidden api reflection restrictions"); + try { + HiddenApiBypass.addHiddenApiExemptions(""); + } catch (Throwable t) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to bypass hidden API reflection restrictions", t); + } + + HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED = true; + } + } + + /** Check if android hidden API reflection restrictions are bypassed. */ + public static boolean areHiddenAPIReflectionRestrictionsBypassed() { + return HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED; + } + + + + + + /** + * Get a {@link Field} for the specified class. + * + * @param clazz The {@link Class} for which to return the field. + * @param fieldName The name of the {@link Field}. + * @return Returns the {@link Field} if getting the it was successful, otherwise {@code null}. + */ + @Nullable + public static Field getDeclaredField(@NonNull Class clazz, @NonNull String fieldName) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return field; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field for \"" + clazz.getName() + "\" class", e); + return null; + } + } + + + + /** Class that represents result of invoking a field. */ + public static class FieldInvokeResult { + public boolean success; + public Object value; + + FieldInvokeResult(boolean success, Object value) { + this.value = success; + this.value = value; + } + } + + /** + * Get a value for a {@link Field} of an object for the specified class. + * + * Trying to access {@code null} fields will result in {@link NoSuchFieldException}. + * + * @param clazz The {@link Class} to which the object belongs to. + * @param fieldName The name of the {@link Field}. + * @param object The {@link Object} instance from which to get the field value. + * @return Returns the {@link FieldInvokeResult} of invoking the field. The + * {@link FieldInvokeResult#success} will be {@code true} if invoking the field was successful, + * otherwise {@code false}. The {@link FieldInvokeResult#value} will contain the field + * {@link Object} value. + */ + @NonNull + public static FieldInvokeResult invokeField(@NonNull Class clazz, @NonNull String fieldName, T object) { + try { + Field field = getDeclaredField(clazz, fieldName); + if (field == null) return new FieldInvokeResult(false, null); + return new FieldInvokeResult(true, field.get(object)); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field value for \"" + clazz.getName() + "\" class", e); + return new FieldInvokeResult(false, null); + } + } + + + + + + /** + * Wrapper for {@link #getDeclaredMethod(Class, String, Class[])} without parameters. + */ + @Nullable + public static Method getDeclaredMethod(@NonNull Class clazz, @NonNull String methodName) { + return getDeclaredMethod(clazz, methodName, new Class[0]); + } + + /** + * Get a {@link Method} for the specified class with the specified parameters. + * + * @param clazz The {@link Class} for which to return the method. + * @param methodName The name of the {@link Method}. + * @param parameterTypes The parameter types of the method. + * @return Returns the {@link Method} if getting the it was successful, otherwise {@code null}. + */ + @Nullable + public static Method getDeclaredMethod(@NonNull Class clazz, @NonNull String methodName, Class... parameterTypes) { + try { + Method method = clazz.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return method; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + methodName + "\" method for \"" + clazz.getName() + "\" class with parameter types: " + Arrays.toString(parameterTypes), e); + return null; + } + } + + + + /** + * Wrapper for {@link #invokeVoidMethod(Method, Object, Object...)} without arguments. + */ + public static boolean invokeVoidMethod(@NonNull Method method, Object obj) { + return invokeVoidMethod(method, obj, new Object[0]); + } + + /** + * Invoke a {@link Method} on the specified object with the specified arguments that returns + * {@code void}. + * + * @param method The {@link Method} to invoke. + * @param obj The {@link Object} the method should be invoked from. + * @param args The arguments to pass to the method. + * @return Returns {@code true} if invoking the method was successful, otherwise {@code false}. + */ + public static boolean invokeVoidMethod(@NonNull Method method, Object obj, Object... args) { + try { + method.setAccessible(true); + method.invoke(obj, args); + return true; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + method.getName() + "\" method with object \"" + obj + "\" and args: " + Arrays.toString(args), e); + return false; + } + } + + + + /** Class that represents result of invoking a method that has a non-void return type. */ + public static class MethodInvokeResult { + public boolean success; + public Object value; + + MethodInvokeResult(boolean success, Object value) { + this.value = success; + this.value = value; + } + } + + /** + * Wrapper for {@link #invokeMethod(Method, Object, Object...)} without arguments. + */ + @NonNull + public static MethodInvokeResult invokeMethod(@NonNull Method method, Object obj) { + return invokeMethod(method, obj, new Object[0]); + } + + /** + * Invoke a {@link Method} on the specified object with the specified arguments. + * + * @param method The {@link Method} to invoke. + * @param obj The {@link Object} the method should be invoked from. + * @param args The arguments to pass to the method. + * @return Returns the {@link MethodInvokeResult} of invoking the method. The + * {@link MethodInvokeResult#success} will be {@code true} if invoking the method was successful, + * otherwise {@code false}. The {@link MethodInvokeResult#value} will contain the {@link Object} + * returned by the method. + */ + @NonNull + public static MethodInvokeResult invokeMethod(@NonNull Method method, Object obj, Object... args) { + try { + method.setAccessible(true); + return new MethodInvokeResult(true, method.invoke(obj, args)); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + method.getName() + "\" method with object \"" + obj + "\" and args: " + Arrays.toString(args), e); + return new MethodInvokeResult(false, null); + } + } + + + + /** + * Wrapper for {@link #getConstructor(String, Class[])} without parameters. + */ + @Nullable + public static Constructor getConstructor(@NonNull String className) { + return getConstructor(className, new Class[0]); + } + + /** + * Wrapper for {@link #getConstructor(Class, Class[])} to get a {@link Constructor} for the + * {@code className}. + */ + @Nullable + public static Constructor getConstructor(@NonNull String className, Class... parameterTypes) { + try { + return getConstructor(Class.forName(className), parameterTypes); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get constructor for \"" + className + "\" class with parameter types: " + Arrays.toString(parameterTypes), e); + return null; + } + } + + /** + * Get a {@link Constructor} for the specified class with the specified parameters. + * + * @param clazz The {@link Class} for which to return the constructor. + * @param parameterTypes The parameter types of the constructor. + * @return Returns the {@link Constructor} if getting the it was successful, otherwise {@code null}. + */ + @Nullable + public static Constructor getConstructor(@NonNull Class clazz, Class... parameterTypes) { + try { + Constructor constructor = clazz.getConstructor(parameterTypes); + constructor.setAccessible(true); + return constructor; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get constructor for \"" + clazz.getName() + "\" class with parameter types: " + Arrays.toString(parameterTypes), e); + return null; + } + } + + + + /** + * Wrapper for {@link #invokeConstructor(Constructor, Object...)} without arguments. + */ + @Nullable + public static Object invokeConstructor(@NonNull Constructor constructor) { + return invokeConstructor(constructor, new Object[0]); + } + + /** + * Invoke a {@link Constructor} with the specified arguments. + * + * @param constructor The {@link Constructor} to invoke. + * @param args The arguments to pass to the constructor. + * @return Returns the new instance if invoking the constructor was successful, otherwise {@code null}. + */ + @Nullable + public static Object invokeConstructor(@NonNull Constructor constructor, Object... args) { + try { + constructor.setAccessible(true); + return constructor.newInstance(args); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + constructor.getName() + "\" constructor with args: " + Arrays.toString(args), e); + return null; + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java new file mode 100644 index 000000000..03a75e03b --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java @@ -0,0 +1,49 @@ +package com.termux.shared.settings.preferences; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** A class that holds {@link SharedPreferences} objects for apps. */ +public class AppSharedPreferences { + + /** The {@link Context} for operations. */ + protected final Context mContext; + + /** The {@link SharedPreferences} that ideally should be created with {@link SharedPreferenceUtils#getPrivateSharedPreferences(Context, String)}. */ + protected final SharedPreferences mSharedPreferences; + + /** The {@link SharedPreferences}that ideally should be created with {@link SharedPreferenceUtils#getPrivateAndMultiProcessSharedPreferences(Context, String)}. */ + protected final SharedPreferences mMultiProcessSharedPreferences; + + protected AppSharedPreferences(@NonNull Context context, @Nullable SharedPreferences sharedPreferences) { + this(context, sharedPreferences, null); + } + + protected AppSharedPreferences(@NonNull Context context, @Nullable SharedPreferences sharedPreferences, + @Nullable SharedPreferences multiProcessSharedPreferences) { + mContext = context; + mSharedPreferences = sharedPreferences; + mMultiProcessSharedPreferences = multiProcessSharedPreferences; + } + + + + /** Get {@link #mContext}. */ + public Context getContext() { + return mContext; + } + + /** Get {@link #mSharedPreferences}. */ + public SharedPreferences getSharedPreferences() { + return mSharedPreferences; + } + + /** Get {@link #mMultiProcessSharedPreferences}. */ + public SharedPreferences getMultiProcessSharedPreferences() { + return mMultiProcessSharedPreferences; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java new file mode 100644 index 000000000..68bd7c3ee --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java @@ -0,0 +1,432 @@ +package com.termux.shared.settings.preferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; + +import com.termux.shared.logger.Logger; + +import java.util.Set; + +public class SharedPreferenceUtils { + + private static final String LOG_TAG = "SharedPreferenceUtils"; + + /** + * Get {@link SharedPreferences} instance of the preferences file 'name' with the operating mode + * {@link Context#MODE_PRIVATE}. This file will be created in the app package's default + * shared preferences directory. + * + * @param context The {@link Context} to get the {@link SharedPreferences} instance. + * @param name The preferences file basename without extension. + * @return The single {@link SharedPreferences} instance that can be used to retrieve and + * modify the preference values. + */ + public static SharedPreferences getPrivateSharedPreferences(Context context, String name) { + return context.getSharedPreferences(name, Context.MODE_PRIVATE); + } + + /** + * Get {@link SharedPreferences} instance of the preferences file 'name' with the operating mode + * {@link Context#MODE_PRIVATE} and {@link Context#MODE_MULTI_PROCESS}. This file will be + * created in the app package's default shared preferences directory. + * + * @param context The {@link Context} to get the {@link SharedPreferences} instance. + * @param name The preferences file basename without extension. + * @return The single {@link SharedPreferences} instance that can be used to retrieve and + * modify the preference values. + */ + public static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context, String name) { + return context.getSharedPreferences(name, Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS); + } + + + + /** + * Get a {@code boolean} from {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code boolean} value stored in {@link SharedPreferences}, otherwise returns + * default if failed to read a valid value, like in case of an exception. + */ + public static boolean getBoolean(SharedPreferences sharedPreferences, String key, boolean def) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting boolean value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + try { + return sharedPreferences.getBoolean(key, def); + } + catch (ClassCastException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting boolean value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e); + return def; + } + } + + /** + * Set a {@code boolean} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setBoolean(SharedPreferences sharedPreferences, String key, boolean value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting boolean value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putBoolean(key, value).commit(); + else + sharedPreferences.edit().putBoolean(key, value).apply(); + + } + + + + /** + * Get a {@code float} from {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code float} value stored in {@link SharedPreferences}, otherwise returns + * default if failed to read a valid value, like in case of an exception. + */ + public static float getFloat(SharedPreferences sharedPreferences, String key, float def) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting float value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + try { + return sharedPreferences.getFloat(key, def); + } + catch (ClassCastException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting float value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e); + return def; + } + } + + /** + * Set a {@code float} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setFloat(SharedPreferences sharedPreferences, String key, float value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting float value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putFloat(key, value).commit(); + else + sharedPreferences.edit().putFloat(key, value).apply(); + } + + + + /** + * Get an {@code int} from {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code int} value stored in {@link SharedPreferences}, otherwise returns + * default if failed to read a valid value, like in case of an exception. + */ + public static int getInt(SharedPreferences sharedPreferences, String key, int def) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting int value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + try { + return sharedPreferences.getInt(key, def); + } + catch (ClassCastException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting int value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e); + return def; + } + } + + /** + * Set an {@code int} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setInt(SharedPreferences sharedPreferences, String key, int value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting int value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putInt(key, value).commit(); + else + sharedPreferences.edit().putInt(key, value).apply(); + } + + /** + * Set an {@code int} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + * @param resetValue The value if not {@code null} that should be set if current or incremented + * value is less than 0. + * @return Returns the {@code int} value stored in {@link SharedPreferences} before increment, + * otherwise returns default if failed to read a valid value, like in case of an exception. + */ + @SuppressLint("ApplySharedPref") + public static int getAndIncrementInt(SharedPreferences sharedPreferences, String key, int def, + boolean commitToFile, Integer resetValue) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring incrementing int value for the \"" + key + "\" key into null shared preferences."); + return def; + } + + int curValue = getInt(sharedPreferences, key, def); + if (resetValue != null && (curValue < 0)) curValue = resetValue; + + int newValue = curValue + 1; + if (resetValue != null && newValue < 0) newValue = resetValue; + + setInt(sharedPreferences, key, newValue, commitToFile); + return curValue; + } + + + + /** + * Get a {@code long} from {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code long} value stored in {@link SharedPreferences}, otherwise returns + * default if failed to read a valid value, like in case of an exception. + */ + public static long getLong(SharedPreferences sharedPreferences, String key, long def) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting long value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + try { + return sharedPreferences.getLong(key, def); + } + catch (ClassCastException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting long value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e); + return def; + } + } + + /** + * Set a {@code long} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setLong(SharedPreferences sharedPreferences, String key, long value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting long value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putLong(key, value).commit(); + else + sharedPreferences.edit().putLong(key, value).apply(); + } + + + + /** + * Get a {@code String} from {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @param defIfEmpty If set to {@code true}, then {@code def} will be returned if value is empty. + * @return Returns the {@code String} value stored in {@link SharedPreferences}, otherwise returns + * default if failed to read a valid value, like in case of an exception. + */ + public static String getString(SharedPreferences sharedPreferences, String key, String def, boolean defIfEmpty) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting String value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + try { + String value = sharedPreferences.getString(key, def); + if (defIfEmpty && (value == null || value.isEmpty())) + return def; + else + return value; + } + catch (ClassCastException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting String value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e); + return def; + } + } + + /** + * Set a {@code String} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setString(SharedPreferences sharedPreferences, String key, String value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting String value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putString(key, value).commit(); + else + sharedPreferences.edit().putString(key, value).apply(); + } + + + + /** + * Get a {@code Set} from {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code Set} value stored in {@link SharedPreferences}, otherwise returns + * default if failed to read a valid value, like in case of an exception. + */ + public static Set getStringSet(SharedPreferences sharedPreferences, String key, Set def) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting Set value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + try { + return sharedPreferences.getStringSet(key, def); + } + catch (ClassCastException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting Set value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e); + return def; + } + } + + /** + * Set a {@code Set} in {@link SharedPreferences}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setStringSet(SharedPreferences sharedPreferences, String key, Set value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting Set value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putStringSet(key, value).commit(); + else + sharedPreferences.edit().putStringSet(key, value).apply(); + } + + + + /** + * Get an {@code int} from {@link SharedPreferences} that is stored as a {@link String}. + * + * @param sharedPreferences The {@link SharedPreferences} to get the value from. + * @param key The key for the value. + * @param def The default value if failed to read a valid value. + * @return Returns the {@code int} value after parsing the {@link String} value stored in + * {@link SharedPreferences}, otherwise returns default if failed to read a valid value, + * like in case of an exception. + */ + public static int getIntStoredAsString(SharedPreferences sharedPreferences, String key, int def) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Error getting int value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\"."); + return def; + } + + String stringValue; + int intValue; + + try { + stringValue = sharedPreferences.getString(key, Integer.toString(def)); + if (stringValue != null) + intValue = Integer.parseInt(stringValue); + else + intValue = def; + } catch (NumberFormatException | ClassCastException e) { + intValue = def; + } + + return intValue; + } + + /** + * Set an {@code int} into {@link SharedPreferences} that is stored as a {@link String}. + * + * @param sharedPreferences The {@link SharedPreferences} to set the value in. + * @param key The key for the value. + * @param value The value to store. + * @param commitToFile If set to {@code true}, then value will be set to shared preferences + * in-memory cache and the file synchronously. Ideally, only to be used for + * multi-process use-cases. + */ + @SuppressLint("ApplySharedPref") + public static void setIntStoredAsString(SharedPreferences sharedPreferences, String key, int value, boolean commitToFile) { + if (sharedPreferences == null) { + Logger.logError(LOG_TAG, "Ignoring setting int value \"" + value + "\" for the \"" + key + "\" key into null shared preferences."); + return; + } + + if (commitToFile) + sharedPreferences.edit().putString(key, Integer.toString(value)).commit(); + else + sharedPreferences.edit().putString(key, Integer.toString(value)).apply(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java new file mode 100644 index 000000000..d2efd0b4e --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java @@ -0,0 +1,645 @@ +package com.termux.shared.settings.properties; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.collect.BiMap; +import com.google.common.collect.ImmutableBiMap; +import com.google.common.primitives.Primitives; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.logger.Logger; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +/** + * An implementation similar to android's {@link android.content.SharedPreferences} interface for + * reading and writing to and from ".properties" files which also maintains an in-memory cache for + * the key/value pairs when an instance object is used. Operations are done under + * synchronization locks and should be thread safe. + * + * If {@link SharedProperties} instance object is used, then two types of in-memory cache maps are + * maintained, one for the literal {@link String} values found in the file for the keys and an + * additional one that stores (near) primitive {@link Object} values for internal use by the caller. + * + * The {@link SharedProperties} also provides static functions that can be used to read properties + * from files or individual key values or even their internal values. An automatic mapping to a + * boolean as internal value can also be done. An in-memory cache is not maintained, nor are locks used. + * + * This currently only has read support, write support can/will be added later if needed. Check android's + * SharedPreferencesImpl class for reference implementation. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/core/java/android/app/SharedPreferencesImpl.java + */ +public class SharedProperties { + + /** + * The {@link Properties} object that maintains an in-memory cache of values loaded from the + * {@link #mPropertiesFile} file. The key/value pairs are of any keys that are found in the file + * against their literal values in the file. + */ + private Properties mProperties; + + /** + * The {@link HashMap<>} object that maintains an in-memory cache of internal values for the values + * loaded from the {@link #mPropertiesFile} file. The key/value pairs are of any keys defined by + * {@link #mPropertiesList} that are found in the file against their internal {@link Object} values + * returned by the call to + * {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context, String, String)} interface. + */ + private Map mMap; + + private final Context mContext; + private final File mPropertiesFile; + private final Set mPropertiesList; + private final SharedPropertiesParser mSharedPropertiesParser; + + private final Object mLock = new Object(); + + /** Defines the bidirectional map for boolean values and their internal values */ + public static final ImmutableBiMap MAP_GENERIC_BOOLEAN = + new ImmutableBiMap.Builder() + .put("true", true) + .put("false", false) + .build(); + + /** Defines the bidirectional map for inverted boolean values and their internal values */ + public static final ImmutableBiMap MAP_GENERIC_INVERTED_BOOLEAN = + new ImmutableBiMap.Builder() + .put("true", false) + .put("false", true) + .build(); + + private static final String LOG_TAG = "SharedProperties"; + + /** + * Constructor for the SharedProperties class. + * + * @param context The Context for operations. + * @param propertiesFile The {@link File} object to load properties from. + * @param propertiesList The {@link Set} object that defined which properties to load. + * If this is set to {@code null}, then all properties that exist in + * {@code propertiesFile} will be read by {@link #loadPropertiesFromDisk()} + * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface. + */ + public SharedProperties(@NonNull Context context, @Nullable File propertiesFile, Set propertiesList, @NonNull SharedPropertiesParser sharedPropertiesParser) { + mContext = context.getApplicationContext(); + mPropertiesFile = propertiesFile; + mPropertiesList = propertiesList; + mSharedPropertiesParser = sharedPropertiesParser; + + mProperties = new Properties(); + mMap = new HashMap<>(); + } + + /** + * Load the properties defined by {@link #mPropertiesList} or all properties if its {@code null} + * from the {@link #mPropertiesFile} file to update the {@link #mProperties} and {@link #mMap} + * in-memory cache. + * Properties are not loading automatically when constructor is called and must be manually called. + */ + public void loadPropertiesFromDisk() { + synchronized (mLock) { + // Get properties from mPropertiesFile + Properties properties = getProperties(false); + + // We still need to load default values into mMap, so we assume no properties defined if + // reading from mPropertiesFile failed + if (properties == null) + properties = new Properties(); + + HashMap map = new HashMap<>(); + Properties newProperties = new Properties(); + + Set propertiesList = mPropertiesList; + if (propertiesList == null) + propertiesList = properties.stringPropertyNames(); + + String value; + Object internalValue; + for (String key : propertiesList) { + value = properties.getProperty(key); // value will be null if key does not exist in propertiesFile + // Logger.logVerbose(LOG_TAG, key + " : " + value); + + // Call the {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)} + // interface method to get the internal value to store in the {@link #mMap}. + internalValue = mSharedPropertiesParser.getInternalPropertyValueFromValue(mContext, key, value); + + // If the internal value was successfully added to map, then also add value to newProperties + // We only store values in-memory defined by propertiesList + if (putToMap(map, key, internalValue)) { // null internalValue will be put into map + putToProperties(newProperties, key, value); // null value will **not** be into properties + } + } + + mMap = map; + mProperties = newProperties; + } + } + + /** + * Get the {@link Properties} object for the {@link #mPropertiesFile}. The {@link Properties} + * object will also contain properties not defined by the {@link #mPropertiesList} if cache + * value is {@code false}. + * + * @param cached If {@code true}, then the {@link #mProperties} in-memory cache is returned. Otherwise + * the {@link Properties} object is directly read from the {@link #mPropertiesFile}. + * @return Returns the {@link Properties} object if read from file, otherwise a copy of {@link #mProperties}. + */ + public Properties getProperties(boolean cached) { + synchronized (mLock) { + if (cached) { + if (mProperties == null) mProperties = new Properties(); + return getPropertiesCopy(mProperties); + } else { + return getPropertiesFromFile(mContext, mPropertiesFile, mSharedPropertiesParser); + } + } + } + + /** + * Get the {@link String} value for the key passed from the {@link #mPropertiesFile}. + * + * @param key The key to read from the {@link Properties} object. + * @param cached If {@code true}, then the value is returned from the {@link #mProperties} in-memory cache. + * Otherwise the {@link Properties} object is read directly from the {@link #mPropertiesFile} + * and value is returned from it against the key. + * @return Returns the {@link String} object. This will be {@code null} if key is not found. + */ + public String getProperty(String key, boolean cached) { + synchronized (mLock) { + return (String) getProperties(cached).get(key); + } + } + + /** + * Get the {@link #mMap} object for the {@link #mPropertiesFile}. A call to + * {@link #loadPropertiesFromDisk()} must be made before this. + * + * @return Returns a copy of {@link #mMap} object. + */ + public Map getInternalProperties() { + synchronized (mLock) { + if (mMap == null) mMap = new HashMap<>(); + return getMapCopy(mMap); + } + } + + /** + * Get the internal {@link Object} value for the key passed from the {@link #mPropertiesFile}. + * The value is returned from the {@link #mMap} in-memory cache, so a call to + * {@link #loadPropertiesFromDisk()} must be made before this. + * + * @param key The key to read from the {@link #mMap} object. + * @return Returns the {@link Object} object. This will be {@code null} if key is not found or + * if object was {@code null}. Use {@link HashMap#containsKey(Object)} to detect the later. + * situation. + */ + public Object getInternalProperty(String key) { + synchronized (mLock) { + // null keys are not allowed to be stored in mMap + if (key != null) + return getInternalProperties().get(key); + else + return null; + } + } + + + + + + /** + * A static function to get the {@link Properties} object for the propertiesFile. A lock is not + * taken when this function is called. + * + * @param context The {@link Context} to use to show a flash if an exception is raised while + * reading the file. If context is {@code null}, then flash will not be shown. + * @param propertiesFile The {@link File} to read the {@link Properties} from. + * @return Returns the {@link Properties} object. It will be {@code null} if an exception is + * raised while reading the file. + */ + public static Properties getPropertiesFromFile(Context context, File propertiesFile, @Nullable SharedPropertiesParser sharedPropertiesParser) { + Properties properties = new Properties(); + + if (propertiesFile == null) { + Logger.logWarn(LOG_TAG, "Not loading properties since file is null"); + return properties; + } + + try { + try (FileInputStream in = new FileInputStream(propertiesFile)) { + Logger.logVerbose(LOG_TAG, "Loading properties from \"" + propertiesFile.getAbsolutePath() + "\" file"); + properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + } catch (Exception e) { + if (context != null) + Toast.makeText(context, "Could not open properties file \"" + propertiesFile.getAbsolutePath() + "\": " + e.getMessage(), Toast.LENGTH_LONG).show(); + Logger.logStackTraceWithMessage(LOG_TAG, "Error loading properties file \"" + propertiesFile.getAbsolutePath() + "\"", e); + return null; + } + + if (sharedPropertiesParser != null && context != null) + return sharedPropertiesParser.preProcessPropertiesOnReadFromDisk(context, properties); + else + return properties; + } + + /** Returns the first {@link File} found in + * {@code propertiesFilePaths} from which app properties can be loaded. If the {@link File} found + * is not a regular file or is not readable, then {@code null} is returned. Symlinks **will not** + * be followed for potential security reasons. + * + * @param propertiesFilePaths The {@link List} containing properties file paths. + * @param logTag If log tag to use for logging errors. + * @return Returns the {@link File} object for Termux:Float app properties. + */ + public static File getPropertiesFileFromList(List propertiesFilePaths, @NonNull String logTag) { + if (propertiesFilePaths == null || propertiesFilePaths.size() == 0) + return null; + + for(String propertiesFilePath : propertiesFilePaths) { + File propertiesFile = new File(propertiesFilePath); + + // Symlinks **will not** be followed. + FileType fileType = FileUtils.getFileType(propertiesFilePath, false); + if (fileType == FileType.REGULAR) { + if (propertiesFile.canRead()) + return propertiesFile; + else + Logger.logWarn(logTag, "Ignoring properties file at \"" + propertiesFilePath + "\" since it is not readable"); + } else if (fileType != FileType.NO_EXIST) { + Logger.logWarn(logTag, "Ignoring properties file at \"" + propertiesFilePath + "\" of type: \"" + fileType.getName() + "\""); + } + } + + Logger.logDebug(logTag, "No readable properties file found at: " + propertiesFilePaths); + return null; + } + + + + public static String getProperty(Context context, File propertiesFile, String key, String def) { + return getProperty(context, propertiesFile, key, def, null); + } + + /** + * A static function to get the {@link String} value for the {@link Properties} key read from + * the propertiesFile file. + * + * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call. + * @param propertiesFile The {@link File} to read the {@link Properties} from. + * @param key The key to read. + * @param def The default value. + * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface. + * @return Returns the {@link String} object. This will be {@code null} if key is not found. + */ + public static String getProperty(Context context, File propertiesFile, String key, String def, @Nullable SharedPropertiesParser sharedPropertiesParser) { + return (String) getDefaultIfNull(getDefaultIfNull(getPropertiesFromFile(context, propertiesFile, sharedPropertiesParser), new Properties()).get(key), def); + } + + /** + * A static function to get the internal {@link Object} value for the {@link String} value for + * the {@link Properties} key read from the propertiesFile file. + * + * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call. + * @param propertiesFile The {@link File} to read the {@link Properties} from. + * @param key The key to read. + * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface. + * @return Returns the {@link String} Object returned by the call to + * {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)}. + */ + public static Object getInternalProperty(Context context, File propertiesFile, String key, @NonNull SharedPropertiesParser sharedPropertiesParser) { + String value = (String) getDefaultIfNull(getPropertiesFromFile(context, propertiesFile, sharedPropertiesParser), new Properties()).get(key); + + // Call the {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)} + // interface method to get the internal value to return. + return sharedPropertiesParser.getInternalPropertyValueFromValue(context, key, value); + } + + + public static boolean isPropertyValueTrue(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue) { + return isPropertyValueTrue(context, propertiesFile, key, logErrorOnInvalidValue, null); + } + + /** + * A static function to check if the value is {@code true} for {@link Properties} key read from + * the propertiesFile file. + * + * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call. + * @param propertiesFile The {@link File} to read the {@link Properties} from. + * @param key The key to read. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value + * was found in {@link Properties} but was invalid. + * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface. + * @return Returns the {@code true} if the {@link Properties} key {@link String} value equals "true", + * regardless of case. If the key does not exist in the file or does not equal "true", then + * {@code false} will be returned. + */ + public static boolean isPropertyValueTrue(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue, @Nullable SharedPropertiesParser sharedPropertiesParser) { + return (boolean) getBooleanValueForStringValue(key, (String) getProperty(context, propertiesFile, key, null, sharedPropertiesParser), false, logErrorOnInvalidValue, LOG_TAG); + } + + + public static boolean isPropertyValueFalse(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue) { + return isPropertyValueFalse(context, propertiesFile, key, logErrorOnInvalidValue, null); + } + + /** + * A static function to check if the value is {@code false} for {@link Properties} key read from + * the propertiesFile file. + * + * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call. + * @param propertiesFile The {@link File} to read the {@link Properties} from. + * @param key The key to read. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value + * was found in {@link Properties} but was invalid. + * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface. + * @return Returns the {@code true} if the {@link Properties} key {@link String} value equals "false", + * regardless of case. If the key does not exist in the file or does not equal "false", then + * {@code true} will be returned. + */ + public static boolean isPropertyValueFalse(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue, @Nullable SharedPropertiesParser sharedPropertiesParser) { + return (boolean) getInvertedBooleanValueForStringValue(key, (String) getProperty(context, propertiesFile, key, null, sharedPropertiesParser), true, logErrorOnInvalidValue, LOG_TAG); + } + + + + + + /** + * Put a value in a {@link #mMap}. + * The key cannot be {@code null}. + * Only {@code null}, primitive or their wrapper classes or String class objects are allowed to be added to + * the map, although this limitation may be changed. + * + * @param map The {@link Map} object to add value to. + * @param key The key for which to add the value to the map. + * @param value The {@link Object} to add to the map. + * @return Returns {@code true} if value was successfully added, otherwise {@code false}. + */ + public static boolean putToMap(HashMap map, String key, Object value) { + + if (map == null) { + Logger.logError(LOG_TAG, "Map passed to SharedProperties.putToProperties() is null"); + return false; + } + + // null keys are not allowed to be stored in mMap + if (key == null) { + Logger.logError(LOG_TAG, "Cannot put a null key into properties map"); + return false; + } + + boolean put = false; + if (value != null) { + Class clazz = value.getClass(); + if (clazz.isPrimitive() || Primitives.isWrapperType(clazz) || value instanceof String) { + put = true; + } + } else { + put = true; + } + + if (put) { + map.put(key, value); + return true; + } else { + Logger.logError(LOG_TAG, "Cannot put a non-primitive value for the key \"" + key + "\" into properties map"); + return false; + } + } + + /** + * Put a value in a {@link Map}. + * The key cannot be {@code null}. + * Passing {@code null} as the value argument is equivalent to removing the key from the + * properties. + * + * @param properties The {@link Properties} object to add value to. + * @param key The key for which to add the value to the properties. + * @param value The {@link String} to add to the properties. + * @return Returns {@code true} if value was successfully added, otherwise {@code false}. + */ + public static boolean putToProperties(Properties properties, String key, String value) { + + if (properties == null) { + Logger.logError(LOG_TAG, "Properties passed to SharedProperties.putToProperties() is null"); + return false; + } + + // null keys are not allowed to be stored in mMap + if (key == null) { + Logger.logError(LOG_TAG, "Cannot put a null key into properties"); + return false; + } + + if (value != null) { + properties.put(key, value); + return true; + } else { + properties.remove(key); + } + + return true; + } + + public static Properties getPropertiesCopy(Properties inputProperties) { + if (inputProperties == null) return null; + + Properties outputProperties = new Properties(); + for (String key : inputProperties.stringPropertyNames()) { + outputProperties.put(key, inputProperties.get(key)); + } + + return outputProperties; + } + + public static Map getMapCopy(Map map) { + if (map == null) return null; + return new HashMap<>(map); + } + + + + + + + /** + * Get the boolean value for the {@link String} value. + * + * @param value The {@link String} value to convert. + * @return Returns {@code true} or {@code false} if value is the literal string "true" or "false" respectively, + * regardless of case. Otherwise returns {@code null}. + */ + public static Boolean getBooleanValueForStringValue(String value) { + return MAP_GENERIC_BOOLEAN.get(toLowerCase(value)); + } + + /** + * Get the boolean value for the {@link String} value. + * + * @param value The {@link String} value to convert. + * @param def The default {@link boolean} value to return. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value} + * was not {@code null} and was invalid. + * @param logTag If log tag to use for logging errors. + * @return Returns {@code true} or {@code false} if value is the literal string "true" or "false" respectively, + * regardless of case. Otherwise returns default value. + */ + public static boolean getBooleanValueForStringValue(String key, String value, boolean def, boolean logErrorOnInvalidValue, String logTag) { + return (boolean) getDefaultIfNotInMap(key, MAP_GENERIC_BOOLEAN, toLowerCase(value), def, logErrorOnInvalidValue, logTag); + } + + /** + * Get the inverted boolean value for the {@link String} value. + * + * @param value The {@link String} value to convert. + * @param def The default {@link boolean} value to return. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value} + * was not {@code null} and was invalid. + * @param logTag If log tag to use for logging errors. + * @return Returns {@code true} or {@code false} if value is the literal string "false" or "true" respectively, + * regardless of case. Otherwise returns default value. + */ + public static boolean getInvertedBooleanValueForStringValue(String key, String value, boolean def, boolean logErrorOnInvalidValue, String logTag) { + return (boolean) getDefaultIfNotInMap(key, MAP_GENERIC_INVERTED_BOOLEAN, toLowerCase(value), def, logErrorOnInvalidValue, logTag); + } + + /** + * Get the value for the {@code inputValue} {@link Object} key from a {@link BiMap<>}, otherwise + * default value if key not found in {@code map}. + * + * @param key The shared properties {@link String} key value for which the value is being returned. + * @param map The {@link BiMap<>} value to get the value from. + * @param inputValue The {@link Object} key value of the map. + * @param defaultOutputValue The default {@link boolean} value to return if {@code inputValue} not found in map. + * The default value must exist as a value in the {@link BiMap<>} passed. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code inputValue} + * was not {@code null} and was not found in the map. + * @param logTag If log tag to use for logging errors. + * @return Returns the value for the {@code inputValue} key from the map if it exists. Otherwise + * returns default value. + */ + public static Object getDefaultIfNotInMap(String key, @NonNull BiMap map, Object inputValue, Object defaultOutputValue, boolean logErrorOnInvalidValue, String logTag) { + Object outputValue = map.get(inputValue); + if (outputValue == null) { + Object defaultInputValue = map.inverse().get(defaultOutputValue); + if (defaultInputValue == null) + Logger.logError(LOG_TAG, "The default output value \"" + defaultOutputValue + "\" for the key \"" + key + "\" does not exist as a value in the BiMap passed to getDefaultIfNotInMap(): " + map.values()); + + if (logErrorOnInvalidValue && inputValue != null) { + if (key != null) + Logger.logError(logTag, "The value \"" + inputValue + "\" for the key \"" + key + "\" is invalid. Using default value \"" + defaultInputValue + "\" instead."); + else + Logger.logError(logTag, "The value \"" + inputValue + "\" is invalid. Using default value \"" + defaultInputValue + "\" instead."); + } + + return defaultOutputValue; + } else { + return outputValue; + } + } + + /** + * Get the {@code int} {@code value} as is if between {@code min} and {@code max} (inclusive), otherwise + * return default value. + * + * @param key The shared properties {@link String} key value for which the value is being returned. + * @param value The {@code int} value to check. + * @param def The default {@code int} value if {@code value} not in range. + * @param min The min allowed {@code int} value. + * @param max The max allowed {@code int} value. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value} + * not in range. + * @param ignoreErrorIfValueZero If logging error should be ignored if value equals 0. + * @param logTag If log tag to use for logging errors. + * @return Returns the {@code value} as is if within range. Otherwise returns default value. + */ + public static int getDefaultIfNotInRange(String key, int value, int def, int min, int max, boolean logErrorOnInvalidValue, boolean ignoreErrorIfValueZero, String logTag) { + if (value < min || value > max) { + if (logErrorOnInvalidValue && (!ignoreErrorIfValueZero || value != 0)) { + if (key != null) + Logger.logError(logTag, "The value \"" + value + "\" for the key \"" + key + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead."); + else + Logger.logError(logTag, "The value \"" + value + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead."); + } + return def; + } else { + return value; + } + } + + /** + * Get the {@code float} {@code value} as is if between {@code min} and {@code max} (inclusive), otherwise + * return default value. + * + * @param key The shared properties {@link String} key value for which the value is being returned. + * @param value The {@code float} value to check. + * @param def The default {@code float} value if {@code value} not in range. + * @param min The min allowed {@code float} value. + * @param max The max allowed {@code float} value. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value} + * not in range. + * @param ignoreErrorIfValueZero If logging error should be ignored if value equals 0. + * @param logTag If log tag to use for logging errors. + * @return Returns the {@code value} as is if within range. Otherwise returns default value. + */ + public static float getDefaultIfNotInRange(String key, float value, float def, float min, float max, boolean logErrorOnInvalidValue, boolean ignoreErrorIfValueZero, String logTag) { + if (value < min || value > max) { + if (logErrorOnInvalidValue && (!ignoreErrorIfValueZero || value != 0)) { + if (key != null) + Logger.logError(logTag, "The value \"" + value + "\" for the key \"" + key + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead."); + else + Logger.logError(logTag, "The value \"" + value + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead."); + } + return def; + } else { + return value; + } + } + + /** + * Get the object itself if it is not {@code null}, otherwise default. + * + * @param object The {@link Object} to check. + * @param def The default {@link Object}. + * @return Returns {@code object} if it is not {@code null}, otherwise returns {@code def}. + */ + public static T getDefaultIfNull(@Nullable T object, @Nullable T def) { + return (object == null) ? def : object; + } + + /** + * Get the {@link String} object itself if it is not {@code null} or empty, otherwise default. + * + * @param object The {@link String} to check. + * @param def The default {@link String}. + * @return Returns {@code object} if it is not {@code null}, otherwise returns {@code def}. + */ + public static String getDefaultIfNullOrEmpty(@Nullable String object, @Nullable String def) { + return (object == null || object.isEmpty()) ? def : object; + } + + /** + * Covert the {@link String} value to lowercase. + * + * @param value The {@link String} value to convert. + * @return Returns the lowercased value. + */ + public static String toLowerCase(String value) { + if (value == null) return null; else return value.toLowerCase(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java new file mode 100644 index 000000000..1e6c67f22 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java @@ -0,0 +1,37 @@ +package com.termux.shared.settings.properties; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Properties; + +/** + * An interface that must be defined by the caller of the {@link SharedProperties} class. + */ +public interface SharedPropertiesParser { + + /** + * Called when properties are loaded from file to allow client to update the {@link Properties} + * loaded from properties file before key/value pairs are stored in the {@link HashMap <>} in-memory + * cache. + * + * @param context The context for operations. + * @param properties The key for which the internal object is required. + */ + @NonNull + Properties preProcessPropertiesOnReadFromDisk(@NonNull Context context, @NonNull Properties properties); + + /** + * A function that should return the internal {@link Object} to be stored for a key/value pair + * read from properties file in the {@link HashMap <>} in-memory cache. + * + * @param context The context for operations. + * @param key The key for which the internal object is required. + * @param value The literal value for the property found is the properties file. + * @return Returns the {@link Object} object to store in the {@link HashMap <>} in-memory cache. + */ + Object getInternalPropertyValueFromValue(@NonNull Context context, String key, String value); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java new file mode 100644 index 000000000..26af2e832 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java @@ -0,0 +1,229 @@ +/*BEGIN_COPYRIGHT_BLOCK + * + * Copyright (c) 2001-2010, JavaPLT group at Rice University (drjava@rice.edu) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the names of DrJava, the JavaPLT group, Rice University, nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * This software is Open Source Initiative approved Open Source Software. + * Open Source Initative Approved is a trademark of the Open Source Initiative. + * + * This file is part of DrJava. Download the current version of this project + * from http://www.drjava.org/ or http://sourceforge.net/projects/drjava/ + * + * END_COPYRIGHT_BLOCK*/ + +package com.termux.shared.shell; + +import java.util.List; +import java.util.LinkedList; + +/** + * Utility class which can tokenize a String into a list of String arguments, + * with behavior similar to parsing command line arguments to a program. + * Quoted Strings are treated as single arguments, and escaped characters + * are translated so that the tokenized arguments have the same meaning. + * Since all methods are static, the class is declared abstract to prevent + * instantiation. + * @version $Id$ + */ +public abstract class ArgumentTokenizer { + private static final int NO_TOKEN_STATE = 0; + private static final int NORMAL_TOKEN_STATE = 1; + private static final int SINGLE_QUOTE_STATE = 2; + private static final int DOUBLE_QUOTE_STATE = 3; + + /** Tokenizes the given String into String tokens + * @param arguments A String containing one or more command-line style arguments to be tokenized. + * @return A list of parsed and properly escaped arguments. + */ + public static List tokenize(String arguments) { + return tokenize(arguments, false); + } + + /** Tokenizes the given String into String tokens. + * @param arguments A String containing one or more command-line style arguments to be tokenized. + * @param stringify whether or not to include escape special characters + * @return A list of parsed and properly escaped arguments. + */ + public static List tokenize(String arguments, boolean stringify) { + + LinkedList argList = new LinkedList(); + StringBuilder currArg = new StringBuilder(); + boolean escaped = false; + int state = NO_TOKEN_STATE; // start in the NO_TOKEN_STATE + int len = arguments.length(); + + // Loop over each character in the string + for (int i = 0; i < len; i++) { + char c = arguments.charAt(i); + if (escaped) { + // Escaped state: just append the next character to the current arg. + escaped = false; + currArg.append(c); + } + else { + switch(state) { + case SINGLE_QUOTE_STATE: + if (c == '\'') { + // Seen the close quote; continue this arg until whitespace is seen + state = NORMAL_TOKEN_STATE; + } + else { + currArg.append(c); + } + break; + case DOUBLE_QUOTE_STATE: + if (c == '"') { + // Seen the close quote; continue this arg until whitespace is seen + state = NORMAL_TOKEN_STATE; + } + else if (c == '\\') { + // Look ahead, and only escape quotes or backslashes + i++; + char next = arguments.charAt(i); + if (next == '"' || next == '\\') { + currArg.append(next); + } + else { + currArg.append(c); + currArg.append(next); + } + } + else { + currArg.append(c); + } + break; +// case NORMAL_TOKEN_STATE: +// if (Character.isWhitespace(c)) { +// // Whitespace ends the token; start a new one +// argList.add(currArg.toString()); +// currArg = new StringBuffer(); +// state = NO_TOKEN_STATE; +// } +// else if (c == '\\') { +// // Backslash in a normal token: escape the next character +// escaped = true; +// } +// else if (c == '\'') { +// state = SINGLE_QUOTE_STATE; +// } +// else if (c == '"') { +// state = DOUBLE_QUOTE_STATE; +// } +// else { +// currArg.append(c); +// } +// break; + case NO_TOKEN_STATE: + case NORMAL_TOKEN_STATE: + switch(c) { + case '\\': + escaped = true; + state = NORMAL_TOKEN_STATE; + break; + case '\'': + state = SINGLE_QUOTE_STATE; + break; + case '"': + state = DOUBLE_QUOTE_STATE; + break; + default: + if (!Character.isWhitespace(c)) { + currArg.append(c); + state = NORMAL_TOKEN_STATE; + } + else if (state == NORMAL_TOKEN_STATE) { + // Whitespace ends the token; start a new one + argList.add(currArg.toString()); + currArg = new StringBuilder(); + state = NO_TOKEN_STATE; + } + } + break; + default: + throw new IllegalStateException("ArgumentTokenizer state " + state + " is invalid!"); + } + } + } + + // If we're still escaped, put in the backslash + if (escaped) { + currArg.append('\\'); + argList.add(currArg.toString()); + } + // Close the last argument if we haven't yet + else if (state != NO_TOKEN_STATE) { + argList.add(currArg.toString()); + } + // Format each argument if we've been told to stringify them + if (stringify) { + for (int i = 0; i < argList.size(); i++) { + argList.set(i, "\"" + _escapeQuotesAndBackslashes(argList.get(i)) + "\""); + } + } + return argList; + } + + /** Inserts backslashes before any occurrences of a backslash or + * quote in the given string. Also converts any special characters + * appropriately. + */ + protected static String _escapeQuotesAndBackslashes(String s) { + final StringBuilder buf = new StringBuilder(s); + + // Walk backwards, looking for quotes or backslashes. + // If we see any, insert an extra backslash into the buffer at + // the same index. (By walking backwards, the index into the buffer + // will remain correct as we change the buffer.) + for (int i = s.length()-1; i >= 0; i--) { + char c = s.charAt(i); + if ((c == '\\') || (c == '"')) { + buf.insert(i, '\\'); + } + // Replace any special characters with escaped versions + else if (c == '\n') { + buf.deleteCharAt(i); + buf.insert(i, "\\n"); + } + else if (c == '\t') { + buf.deleteCharAt(i); + buf.insert(i, "\\t"); + } + else if (c == '\r') { + buf.deleteCharAt(i); + buf.insert(i, "\\r"); + } + else if (c == '\b') { + buf.deleteCharAt(i); + buf.insert(i, "\\b"); + } + else if (c == '\f') { + buf.deleteCharAt(i); + buf.insert(i, "\\f"); + } + } + return buf.toString(); + } +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java new file mode 100644 index 000000000..5c3ad5760 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java @@ -0,0 +1,76 @@ +package com.termux.shared.shell; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.file.FileUtils; +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; + +import java.lang.reflect.Field; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ShellUtils { + + /** Get process id of {@link Process}. */ + public static int getPid(Process p) { + try { + Field f = p.getClass().getDeclaredField("pid"); + f.setAccessible(true); + try { + return f.getInt(p); + } finally { + f.setAccessible(false); + } + } catch (Throwable e) { + return -1; + } + } + + /** Setup shell command arguments for the execute. */ + @NonNull + public static String[] setupShellCommandArguments(@NonNull String executable, @Nullable String[] arguments) { + List result = new ArrayList<>(); + result.add(executable); + if (arguments != null) Collections.addAll(result, arguments); + return result.toArray(new String[0]); + } + + /** Get basename for executable. */ + @Nullable + public static String getExecutableBasename(@Nullable String executable) { + return FileUtils.getFileBasename(executable); + } + + + + /** Get transcript for {@link TerminalSession}. */ + public static String getTerminalSessionTranscriptText(TerminalSession terminalSession, boolean linesJoined, boolean trim) { + if (terminalSession == null) return null; + + TerminalEmulator terminalEmulator = terminalSession.getEmulator(); + if (terminalEmulator == null) return null; + + TerminalBuffer terminalBuffer = terminalEmulator.getScreen(); + if (terminalBuffer == null) return null; + + String transcriptText; + + if (linesJoined) + transcriptText = terminalBuffer.getTranscriptTextWithFullLinesJoined(); + else + transcriptText = terminalBuffer.getTranscriptTextWithoutJoinedLines(); + + if (transcriptText == null) return null; + + if (trim) + transcriptText = transcriptText.trim(); + + return transcriptText; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java new file mode 100644 index 000000000..04a5109da --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.termux.shared.shell; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.List; +import java.util.Locale; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.termux.shared.logger.Logger; + +/** + * Thread utility class continuously reading from an InputStream + * + * https://github.com/Chainfire/libsuperuser/blob/1.1.0.201907261845/libsuperuser/src/eu/chainfire/libsuperuser/Shell.java#L141 + * https://github.com/Chainfire/libsuperuser/blob/1.1.0.201907261845/libsuperuser/src/eu/chainfire/libsuperuser/StreamGobbler.java + */ +@SuppressWarnings({"WeakerAccess"}) +public class StreamGobbler extends Thread { + private static int threadCounter = 0; + private static int incThreadCounter() { + synchronized (StreamGobbler.class) { + int ret = threadCounter; + threadCounter++; + return ret; + } + } + + /** + * Line callback interface + */ + public interface OnLineListener { + /** + *

Line callback

+ * + *

This callback should process the line as quickly as possible. + * Delays in this callback may pause the native process or even + * result in a deadlock

+ * + * @param line String that was gobbled + */ + void onLine(String line); + } + + /** + * Stream closed callback interface + */ + public interface OnStreamClosedListener { + /** + *

Stream closed callback

+ */ + void onStreamClosed(); + } + + @NonNull + private final String shell; + @NonNull + private final InputStream inputStream; + @NonNull + private final BufferedReader reader; + @Nullable + private final List listWriter; + @Nullable + private final StringBuilder stringWriter; + @Nullable + private final OnLineListener lineListener; + @Nullable + private final OnStreamClosedListener streamClosedListener; + @Nullable + private final Integer mLogLevel; + private volatile boolean active = true; + private volatile boolean calledOnClose = false; + + private static final String LOG_TAG = "StreamGobbler"; + + /** + *

StreamGobbler constructor

+ * + *

We use this class because shell STDOUT and STDERR should be read as quickly as + * possible to prevent a deadlock from occurring, or Process.waitFor() never + * returning (as the buffer is full, pausing the native process)

+ * + * @param shell Name of the shell + * @param inputStream InputStream to read from + * @param outputList {@literal List} to write to, or null + * @param logLevel The custom log level to use for logging the command output. If set to + * {@code null}, then {@link Logger#LOG_LEVEL_VERBOSE} will be used. + */ + @AnyThread + public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream, + @Nullable List outputList, + @Nullable Integer logLevel) { + super("Gobbler#" + incThreadCounter()); + this.shell = shell; + this.inputStream = inputStream; + reader = new BufferedReader(new InputStreamReader(inputStream)); + streamClosedListener = null; + + listWriter = outputList; + stringWriter = null; + lineListener = null; + + mLogLevel = logLevel; + } + + /** + *

StreamGobbler constructor

+ * + *

We use this class because shell STDOUT and STDERR should be read as quickly as + * possible to prevent a deadlock from occurring, or Process.waitFor() never + * returning (as the buffer is full, pausing the native process)

+ * Do not use this for concurrent reading for STDOUT and STDERR for the same StringBuilder since + * its not synchronized. + * + * @param shell Name of the shell + * @param inputStream InputStream to read from + * @param outputString {@literal List} to write to, or null + * @param logLevel The custom log level to use for logging the command output. If set to + * {@code null}, then {@link Logger#LOG_LEVEL_VERBOSE} will be used. + */ + @AnyThread + public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream, + @Nullable StringBuilder outputString, + @Nullable Integer logLevel) { + super("Gobbler#" + incThreadCounter()); + this.shell = shell; + this.inputStream = inputStream; + reader = new BufferedReader(new InputStreamReader(inputStream)); + streamClosedListener = null; + + listWriter = null; + stringWriter = outputString; + lineListener = null; + + mLogLevel = logLevel; + } + + /** + *

StreamGobbler constructor

+ * + *

We use this class because shell STDOUT and STDERR should be read as quickly as + * possible to prevent a deadlock from occurring, or Process.waitFor() never + * returning (as the buffer is full, pausing the native process)

+ * + * @param shell Name of the shell + * @param inputStream InputStream to read from + * @param onLineListener OnLineListener callback + * @param onStreamClosedListener OnStreamClosedListener callback + * @param logLevel The custom log level to use for logging the command output. If set to + * {@code null}, then {@link Logger#LOG_LEVEL_VERBOSE} will be used. + */ + @AnyThread + public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream, + @Nullable OnLineListener onLineListener, + @Nullable OnStreamClosedListener onStreamClosedListener, + @Nullable Integer logLevel) { + super("Gobbler#" + incThreadCounter()); + this.shell = shell; + this.inputStream = inputStream; + reader = new BufferedReader(new InputStreamReader(inputStream)); + streamClosedListener = onStreamClosedListener; + + listWriter = null; + stringWriter = null; + lineListener = onLineListener; + + mLogLevel = logLevel; + } + + @Override + public void run() { + String defaultLogTag = Logger.getDefaultLogTag(); + boolean loggingEnabled = Logger.shouldEnableLoggingForCustomLogLevel(mLogLevel); + if (loggingEnabled) + Logger.logVerbose(LOG_TAG, "Using custom log level: " + mLogLevel + ", current log level: " + Logger.getLogLevel()); + + // keep reading the InputStream until it ends (or an error occurs) + // optionally pausing when a command is executed that consumes the InputStream itself + try { + String line; + while ((line = reader.readLine()) != null) { + if (loggingEnabled) + Logger.logVerboseForce(defaultLogTag + "Command", String.format(Locale.ENGLISH, "[%s] %s", shell, line)); // This will get truncated by LOGGER_ENTRY_MAX_LEN, likely 4KB + + if (stringWriter != null) stringWriter.append(line).append("\n"); + if (listWriter != null) listWriter.add(line); + if (lineListener != null) lineListener.onLine(line); + while (!active) { + synchronized (this) { + try { + this.wait(128); + } catch (InterruptedException e) { + // no action + } + } + } + } + } catch (IOException e) { + // reader probably closed, expected exit condition + if (streamClosedListener != null) { + calledOnClose = true; + streamClosedListener.onStreamClosed(); + } + } + + // make sure our stream is closed and resources will be freed + try { + reader.close(); + } catch (IOException e) { + // read already closed + } + + if (!calledOnClose) { + if (streamClosedListener != null) { + calledOnClose = true; + streamClosedListener.onStreamClosed(); + } + } + } + + /** + *

Resume consuming the input from the stream

+ */ + @AnyThread + public void resumeGobbling() { + if (!active) { + synchronized (this) { + active = true; + this.notifyAll(); + } + } + } + + /** + *

Suspend gobbling, so other code may read from the InputStream instead

+ * + *

This should only be called from the OnLineListener callback!

+ */ + @AnyThread + public void suspendGobbling() { + synchronized (this) { + active = false; + this.notifyAll(); + } + } + + /** + *

Wait for gobbling to be suspended

+ * + *

Obviously this cannot be called from the same thread as {@link #suspendGobbling()}

+ */ + @WorkerThread + public void waitForSuspend() { + synchronized (this) { + while (active) { + try { + this.wait(32); + } catch (InterruptedException e) { + // no action + } + } + } + } + + /** + *

Is gobbling suspended ?

+ * + * @return is gobbling suspended? + */ + @AnyThread + public boolean isSuspended() { + synchronized (this) { + return !active; + } + } + + /** + *

Get current source InputStream

+ * + * @return source InputStream + */ + @NonNull + @AnyThread + public InputStream getInputStream() { + return inputStream; + } + + /** + *

Get current OnLineListener

+ * + * @return OnLineListener + */ + @Nullable + @AnyThread + public OnLineListener getOnLineListener() { + return lineListener; + } + + void conditionalJoin() throws InterruptedException { + if (calledOnClose) return; // deadlock from callback, we're inside exit procedure + if (Thread.currentThread() == this) return; // can't join self + join(); + } +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java new file mode 100644 index 000000000..e316ec066 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java @@ -0,0 +1,258 @@ +package com.termux.shared.shell.am; + +import android.Manifest; +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.am.Am; +import com.termux.shared.R; +import com.termux.shared.android.PackageUtils; +import com.termux.shared.android.PermissionUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.logger.Logger; +import com.termux.shared.net.socket.local.ILocalSocketManager; +import com.termux.shared.net.socket.local.LocalClientSocket; +import com.termux.shared.net.socket.local.LocalServerSocket; +import com.termux.shared.net.socket.local.LocalSocketManager; +import com.termux.shared.net.socket.local.LocalSocketManagerClientBase; +import com.termux.shared.net.socket.local.LocalSocketRunConfig; +import com.termux.shared.shell.ArgumentTokenizer; +import com.termux.shared.shell.command.ExecutionCommand; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A AF_UNIX/SOCK_STREAM local server managed with {@link LocalSocketManager} whose + * {@link LocalServerSocket} receives android activity manager (am) commands from {@link LocalClientSocket} + * and runs them with termux-am-library. It would normally only allow processes belonging to the + * server app's user and root user to connect to it. + * + * The client must send the am command as a string without the initial "am" arg on its output stream + * and then wait for the result on its input stream. The result of the execution or error is sent + * back in the format `exit_code\0stdout\0stderr\0` where `\0` represents a null character. + * Check termux/termux-am-socket for implementation of a native c client. + * + * Usage: + * 1. Optionally extend {@link AmSocketServerClient}, the implementation for + * {@link ILocalSocketManager} that will receive call backs from the server including + * when client connects via {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)}. + * 2. Create a {@link AmSocketServerRunConfig} instance which extends from {@link LocalSocketRunConfig} + * with the run config of the am server. It would be better to use a filesystem socket instead + * of abstract namespace socket for security reasons. + * 3. Call {@link #start(Context, LocalSocketRunConfig)} to start the server and store the {@link LocalSocketManager} + * instance returned. + * 4. Stop server if needed with a call to {@link LocalSocketManager#stop()} on the + * {@link LocalSocketManager} instance returned by start call. + * + * https://github.com/termux/termux-am-library/blob/main/termux-am-library/src/main/java/com/termux/am/Am.java + * https://github.com/termux/termux-am-socket + * https://developer.android.com/studio/command-line/adb#am + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/ActivityManagerShellCommand.java + */ +public class AmSocketServer { + + public static final String LOG_TAG = "AmSocketServer"; + + /** + * Create the {@link AmSocketServer} {@link LocalServerSocket} and start listening for new {@link LocalClientSocket}. + * + * @param context The {@link Context} for {@link LocalSocketManager}. + * @param localSocketRunConfig The {@link LocalSocketRunConfig} for {@link LocalSocketManager}. + */ + public static synchronized LocalSocketManager start(@NonNull Context context, + @NonNull LocalSocketRunConfig localSocketRunConfig) { + LocalSocketManager localSocketManager = new LocalSocketManager(context, localSocketRunConfig); + Error error = localSocketManager.start(); + if (error != null) { + localSocketManager.onError(error); + return null; + } + + return localSocketManager; + } + + public static void processAmClient(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket) { + Error error; + + // Read amCommandString client sent and close input stream + StringBuilder data = new StringBuilder(); + error = clientSocket.readDataOnInputStream(data, true); + if (error != null) { + sendResultToClient(localSocketManager, clientSocket, 1, null, error.toString()); + return; + } + + String amCommandString = data.toString(); + + Logger.logVerbose(LOG_TAG, "am command received from peer " + clientSocket.getPeerCred().getMinimalString() + + "\nam command: `" + amCommandString + "`"); + + // Parse am command string and convert it to a list of arguments + List amCommandList = new ArrayList<>(); + error = parseAmCommand(amCommandString, amCommandList); + if (error != null) { + sendResultToClient(localSocketManager, clientSocket, 1, null, error.toString()); + return; + } + + String[] amCommandArray = amCommandList.toArray(new String[0]); + + Logger.logDebug(LOG_TAG, "am command received from peer " + clientSocket.getPeerCred().getMinimalString() + + "\n" + ExecutionCommand.getArgumentsLogString("am command", amCommandArray)); + + AmSocketServerRunConfig amSocketServerRunConfig = (AmSocketServerRunConfig) localSocketManager.getLocalSocketRunConfig(); + + // Run am command and send its result to the client + StringBuilder stdout = new StringBuilder(); + StringBuilder stderr = new StringBuilder(); + error = runAmCommand(localSocketManager.getContext(), amCommandArray, stdout, stderr, + amSocketServerRunConfig.shouldCheckDisplayOverAppsPermission()); + if (error != null) { + sendResultToClient(localSocketManager, clientSocket, 1, stdout.toString(), + !stderr.toString().isEmpty() ? stderr + "\n\n" + error : error.toString()); + } + + sendResultToClient(localSocketManager, clientSocket, 0, stdout.toString(), stderr.toString()); + } + + /** + * Send result to {@link LocalClientSocket} that requested the am command to be run. + * + * @param localSocketManager The {@link LocalSocketManager} instance for the local socket. + * @param clientSocket The {@link LocalClientSocket} to which the result is to be sent. + * @param exitCode The exit code value to send. + * @param stdout The stdout value to send. + * @param stderr The stderr value to send. + */ + public static void sendResultToClient(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket, + int exitCode, + @Nullable String stdout, @Nullable String stderr) { + StringBuilder result = new StringBuilder(); + result.append(sanitizeExitCode(clientSocket, exitCode)); + result.append('\0'); + result.append(stdout != null ? stdout : ""); + result.append('\0'); + result.append(stderr != null ? stderr : ""); + + // Send result to client and close output stream + Error error = clientSocket.sendDataToOutputStream(result.toString(), true); + if (error != null) { + localSocketManager.onError(clientSocket, error); + } + } + + /** + * Sanitize exitCode to between 0-255, otherwise it may be considered invalid. + * Out of bound exit codes would return with exit code `44` `Channel number out of range` in shell. + * + * @param clientSocket The {@link LocalClientSocket} to which the exit code will be sent. + * @param exitCode The current exit code. + * @return Returns the sanitized exit code. + */ + public static int sanitizeExitCode(@NonNull LocalClientSocket clientSocket, int exitCode) { + if (exitCode < 0 || exitCode > 255) { + Logger.logWarn(LOG_TAG, "Ignoring invalid peer " + clientSocket.getPeerCred().getMinimalString() + " result value \"" + exitCode + "\" and force setting it to \"" + 1 + "\""); + exitCode = 1; + } + + return exitCode; + } + + + /** + * Parse amCommandString into a list of arguments like normally done on shells like bourne shell. + * Arguments are split on whitespaces unless quoted with single or double quotes. + * Double quotes and backslashes can be escaped with backslashes in arguments surrounded. + * Double quotes and backslashes can be escaped with backslashes in arguments surrounded with + * double quotes. + * + * @param amCommandString The am command {@link String}. + * @param amCommandList The {@link List} to set list of arguments in. + * @return Returns the {@code error} if parsing am command failed, otherwise {@code null}. + */ + public static Error parseAmCommand(String amCommandString, List amCommandList) { + + if (amCommandString == null || amCommandString.isEmpty()) { + return null; + } + + try { + amCommandList.addAll(ArgumentTokenizer.tokenize(amCommandString)); + } catch (Exception e) { + return AmSocketServerErrno.ERRNO_PARSE_AM_COMMAND_FAILED_WITH_EXCEPTION.getError(e, amCommandString, e.getMessage()); + } + + return null; + } + + /** + * Call termux-am-library to run the am command. + * + * @param context The {@link Context} to run am command with. + * @param amCommandArray The am command array. + * @param stdout The {@link StringBuilder} to set stdout in that is returned by the am command. + * @param stderr The {@link StringBuilder} to set stderr in that is returned by the am command. + * @param checkDisplayOverAppsPermission Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW} + * has been granted if running on Android `>= 10` and + * starting activity or service. + * @return Returns the {@code error} if am command failed, otherwise {@code null}. + */ + public static Error runAmCommand(@NonNull Context context, + String[] amCommandArray, + @NonNull StringBuilder stdout, @NonNull StringBuilder stderr, + boolean checkDisplayOverAppsPermission) { + try (ByteArrayOutputStream stdoutByteStream = new ByteArrayOutputStream(); + PrintStream stdoutPrintStream = new PrintStream(stdoutByteStream); + ByteArrayOutputStream stderrByteStream = new ByteArrayOutputStream(); + PrintStream stderrPrintStream = new PrintStream(stderrByteStream)) { + + if (checkDisplayOverAppsPermission && amCommandArray.length >= 1 && + (amCommandArray[0].equals("start") || amCommandArray[0].equals("startservice")) && + !PermissionUtils.validateDisplayOverOtherAppsPermissionForPostAndroid10(context, true)) { + throw new IllegalStateException(context.getString(R.string.error_display_over_other_apps_permission_not_granted, + PackageUtils.getAppNameForPackage(context))); + } + + new Am(stdoutPrintStream, stderrPrintStream, (Application) context.getApplicationContext()).run(amCommandArray); + + // Set stdout to value set by am command in stdoutPrintStream + stdoutPrintStream.flush(); + stdout.append(stdoutByteStream.toString(StandardCharsets.UTF_8.name())); + + // Set stderr to value set by am command in stderrPrintStream + stderrPrintStream.flush(); + stderr.append(stderrByteStream.toString(StandardCharsets.UTF_8.name())); + } catch (Exception e) { + return AmSocketServerErrno.ERRNO_RUN_AM_COMMAND_FAILED_WITH_EXCEPTION.getError(e, Arrays.toString(amCommandArray), e.getMessage()); + } + + return null; + } + + + + + + /** Implementation for {@link ILocalSocketManager} for {@link AmSocketServer}. */ + public abstract static class AmSocketServerClient extends LocalSocketManagerClientBase { + + @Override + public void onClientAccepted(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket) { + AmSocketServer.processAmClient(localSocketManager, clientSocket); + super.onClientAccepted(localSocketManager, clientSocket); + } + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java new file mode 100644 index 000000000..74b26528d --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java @@ -0,0 +1,18 @@ +package com.termux.shared.shell.am; + +import com.termux.shared.errors.Errno; + +public class AmSocketServerErrno extends Errno { + + public static final String TYPE = "AmSocketServer Error"; + + + /** Errors for {@link AmSocketServer} (100-150) */ + public static final Errno ERRNO_PARSE_AM_COMMAND_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Parse am command `%1$s` failed.\nException: %2$s"); + public static final Errno ERRNO_RUN_AM_COMMAND_FAILED_WITH_EXCEPTION = new Errno(TYPE, 101, "Run am command `%1$s` failed.\nException: %2$s"); + + AmSocketServerErrno(final String type, final int code, final String message) { + super(type, code, message); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java new file mode 100644 index 000000000..f36cbece5 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java @@ -0,0 +1,108 @@ +package com.termux.shared.shell.am; + +import android.Manifest; + +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.net.socket.local.ILocalSocketManager; +import com.termux.shared.net.socket.local.LocalSocketRunConfig; + +import java.io.Serializable; + +/** + * Run config for {@link AmSocketServer}. + */ +public class AmSocketServerRunConfig extends LocalSocketRunConfig implements Serializable { + + /** + * Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW} has been granted if running on Android `>= 10` + * if starting activities. Will also check when starting services in case starting foreground + * service is not allowed. + * + * https://developer.android.com/guide/components/activities/background-starts + */ + private Boolean mCheckDisplayOverAppsPermission; + public static final boolean DEFAULT_CHECK_DISPLAY_OVER_APPS_PERMISSION = true; + + /** + * Create an new instance of {@link AmSocketServerRunConfig}. + * + * @param title The {@link #mTitle} value. + * @param path The {@link #mPath} value. + * @param localSocketManagerClient The {@link #mLocalSocketManagerClient} value. + */ + public AmSocketServerRunConfig(@NonNull String title, @NonNull String path, @NonNull ILocalSocketManager localSocketManagerClient) { + super(title, path, localSocketManagerClient); + } + + + /** Get {@link #mCheckDisplayOverAppsPermission} if set, otherwise {@link #DEFAULT_CHECK_DISPLAY_OVER_APPS_PERMISSION}. */ + public boolean shouldCheckDisplayOverAppsPermission() { + return mCheckDisplayOverAppsPermission != null ? mCheckDisplayOverAppsPermission : DEFAULT_CHECK_DISPLAY_OVER_APPS_PERMISSION; + } + + /** Set {@link #mCheckDisplayOverAppsPermission}. */ + public void setCheckDisplayOverAppsPermission(Boolean checkDisplayOverAppsPermission) { + mCheckDisplayOverAppsPermission = checkDisplayOverAppsPermission; + } + + + + /** + * Get a log {@link String} for {@link AmSocketServerRunConfig}. + * + * @param config The {@link AmSocketServerRunConfig} to get info of. + * @return Returns the log {@link String}. + */ + @NonNull + public static String getRunConfigLogString(final AmSocketServerRunConfig config) { + if (config == null) return "null"; + return config.getLogString(); + } + + /** Get a log {@link String} for the {@link AmSocketServerRunConfig}. */ + @NonNull + public String getLogString() { + StringBuilder logString = new StringBuilder(); + logString.append(super.getLogString()).append("\n\n\n"); + + logString.append("Am Command:"); + logString.append("\n").append(Logger.getSingleLineLogStringEntry("CheckDisplayOverAppsPermission", shouldCheckDisplayOverAppsPermission(), "-")); + + return logString.toString(); + } + + /** + * Get a markdown {@link String} for {@link AmSocketServerRunConfig}. + * + * @param config The {@link AmSocketServerRunConfig} to get info of. + * @return Returns the markdown {@link String}. + */ + public static String getRunConfigMarkdownString(final AmSocketServerRunConfig config) { + if (config == null) return "null"; + return config.getMarkdownString(); + } + + /** Get a markdown {@link String} for the {@link AmSocketServerRunConfig}. */ + @NonNull + public String getMarkdownString() { + StringBuilder markdownString = new StringBuilder(); + markdownString.append(super.getMarkdownString()).append("\n\n\n"); + + markdownString.append("## ").append("Am Command"); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("CheckDisplayOverAppsPermission", shouldCheckDisplayOverAppsPermission(), "-")); + + return markdownString.toString(); + } + + + + @NonNull + @Override + public String toString() { + return getLogString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java new file mode 100644 index 000000000..78065cdad --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java @@ -0,0 +1,689 @@ +package com.termux.shared.shell.command; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.data.IntentUtils; +import com.termux.shared.shell.command.result.ResultConfig; +import com.termux.shared.shell.command.result.ResultData; +import com.termux.shared.errors.Error; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.shell.command.runner.app.AppShell; +import com.termux.terminal.TerminalSession; + +import java.util.Collections; +import java.util.List; + +public class ExecutionCommand { + + /* + The {@link ExecutionState#SUCCESS} and {@link ExecutionState#FAILED} is defined based on + successful execution of command without any internal errors or exceptions being raised. + The shell command {@link #exitCode} being non-zero **does not** mean that execution command failed. + Only the {@link #errCode} being non-zero means that execution command failed from the Termux app + perspective. + */ + + /** The {@link Enum} that defines {@link ExecutionCommand} state. */ + public enum ExecutionState { + + PRE_EXECUTION("Pre-Execution", 0), + EXECUTING("Executing", 1), + EXECUTED("Executed", 2), + SUCCESS("Success", 3), + FAILED("Failed", 4); + + private final String name; + private final int value; + + ExecutionState(final String name, final int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public int getValue() { + return value; + } + + + } + + public enum Runner { + + /** Run command in {@link TerminalSession}. */ + TERMINAL_SESSION("terminal-session"), + + /** Run command in {@link AppShell}. */ + APP_SHELL("app-shell"); + + ///** Run command in {@link AdbShell}. */ + //ADB_SHELL("adb-shell"), + + ///** Run command in {@link RootShell}. */ + //ROOT_SHELL("root-shell"); + + private final String name; + + Runner(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public boolean equalsRunner(String runner) { + return runner != null && runner.equals(this.name); + } + + /** Get {@link Runner} for {@code name} if found, otherwise {@code null}. */ + @Nullable + public static Runner runnerOf(String name) { + for (Runner v : Runner.values()) { + if (v.name.equals(name)) { + return v; + } + } + return null; + } + + /** Get {@link Runner} for {@code name} if found, otherwise {@code def}. */ + @NonNull + public static Runner runnerOf(@Nullable String name, @NonNull Runner def) { + Runner runner = runnerOf(name); + return runner != null ? runner : def; + } + + } + + public enum ShellCreateMode { + + /** Always create {@link TerminalSession}. */ + ALWAYS("always"), + + /** Create shell only if no shell with {@link #shellName} found. */ + NO_SHELL_WITH_NAME("no-shell-with-name"); + + private final String mode; + + ShellCreateMode(final String mode) { + this.mode = mode; + } + + public String getMode() { + return mode; + } + + public boolean equalsMode(String sessionCreateMode) { + return sessionCreateMode != null && sessionCreateMode.equals(this.mode); + } + + /** Get {@link ShellCreateMode} for {@code mode} if found, otherwise {@code null}. */ + @Nullable + public static ShellCreateMode modeOf(String mode) { + for (ShellCreateMode v : ShellCreateMode.values()) { + if (v.mode.equals(mode)) { + return v; + } + } + return null; + } + + } + + /** The optional unique id for the {@link ExecutionCommand}. This should equal -1 if execution + * command is not going to be managed by a shell manager. */ + public Integer id; + + /** The process id of command. */ + public int mPid = -1; + + /** The current state of the {@link ExecutionCommand}. */ + private ExecutionState currentState = ExecutionState.PRE_EXECUTION; + /** The previous state of the {@link ExecutionCommand}. */ + private ExecutionState previousState = ExecutionState.PRE_EXECUTION; + + + /** The executable for the {@link ExecutionCommand}. */ + public String executable; + /** The executable Uri for the {@link ExecutionCommand}. */ + public Uri executableUri; + /** The executable arguments array for the {@link ExecutionCommand}. */ + public String[] arguments; + /** The stdin string for the {@link ExecutionCommand}. */ + public String stdin; + /** The current working directory for the {@link ExecutionCommand}. */ + public String workingDirectory; + + + /** The terminal transcript rows for the {@link ExecutionCommand}. */ + public Integer terminalTranscriptRows; + + + /** The {@link Runner} for the {@link ExecutionCommand}. */ + public String runner; + + /** If the {@link ExecutionCommand} is meant to start a failsafe terminal session. */ + public boolean isFailsafe; + + /** + * The {@link ExecutionCommand} custom log level for background {@link AppShell} + * commands. By default, @link com.termux.shared.shell.StreamGobbler} only logs stdout and + * stderr if {@link Logger} `CURRENT_LOG_LEVEL` is >= {@link Logger#LOG_LEVEL_VERBOSE} and + * {@link AppShell} only logs stdin if `CURRENT_LOG_LEVEL` is >= + * {@link Logger#LOG_LEVEL_DEBUG}. + */ + public Integer backgroundCustomLogLevel; + + + /** The session action of {@link Runner#TERMINAL_SESSION} commands. */ + public String sessionAction; + + + /** The shell name of commands. */ + public String shellName; + + /** The {@link ShellCreateMode} of commands. */ + public String shellCreateMode; + + /** Whether to set {@link ExecutionCommand} shell environment. */ + public boolean setShellCommandShellEnvironment; + + + /** The command label for the {@link ExecutionCommand}. */ + public String commandLabel; + /** The markdown text for the command description for the {@link ExecutionCommand}. */ + public String commandDescription; + /** The markdown text for the help of command for the {@link ExecutionCommand}. This can be used + * to provide useful info to the user if an internal error is raised. */ + public String commandHelp; + + + /** Defines the markdown text for the help of the Termux plugin API that was used to start the + * {@link ExecutionCommand}. This can be used to provide useful info to the user if an internal + * error is raised. */ + public String pluginAPIHelp; + + + /** Defines the {@link Intent} received which started the command. */ + public Intent commandIntent; + + /** Defines if {@link ExecutionCommand} was started because of an external plugin request + * like with an intent or from within Termux app itself. */ + public boolean isPluginExecutionCommand; + + /** Defines the {@link ResultConfig} for the {@link ExecutionCommand} containing information + * on how to handle the result. */ + public final ResultConfig resultConfig = new ResultConfig(); + + /** Defines the {@link ResultData} for the {@link ExecutionCommand} containing information + * of the result. */ + public final ResultData resultData = new ResultData(); + + + /** Defines if processing results already called for this {@link ExecutionCommand}. */ + public boolean processingResultsAlreadyCalled; + + private static final String LOG_TAG = "ExecutionCommand"; + + + public ExecutionCommand() { + } + + public ExecutionCommand(Integer id) { + this.id = id; + } + + public ExecutionCommand(Integer id, String executable, String[] arguments, String stdin, String workingDirectory, String runner, boolean isFailsafe) { + this.id = id; + this.executable = executable; + this.arguments = arguments; + this.stdin = stdin; + this.workingDirectory = workingDirectory; + this.runner = runner; + this.isFailsafe = isFailsafe; + } + + + public boolean isPluginExecutionCommandWithPendingResult() { + return isPluginExecutionCommand && resultConfig.isCommandWithPendingResult(); + } + + + public synchronized boolean setState(ExecutionState newState) { + // The state transition cannot go back or change if already at {@link ExecutionState#SUCCESS} + if (newState.getValue() < currentState.getValue() || currentState == ExecutionState.SUCCESS) { + Logger.logError(LOG_TAG, "Invalid "+ getCommandIdAndLabelLogString() + " state transition from \"" + currentState.getName() + "\" to " + "\"" + newState.getName() + "\""); + return false; + } + + // The {@link ExecutionState#FAILED} can be set again, like to add more errors, but we don't update + // {@link #previousState} with the {@link #currentState} value if its at {@link ExecutionState#FAILED} to + // preserve the last valid state + if (currentState != ExecutionState.FAILED) + previousState = currentState; + + currentState = newState; + return true; + } + + public synchronized boolean hasExecuted() { + return currentState.getValue() >= ExecutionState.EXECUTED.getValue(); + } + + public synchronized boolean isExecuting() { + return currentState == ExecutionState.EXECUTING; + } + + public synchronized boolean isSuccessful() { + return currentState == ExecutionState.SUCCESS; + } + + + public synchronized boolean setStateFailed(@NonNull Error error) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), null); + } + + public synchronized boolean setStateFailed(@NonNull Error error, Throwable throwable) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), Collections.singletonList(throwable)); + } + public synchronized boolean setStateFailed(@NonNull Error error, List throwablesList) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), throwablesList); + } + + public synchronized boolean setStateFailed(int code, String message) { + return setStateFailed(null, code, message, null); + } + + public synchronized boolean setStateFailed(int code, String message, Throwable throwable) { + return setStateFailed(null, code, message, Collections.singletonList(throwable)); + } + + public synchronized boolean setStateFailed(int code, String message, List throwablesList) { + return setStateFailed(null, code, message, throwablesList); + } + public synchronized boolean setStateFailed(String type, int code, String message, List throwablesList) { + if (!this.resultData.setStateFailed(type, code, message, throwablesList)) { + Logger.logWarn(LOG_TAG, "setStateFailed for " + getCommandIdAndLabelLogString() + " resultData encountered an error."); + } + + return setState(ExecutionState.FAILED); + } + + public synchronized boolean shouldNotProcessResults() { + if (processingResultsAlreadyCalled) { + return true; + } else { + processingResultsAlreadyCalled = true; + return false; + } + } + + public synchronized boolean isStateFailed() { + if (currentState != ExecutionState.FAILED) + return false; + + if (!resultData.isStateFailed()) { + Logger.logWarn(LOG_TAG, "The " + getCommandIdAndLabelLogString() + " has an invalid errCode value set in errors list while having ExecutionState.FAILED state.\n" + resultData.errorsList); + return false; + } else { + return true; + } + } + + + @NonNull + @Override + public String toString() { + if (!hasExecuted()) + return getExecutionInputLogString(this, true, true); + else { + return getExecutionOutputLogString(this, true, true, true); + } + } + + /** + * Get a log friendly {@link String} for {@link ExecutionCommand} execution input parameters. + * + * @param executionCommand The {@link ExecutionCommand} to convert. + * @param ignoreNull Set to {@code true} if non-critical {@code null} values are to be ignored. + * @param logStdin Set to {@code true} if {@link #stdin} should be logged. + * @return Returns the log friendly {@link String}. + */ + public static String getExecutionInputLogString(final ExecutionCommand executionCommand, boolean ignoreNull, boolean logStdin) { + if (executionCommand == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + logString.append(executionCommand.getCommandIdAndLabelLogString()).append(":"); + + if (executionCommand.mPid != -1) + logString.append("\n").append(executionCommand.getPidLogString()); + + if (executionCommand.previousState != ExecutionState.PRE_EXECUTION) + logString.append("\n").append(executionCommand.getPreviousStateLogString()); + logString.append("\n").append(executionCommand.getCurrentStateLogString()); + + logString.append("\n").append(executionCommand.getExecutableLogString()); + logString.append("\n").append(executionCommand.getArgumentsLogString()); + logString.append("\n").append(executionCommand.getWorkingDirectoryLogString()); + logString.append("\n").append(executionCommand.getRunnerLogString()); + logString.append("\n").append(executionCommand.getIsFailsafeLogString()); + + if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) { + if (logStdin && (!ignoreNull || !DataUtils.isNullOrEmpty(executionCommand.stdin))) + logString.append("\n").append(executionCommand.getStdinLogString()); + + if (!ignoreNull || executionCommand.backgroundCustomLogLevel != null) + logString.append("\n").append(executionCommand.getBackgroundCustomLogLevelLogString()); + } + + if (!ignoreNull || executionCommand.sessionAction != null) + logString.append("\n").append(executionCommand.getSessionActionLogString()); + + if (!ignoreNull || executionCommand.shellName != null) { + logString.append("\n").append(executionCommand.getShellNameLogString()); + } + + if (!ignoreNull || executionCommand.shellCreateMode != null) { + logString.append("\n").append(executionCommand.getShellCreateModeLogString()); + } + + logString.append("\n").append(executionCommand.getSetRunnerShellEnvironmentLogString()); + + if (!ignoreNull || executionCommand.commandIntent != null) + logString.append("\n").append(executionCommand.getCommandIntentLogString()); + + logString.append("\n").append(executionCommand.getIsPluginExecutionCommandLogString()); + if (executionCommand.isPluginExecutionCommand) + logString.append("\n").append(ResultConfig.getResultConfigLogString(executionCommand.resultConfig, ignoreNull)); + + return logString.toString(); + } + + /** + * Get a log friendly {@link String} for {@link ExecutionCommand} execution output parameters. + * + * @param executionCommand The {@link ExecutionCommand} to convert. + * @param ignoreNull Set to {@code true} if non-critical {@code null} values are to be ignored. + * @param logResultData Set to {@code true} if {@link #resultData} should be logged. + * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr} should be logged. + * @return Returns the log friendly {@link String}. + */ + public static String getExecutionOutputLogString(final ExecutionCommand executionCommand, boolean ignoreNull, boolean logResultData, boolean logStdoutAndStderr) { + if (executionCommand == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + logString.append(executionCommand.getCommandIdAndLabelLogString()).append(":"); + + logString.append("\n").append(executionCommand.getPreviousStateLogString()); + logString.append("\n").append(executionCommand.getCurrentStateLogString()); + + if (logResultData) + logString.append("\n").append(ResultData.getResultDataLogString(executionCommand.resultData, logStdoutAndStderr)); + + return logString.toString(); + } + + /** + * Get a log friendly {@link String} for {@link ExecutionCommand} with more details. + * + * @param executionCommand The {@link ExecutionCommand} to convert. + * @return Returns the log friendly {@link String}. + */ + public static String getDetailedLogString(final ExecutionCommand executionCommand) { + if (executionCommand == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + logString.append(getExecutionInputLogString(executionCommand, false, true)); + logString.append(getExecutionOutputLogString(executionCommand, false, true, true)); + + logString.append("\n").append(executionCommand.getCommandDescriptionLogString()); + logString.append("\n").append(executionCommand.getCommandHelpLogString()); + logString.append("\n").append(executionCommand.getPluginAPIHelpLogString()); + + return logString.toString(); + } + + /** + * Get a markdown {@link String} for {@link ExecutionCommand}. + * + * @param executionCommand The {@link ExecutionCommand} to convert. + * @return Returns the markdown {@link String}. + */ + public static String getExecutionCommandMarkdownString(final ExecutionCommand executionCommand) { + if (executionCommand == null) return "null"; + + if (executionCommand.commandLabel == null) executionCommand.commandLabel = "Execution Command"; + + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## ").append(executionCommand.commandLabel).append("\n"); + + if (executionCommand.mPid != -1) + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Pid", executionCommand.mPid, "-")); + + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Previous State", executionCommand.previousState.getName(), "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Current State", executionCommand.currentState.getName(), "-")); + + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Executable", executionCommand.executable, "-")); + markdownString.append("\n").append(getArgumentsMarkdownString("Arguments", executionCommand.arguments)); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Working Directory", executionCommand.workingDirectory, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Runner", executionCommand.runner, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("isFailsafe", executionCommand.isFailsafe, "-")); + + if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) { + if (!DataUtils.isNullOrEmpty(executionCommand.stdin)) + markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Stdin", executionCommand.stdin, "-")); + if (executionCommand.backgroundCustomLogLevel != null) + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Background Custom Log Level", executionCommand.backgroundCustomLogLevel, "-")); + } + + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Session Action", executionCommand.sessionAction, "-")); + + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Shell Name", executionCommand.shellName, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Shell Create Mode", executionCommand.shellCreateMode, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Set Shell Command Shell Environment", executionCommand.setShellCommandShellEnvironment, "-")); + + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("isPluginExecutionCommand", executionCommand.isPluginExecutionCommand, "-")); + + markdownString.append("\n\n").append(ResultConfig.getResultConfigMarkdownString(executionCommand.resultConfig)); + + markdownString.append("\n\n").append(ResultData.getResultDataMarkdownString(executionCommand.resultData)); + + if (executionCommand.commandDescription != null || executionCommand.commandHelp != null) { + if (executionCommand.commandDescription != null) + markdownString.append("\n\n### Command Description\n\n").append(executionCommand.commandDescription).append("\n"); + if (executionCommand.commandHelp != null) + markdownString.append("\n\n### Command Help\n\n").append(executionCommand.commandHelp).append("\n"); + markdownString.append("\n##\n"); + } + + if (executionCommand.pluginAPIHelp != null) { + markdownString.append("\n\n### Plugin API Help\n\n").append(executionCommand.pluginAPIHelp); + markdownString.append("\n##\n"); + } + + return markdownString.toString(); + } + + + public String getIdLogString() { + if (id != null) + return "(" + id + ") "; + else + return ""; + } + + public String getPidLogString() { + return "Pid: `" + mPid + "`"; + } + + public String getCurrentStateLogString() { + return "Current State: `" + currentState.getName() + "`"; + } + + public String getPreviousStateLogString() { + return "Previous State: `" + previousState.getName() + "`"; + } + + public String getCommandLabelLogString() { + if (commandLabel != null && !commandLabel.isEmpty()) + return commandLabel; + else + return "Execution Command"; + } + + public String getCommandIdAndLabelLogString() { + return getIdLogString() + getCommandLabelLogString(); + } + + public String getExecutableLogString() { + return "Executable: `" + executable + "`"; + } + + public String getArgumentsLogString() { + return getArgumentsLogString("Arguments", arguments); + } + + public String getWorkingDirectoryLogString() { + return "Working Directory: `" + workingDirectory + "`"; + } + + public String getRunnerLogString() { + return Logger.getSingleLineLogStringEntry("Runner", runner, "-"); + } + + public String getIsFailsafeLogString() { + return "isFailsafe: `" + isFailsafe + "`"; + } + + public String getStdinLogString() { + if (DataUtils.isNullOrEmpty(stdin)) + return "Stdin: -"; + else + return Logger.getMultiLineLogStringEntry("Stdin", stdin, "-"); + } + + public String getBackgroundCustomLogLevelLogString() { + return "Background Custom Log Level: `" + backgroundCustomLogLevel + "`"; + } + + public String getSessionActionLogString() { + return Logger.getSingleLineLogStringEntry("Session Action", sessionAction, "-"); + } + + public String getShellNameLogString() { + return Logger.getSingleLineLogStringEntry("Shell Name", shellName, "-"); + } + + public String getShellCreateModeLogString() { + return Logger.getSingleLineLogStringEntry("Shell Create Mode", shellCreateMode, "-"); + } + + public String getSetRunnerShellEnvironmentLogString() { + return "Set Shell Command Shell Environment: `" + setShellCommandShellEnvironment + "`"; + } + + public String getCommandDescriptionLogString() { + return Logger.getSingleLineLogStringEntry("Command Description", commandDescription, "-"); + } + + public String getCommandHelpLogString() { + return Logger.getSingleLineLogStringEntry("Command Help", commandHelp, "-"); + } + + public String getPluginAPIHelpLogString() { + return Logger.getSingleLineLogStringEntry("Plugin API Help", pluginAPIHelp, "-"); + } + + public String getCommandIntentLogString() { + if (commandIntent == null) + return "Command Intent: -"; + else + return Logger.getMultiLineLogStringEntry("Command Intent", IntentUtils.getIntentString(commandIntent), "-"); + } + + public String getIsPluginExecutionCommandLogString() { + return "isPluginExecutionCommand: `" + isPluginExecutionCommand + "`"; + } + + + /** + * Get a log friendly {@link String} for {@link List} argumentsArray. + * If argumentsArray are null or of size 0, then `Arguments: -` is returned. Otherwise + * following format is returned: + * + * Arguments: + * ``` + * Arg 1: `value` + * Arg 2: 'value` + * ``` + * + * @param argumentsArray The {@link String[]} argumentsArray to convert. + * @return Returns the log friendly {@link String}. + */ + public static String getArgumentsLogString(String label, final String[] argumentsArray) { + StringBuilder argumentsString = new StringBuilder(label + ":"); + + if (argumentsArray != null && argumentsArray.length != 0) { + argumentsString.append("\n```\n"); + for (int i = 0; i != argumentsArray.length; i++) { + argumentsString.append(Logger.getSingleLineLogStringEntry("Arg " + (i + 1), + DataUtils.getTruncatedCommandOutput(argumentsArray[i], Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD / 5, true, false, true), + "-")).append("\n"); + } + argumentsString.append("```"); + } else{ + argumentsString.append(" -"); + } + + return argumentsString.toString(); + } + + /** + * Get a markdown {@link String} for {@link String[]} argumentsArray. + * If argumentsArray are null or of size 0, then `**Arguments:** -` is returned. Otherwise + * following format is returned: + * + * **Arguments:** + * + * **Arg 1:** + * ``` + * value + * ``` + * **Arg 2:** + * ``` + * value + *``` + * + * @param argumentsArray The {@link String[]} argumentsArray to convert. + * @return Returns the markdown {@link String}. + */ + public static String getArgumentsMarkdownString(String label, final String[] argumentsArray) { + StringBuilder argumentsString = new StringBuilder("**" + label + ":**"); + + if (argumentsArray != null && argumentsArray.length != 0) { + argumentsString.append("\n"); + for (int i = 0; i != argumentsArray.length; i++) { + argumentsString.append(MarkdownUtils.getMultiLineMarkdownStringEntry("Arg " + (i + 1), argumentsArray[i], "-")).append("\n"); + } + } else{ + argumentsString.append(" - "); + } + + return argumentsString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java new file mode 100644 index 000000000..359cb4a9c --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java @@ -0,0 +1,75 @@ +package com.termux.shared.shell.command; + +import com.termux.shared.errors.Errno; +import com.termux.shared.shell.command.result.ResultConfig; + +import java.util.Formatter; +import java.util.IllegalFormatException; + +public class ShellCommandConstants { + + /** + * Class to send back results of commands to their callers like plugin or 3rd party apps. + */ + public static final class RESULT_SENDER { + + /* + * The default `Formatter` format strings to use for `ResultConfig#resultFileBasename` + * if `ResultConfig#resultSingleFile` is `true`. + */ + + /** The {@link Formatter} format string for success if only `stdout` needs to be written to + * {@link ResultConfig#resultFileBasename} where `stdout` maps to `%1$s`. + * This is used when `err` equals {@link Errno#ERRNO_SUCCESS} (-1) and `stderr` is empty + * and `exit_code` equals `0` and {@link ResultConfig#resultFileOutputFormat} is not passed. */ + public static final String FORMAT_SUCCESS_STDOUT = "%1$s%n"; + /** The {@link Formatter} format string for success if `stdout` and `exit_code` need to be written to + * {@link ResultConfig#resultFileBasename} where `stdout` maps to `%1$s` and `exit_code` to `%2$s`. + * This is used when `err` equals {@link Errno#ERRNO_SUCCESS} (-1) and `stderr` is empty + * and `exit_code` does not equal `0` and {@link ResultConfig#resultFileOutputFormat} is not passed. + * The exit code will be placed in a markdown inline code. */ + public static final String FORMAT_SUCCESS_STDOUT__EXIT_CODE = "%1$s%n%n%n%nexit_code=%2$s%n"; + /** The {@link Formatter} format string for success if `stdout`, `stderr` and `exit_code` need to be + * written to {@link ResultConfig#resultFileBasename} where `stdout` maps to `%1$s`, `stderr` + * maps to `%2$s` and `exit_code` to `%3$s`. + * This is used when `err` equals {@link Errno#ERRNO_SUCCESS} (-1) and `stderr` is not empty + * and {@link ResultConfig#resultFileOutputFormat} is not passed. + * The stdout and stderr will be placed in a markdown code block. The exit code will be placed + * in a markdown inline code. The surrounding backticks will be 3 more than the consecutive + * backticks in any parameter itself for code blocks. */ + public static final String FORMAT_SUCCESS_STDOUT__STDERR__EXIT_CODE = "stdout=%n%1$s%n%n%n%nstderr=%n%2$s%n%n%n%nexit_code=%3$s%n"; + /** The {@link Formatter} format string for failure if `err`, `errmsg`(`error`), `stdout`, + * `stderr` and `exit_code` need to be written to {@link ResultConfig#resultFileBasename} where + * `err` maps to `%1$s`, `errmsg` maps to `%2$s`, `stdout` maps + * to `%3$s`, `stderr` to `%4$s` and `exit_code` maps to `%5$s`. + * Do not define an argument greater than `5`, like `%6$s` if you change this value since it will + * raise {@link IllegalFormatException}. + * This is used when `err` does not equal {@link Errno#ERRNO_SUCCESS} (-1) and + * {@link ResultConfig#resultFileErrorFormat} is not passed. + * The errmsg, stdout and stderr will be placed in a markdown code block. The err and exit code + * will be placed in a markdown inline code. The surrounding backticks will be 3 more than + * the consecutive backticks in any parameter itself for code blocks. The stdout, stderr + * and exit code may be empty without any surrounding backticks if not set. */ + public static final String FORMAT_FAILED_ERR__ERRMSG__STDOUT__STDERR__EXIT_CODE = "err=%1$s%n%n%n%nerrmsg=%n%2$s%n%n%n%nstdout=%n%3$s%n%n%n%nstderr=%n%4$s%n%n%n%nexit_code=%5$s%n"; + + + + /* + * The default prefixes to use for result files under `ResultConfig#resultDirectoryPath` + * if `ResultConfig#resultSingleFile` is `false`. + */ + + /** The prefix for the err result file. */ + public static final String RESULT_FILE_ERR_PREFIX = "err"; + /** The prefix for the errmsg result file. */ + public static final String RESULT_FILE_ERRMSG_PREFIX = "errmsg"; + /** The prefix for the stdout result file. */ + public static final String RESULT_FILE_STDOUT_PREFIX = "stdout"; + /** The prefix for the stderr result file. */ + public static final String RESULT_FILE_STDERR_PREFIX = "stderr"; + /** The prefix for the exitCode result file. */ + public static final String RESULT_FILE_EXIT_CODE_PREFIX = "exit_code"; + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java new file mode 100644 index 000000000..8d113d015 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java @@ -0,0 +1,101 @@ +package com.termux.shared.shell.command.environment; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.itsaky.androidide.utils.Environment; +import com.termux.shared.shell.command.ExecutionCommand; + +import java.io.File; +import java.util.HashMap; + +/** + * Environment for Android. + * + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:system/core/rootdir/init.environ.rc.in + * https://cs.android.com/android/platform/superproject/+/android-5.0.0_r1.0.1:system/core/rootdir/init.environ.rc.in + * https://cs.android.com/android/_/android/platform/system/core/+/refs/tags/android-12.0.0_r32:rootdir/init.rc;l=910 + * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:packages/modules/SdkExtensions/derive_classpath/derive_classpath.cpp;l=96 + */ +public class AndroidShellEnvironment extends UnixShellEnvironment { + + protected ShellCommandShellEnvironment shellCommandShellEnvironment; + + public AndroidShellEnvironment() { + shellCommandShellEnvironment = new ShellCommandShellEnvironment(); + } + + /** Get shell environment for Android. */ + @NonNull + @Override + public HashMap getEnvironment(@NonNull Context currentPackageContext, boolean isFailSafe) { + HashMap environment = new HashMap<>(Environment.getEnvironment()); + + environment.put(ENV_HOME, "/"); + environment.put(ENV_LANG, "en_US.UTF-8"); + environment.put(ENV_PATH, System.getenv(ENV_PATH)); + environment.put(ENV_TMPDIR, "/data/local/tmp"); + + environment.put(ENV_COLORTERM, "truecolor"); + environment.put(ENV_TERM, "xterm-256color"); + + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_ASSETS"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_DATA"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_ROOT"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_STORAGE"); + + // EXTERNAL_STORAGE is needed for /system/bin/am to work on at least + // Samsung S7 - see https://plus.google.com/110070148244138185604/posts/gp8Lk3aCGp3. + // https://cs.android.com/android/_/android/platform/system/core/+/fc000489 + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "EXTERNAL_STORAGE"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ASEC_MOUNTPOINT"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "LOOP_MOUNTPOINT"); + + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_RUNTIME_ROOT"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_ART_ROOT"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_I18N_ROOT"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_TZDATA_ROOT"); + + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "BOOTCLASSPATH"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "DEX2OATBOOTCLASSPATH"); + ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "SYSTEMSERVERCLASSPATH"); + + return environment; + } + + + + @NonNull + @Override + public String getDefaultWorkingDirectoryPath() { + return "/"; + } + + + @NonNull + @Override + public String getDefaultBinPath() { + return "/system/bin"; + } + + @NonNull + @Override + public HashMap setupShellCommandEnvironment(@NonNull Context currentPackageContext, + @NonNull ExecutionCommand executionCommand) { + HashMap environment = getEnvironment(currentPackageContext, executionCommand.isFailsafe); + + String workingDirectory = executionCommand.workingDirectory; + environment.put(ENV_PWD, + workingDirectory != null && !workingDirectory.isEmpty() ? new File(workingDirectory).getAbsolutePath() : // PWD must be absolute path + getDefaultWorkingDirectoryPath()); + ShellEnvironmentUtils.createHomeDir(environment); + + if (executionCommand.setShellCommandShellEnvironment && shellCommandShellEnvironment != null) + environment.putAll(shellCommandShellEnvironment.getEnvironment(currentPackageContext, executionCommand)); + + return environment; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java new file mode 100644 index 000000000..a8e75e9af --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java @@ -0,0 +1,52 @@ +package com.termux.shared.shell.command.environment; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.shell.command.ExecutionCommand; + +import java.util.HashMap; + +public interface IShellEnvironment { + + /** + * Get the default working directory path for the environment in case the path that was passed + * was {@code null} or empty. + * + * @return Should return the default working directory path. + */ + @NonNull + String getDefaultWorkingDirectoryPath(); + + /** + * Get the default "/bin" path, like $PREFIX/bin. + * + * @return Should return the "/bin" path. + */ + @NonNull + String getDefaultBinPath(); + + /** + * Setup shell command arguments for the file to execute, like interpreter, etc. + * + * @param fileToExecute The file to execute. + * @param arguments The arguments to pass to the executable. + * @return Should return the final process arguments. + */ + @NonNull + String[] setupShellCommandArguments(@NonNull String fileToExecute, @Nullable String[] arguments); + + /** + * Setup shell command environment to be used for commands. + * + * @param currentPackageContext The {@link Context} for the current package. + * @param executionCommand The {@link ExecutionCommand} for which to set environment. + * @return Should return the shell environment. + */ + @NonNull + HashMap setupShellCommandEnvironment(@NonNull Context currentPackageContext, + @NonNull ExecutionCommand executionCommand); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java new file mode 100644 index 000000000..a898dec91 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java @@ -0,0 +1,62 @@ +package com.termux.shared.shell.command.environment; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.shell.command.ExecutionCommand; + +import java.util.HashMap; + +/** + * Environment for {@link ExecutionCommand}. + */ +public class ShellCommandShellEnvironment { + + /** Environment variable prefix for the {@link ExecutionCommand}. */ + public static final String SHELL_CMD_ENV_PREFIX = "SHELL_CMD__"; + + /** Environment variable for the {@link ExecutionCommand.Runner} name. */ + public static final String ENV_SHELL_CMD__RUNNER_NAME = SHELL_CMD_ENV_PREFIX + "RUNNER_NAME"; + + /** Environment variable for the package name running the {@link ExecutionCommand}. */ + public static final String ENV_SHELL_CMD__PACKAGE_NAME = SHELL_CMD_ENV_PREFIX + "PACKAGE_NAME"; + + /** Environment variable for the {@link ExecutionCommand#id}/TermuxShellManager.SHELL_ID name. + * This will be common for all runners. */ + public static final String ENV_SHELL_CMD__SHELL_ID = SHELL_CMD_ENV_PREFIX + "SHELL_ID"; + + /** Environment variable for the {@link ExecutionCommand#shellName} name. */ + public static final String ENV_SHELL_CMD__SHELL_NAME = SHELL_CMD_ENV_PREFIX + "SHELL_NAME"; + + /** Environment variable for the {@link ExecutionCommand.Runner#APP_SHELL} number since boot. */ + public static final String ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_BOOT = SHELL_CMD_ENV_PREFIX + "APP_SHELL_NUMBER_SINCE_BOOT"; + + /** Environment variable for the {{@link ExecutionCommand.Runner#TERMINAL_SESSION} number since boot. */ + public static final String ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_BOOT = SHELL_CMD_ENV_PREFIX + "TERMINAL_SESSION_NUMBER_SINCE_BOOT"; + + /** Environment variable for the {@link ExecutionCommand.Runner#APP_SHELL} number since app start. */ + public static final String ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_APP_START = SHELL_CMD_ENV_PREFIX + "APP_SHELL_NUMBER_SINCE_APP_START"; + + /** Environment variable for the {@link ExecutionCommand.Runner#TERMINAL_SESSION} number since app start. */ + public static final String ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_APP_START = SHELL_CMD_ENV_PREFIX + "TERMINAL_SESSION_NUMBER_SINCE_APP_START"; + + + /** Get shell environment containing info for {@link ExecutionCommand}. */ + @NonNull + public HashMap getEnvironment(@NonNull Context currentPackageContext, + @NonNull ExecutionCommand executionCommand) { + HashMap environment = new HashMap<>(); + + ExecutionCommand.Runner runner = ExecutionCommand.Runner.runnerOf(executionCommand.runner); + if (runner == null) return environment; + + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__RUNNER_NAME, runner.getName()); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__PACKAGE_NAME, currentPackageContext.getPackageName()); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__SHELL_ID, String.valueOf(executionCommand.id)); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__SHELL_NAME, executionCommand.shellName); + + return environment; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java new file mode 100644 index 000000000..9b331c333 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java @@ -0,0 +1,180 @@ +package com.termux.shared.shell.command.environment; + +import static com.termux.shared.shell.command.environment.UnixShellEnvironment.*; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ShellEnvironmentUtils { + + private static final String LOG_TAG = "ShellEnvironmentUtils"; + + /** + * Convert environment {@link HashMap} to `environ` {@link List }. + * + * The items in the environ will have the format `name=value`. + * + * Check {@link #isValidEnvironmentVariableName(String)} and {@link #isValidEnvironmentVariableValue(String)} + * for valid variable names and values. + * + * https://manpages.debian.org/testing/manpages/environ.7.en.html + * https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html + */ + @NonNull + public static List convertEnvironmentToEnviron(@NonNull HashMap environmentMap) { + List environmentList = new ArrayList<>(environmentMap.size()); + String value; + for (String name : environmentMap.keySet()) { + value = environmentMap.get(name); + if (isValidEnvironmentVariableNameValuePair(name, value, true)) + environmentList.add(name + "=" + environmentMap.get(name)); + } + return environmentList; + } + + /** + * Convert environment {@link HashMap} to {@link String} where each item equals "key=value". + * + */ + @NonNull + public static String convertEnvironmentToDotEnvFile(@NonNull HashMap environmentMap) { + return convertEnvironmentToDotEnvFile(convertEnvironmentMapToEnvironmentVariableList(environmentMap)); + } + + /** + * Convert environment {@link HashMap} to `.env` file {@link String}. + * + * The items in the `.env` file have the format `export name="value"`. + * + * If the {@link ShellEnvironmentVariable#escaped} is set to {@code true}, then + * {@link ShellEnvironmentVariable#value} will be considered to be a literal value that has + * already been escaped by the caller, otherwise all the `"`\$` in the value will be escaped + * with `a backslash `\`, like `\"`. Note that if `$` is escaped and if its part of variable, + * then variable expansion will not happen if `.env` file is sourced. + * + * The `\` at the end of a value line means line continuation. Value can contain newline characters. + * + * Check {@link #isValidEnvironmentVariableName(String)} and {@link #isValidEnvironmentVariableValue(String)} + * for valid variable names and values. + * + * https://github.com/ko1nksm/shdotenv#env-file-syntax + * https://github.com/ko1nksm/shdotenv/blob/main/docs/specification.md + */ + @NonNull + public static String convertEnvironmentToDotEnvFile(@NonNull List environmentList) { + StringBuilder environment = new StringBuilder(); + Collections.sort(environmentList); + for (ShellEnvironmentVariable variable : environmentList) { + if (isValidEnvironmentVariableNameValuePair(variable.name, variable.value, true) && variable.value != null) { + environment.append("export ").append(variable.name).append("=\"") + .append(variable.escaped ? variable.value : variable.value.replaceAll("([\"`\\\\$])", "\\\\$1")) + .append("\"\n"); + } + } + return environment.toString(); + } + + /** + * Convert environment {@link HashMap} to {@link List< ShellEnvironmentVariable >}. Each item + * will have its {@link ShellEnvironmentVariable#escaped} set to {@code false}. + */ + @NonNull + public static List convertEnvironmentMapToEnvironmentVariableList(@NonNull HashMap environmentMap) { + List environmentList = new ArrayList<>(); + for (String name :environmentMap.keySet()) { + environmentList.add(new ShellEnvironmentVariable(name, environmentMap.get(name), false)); + } + return environmentList; + } + + /** + * Check if environment variable name and value pair is valid. Errors will be logged if + * {@code logErrors} is {@code true}. + * + * Check {@link #isValidEnvironmentVariableName(String)} and {@link #isValidEnvironmentVariableValue(String)} + * for valid variable names and values. + */ + public static boolean isValidEnvironmentVariableNameValuePair(@Nullable String name, @Nullable String value, boolean logErrors) { + if (!isValidEnvironmentVariableName(name)) { + if (logErrors) + Logger.logErrorPrivate(LOG_TAG, "Invalid environment variable name. name=`" + name + "`, value=`" + value + "`"); + return false; + } + + if (!isValidEnvironmentVariableValue(value)) { + if (logErrors) + Logger.logErrorPrivate(LOG_TAG, "Invalid environment variable value. name=`" + name + "`, value=`" + value + "`"); + return false; + } + + return true; + } + + /** + * Check if environment variable name is valid. It must not be {@code null} and must not contain + * the null byte ('\0') and must only contain alphanumeric and underscore characters and must not + * start with a digit. + */ + public static boolean isValidEnvironmentVariableName(@Nullable String name) { + return name != null && !name.contains("\0") && name.matches("[a-zA-Z_][a-zA-Z0-9_]*"); + } + + /** + * Check if environment variable value is valid. It must not be {@code null} and must not contain + * the null byte ('\0'). + */ + public static boolean isValidEnvironmentVariableValue(@Nullable String value) { + return value != null && !value.contains("\0"); + } + + + + /** Put value in environment if variable exists in {@link System) environment. */ + public static void putToEnvIfInSystemEnv(@NonNull HashMap environment, + @NonNull String name) { + String value = System.getenv(name); + if (value != null) { + environment.put(name, value); + } + } + + /** Put {@link String} value in environment if value set. */ + public static void putToEnvIfSet(@NonNull HashMap environment, @NonNull String name, + @Nullable String value) { + if (value != null) { + environment.put(name, value); + } + } + + /** Put {@link Boolean} value "true" or "false" in environment if value set. */ + public static void putToEnvIfSet(@NonNull HashMap environment, @NonNull String name, + @Nullable Boolean value) { + if (value != null) { + environment.put(name, String.valueOf(value)); + } + } + + + + /** Create HOME directory in environment {@link Map} if set. */ + public static void createHomeDir(@NonNull HashMap environment) { + String homeDirectory = environment.get(ENV_HOME); + if (homeDirectory != null && !homeDirectory.isEmpty()) { + Error error = FileUtils.createDirectoryFile("shell home", homeDirectory); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, "Failed to create shell home directory\n" + error.toString()); + } + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java new file mode 100644 index 000000000..f4abee3a2 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java @@ -0,0 +1,28 @@ +package com.termux.shared.shell.command.environment; + +public class ShellEnvironmentVariable implements Comparable { + + /** The name for environment variable */ + public String name; + + /** The value for environment variable */ + public String value; + + /** If environment variable {@link #value} is already escaped. */ + public boolean escaped; + + public ShellEnvironmentVariable(String name, String value) { + this(name, value, false); + } + + public ShellEnvironmentVariable(String name, String value, boolean escaped) { + this.name = name; + this.value = value; + this.escaped = escaped; + } + + @Override + public int compareTo(ShellEnvironmentVariable other) { + return this.name.compareTo(other.name); + } +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java new file mode 100644 index 000000000..7f29a6403 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java @@ -0,0 +1,83 @@ +package com.termux.shared.shell.command.environment; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.shell.ShellUtils; +import com.termux.shared.shell.command.ExecutionCommand; + +import java.util.HashMap; + +/** + * Environment for Unix-like systems. + * + * https://manpages.debian.org/testing/manpages/environ.7.en.html + * https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html + */ +public abstract class UnixShellEnvironment implements IShellEnvironment { + + /** Environment variable for the terminal's colour capabilities. */ + public static final String ENV_COLORTERM = "COLORTERM"; + + /** Environment variable for the path of the user's home directory. */ + public static final String ENV_HOME = "HOME"; + + /** Environment variable for the locale category for native language, local customs, and coded + * character set in the absence of the LC_ALL and other LC_* environment variables. */ + public static final String ENV_LANG = "LANG"; + + /** Environment variable for the represent the sequence of directory paths separated with + * colons ":" that should be searched in for dynamic shared libraries to link programs against. */ + public static final String ENV_LD_LIBRARY_PATH = "LD_LIBRARY_PATH"; + + /** Environment variable for the represent the sequence of directory path prefixes separated with + * colons ":" that certain functions and utilities apply in searching for an executable file + * known only by a filename. */ + public static final String ENV_PATH = "PATH"; + + /** Environment variable for the absolute path of the current working directory. It shall not + * contain any components that are dot or dot-dot. The value is set by the cd utility, and by + * the sh utility during initialization. */ + public static final String ENV_PWD = "PWD"; + + /** Environment variable for the terminal type for which output is to be prepared. This information + * is used by utilities and application programs wishing to exploit special capabilities specific + * to a terminal. The format and allowable values of this environment variable are unspecified. */ + public static final String ENV_TERM = "TERM"; + + /** Environment variable for the path of a directory made available for programs that need a place + * to create temporary files. */ + public static final String ENV_TMPDIR = "TMPDIR"; + + + /** Names for common/supported login shell binaries. */ + public static final String[] LOGIN_SHELL_BINARIES = new String[]{"login", "bash", "zsh", "fish", "sh"}; + + + + @NonNull + public abstract HashMap getEnvironment(@NonNull Context currentPackageContext, + boolean isFailSafe); + + @NonNull + @Override + public abstract String getDefaultWorkingDirectoryPath(); + + @NonNull + @Override + public abstract String getDefaultBinPath(); + + @NonNull + @Override + public String[] setupShellCommandArguments(@NonNull String executable, @Nullable String[] arguments) { + return ShellUtils.setupShellCommandArguments(executable, arguments); + } + + @NonNull + @Override + public abstract HashMap setupShellCommandEnvironment(@NonNull Context currentPackageContext, + @NonNull ExecutionCommand executionCommand); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java new file mode 100644 index 000000000..e3ab5cafc --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java @@ -0,0 +1,170 @@ +package com.termux.shared.shell.command.result; + +import android.app.PendingIntent; + +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; + +import java.util.Formatter; + +public class ResultConfig { + + /** Defines {@link PendingIntent} that should be sent with the result of the command. We cannot + * implement {@link java.io.Serializable} because {@link PendingIntent} cannot be serialized. */ + public PendingIntent resultPendingIntent; + /** The key with which to send result {@link android.os.Bundle} in {@link #resultPendingIntent}. */ + public String resultBundleKey; + /** The key with which to send {@link ResultData#stdout} in {@link #resultPendingIntent}. */ + public String resultStdoutKey; + /** The key with which to send {@link ResultData#stderr} in {@link #resultPendingIntent}. */ + public String resultStderrKey; + /** The key with which to send {@link ResultData#exitCode} in {@link #resultPendingIntent}. */ + public String resultExitCodeKey; + /** The key with which to send {@link ResultData#errorsList} errCode in {@link #resultPendingIntent}. */ + public String resultErrCodeKey; + /** The key with which to send {@link ResultData#errorsList} errmsg in {@link #resultPendingIntent}. */ + public String resultErrmsgKey; + /** The key with which to send original length of {@link ResultData#stdout} in {@link #resultPendingIntent}. */ + public String resultStdoutOriginalLengthKey; + /** The key with which to send original length of {@link ResultData#stderr} in {@link #resultPendingIntent}. */ + public String resultStderrOriginalLengthKey; + + + /** Defines the directory path in which to write the result of the command. */ + public String resultDirectoryPath; + /** Defines the directory path under which {@link #resultDirectoryPath} can exist. */ + public String resultDirectoryAllowedParentPath; + /** Defines whether the result should be written to a single file or multiple files + * (err, error, stdout, stderr, exit_code) in {@link #resultDirectoryPath}. */ + public boolean resultSingleFile; + /** Defines the basename of the result file that should be created in {@link #resultDirectoryPath} + * if {@link #resultSingleFile} is {@code true}. */ + public String resultFileBasename; + /** Defines the output {@link Formatter} format of the {@link #resultFileBasename} result file. */ + public String resultFileOutputFormat; + /** Defines the error {@link Formatter} format of the {@link #resultFileBasename} result file. */ + public String resultFileErrorFormat; + /** Defines the suffix of the result files that should be created in {@link #resultDirectoryPath} + * if {@link #resultSingleFile} is {@code true}. */ + public String resultFilesSuffix; + + + public ResultConfig() { + } + + + public boolean isCommandWithPendingResult() { + return resultPendingIntent != null || resultDirectoryPath != null; + } + + + @NonNull + @Override + public String toString() { + return getResultConfigLogString(this, true); + } + + /** + * Get a log friendly {@link String} for {@link ResultConfig} parameters. + * + * @param resultConfig The {@link ResultConfig} to convert. + * @param ignoreNull Set to {@code true} if non-critical {@code null} values are to be ignored. + * @return Returns the log friendly {@link String}. + */ + public static String getResultConfigLogString(final ResultConfig resultConfig, boolean ignoreNull) { + if (resultConfig == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + logString.append("Result Pending: `").append(resultConfig.isCommandWithPendingResult()).append("`\n"); + + if (resultConfig.resultPendingIntent != null) { + logString.append(resultConfig.getResultPendingIntentVariablesLogString(ignoreNull)); + if (resultConfig.resultDirectoryPath != null) + logString.append("\n"); + } + + if (resultConfig.resultDirectoryPath != null && !resultConfig.resultDirectoryPath.isEmpty()) + logString.append(resultConfig.getResultDirectoryVariablesLogString(ignoreNull)); + + return logString.toString(); + } + + public String getResultPendingIntentVariablesLogString(boolean ignoreNull) { + if (resultPendingIntent == null) return "Result PendingIntent Creator: -"; + + StringBuilder resultPendingIntentVariablesString = new StringBuilder(); + + resultPendingIntentVariablesString.append("Result PendingIntent Creator: `").append(resultPendingIntent.getCreatorPackage()).append("`"); + + if (!ignoreNull || resultBundleKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Bundle Key", resultBundleKey, "-")); + if (!ignoreNull || resultStdoutKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stdout Key", resultStdoutKey, "-")); + if (!ignoreNull || resultStderrKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stderr Key", resultStderrKey, "-")); + if (!ignoreNull || resultExitCodeKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Exit Code Key", resultExitCodeKey, "-")); + if (!ignoreNull || resultErrCodeKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Err Code Key", resultErrCodeKey, "-")); + if (!ignoreNull || resultErrmsgKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Error Key", resultErrmsgKey, "-")); + if (!ignoreNull || resultStdoutOriginalLengthKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stdout Original Length Key", resultStdoutOriginalLengthKey, "-")); + if (!ignoreNull || resultStderrOriginalLengthKey != null) + resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stderr Original Length Key", resultStderrOriginalLengthKey, "-")); + + return resultPendingIntentVariablesString.toString(); + } + + public String getResultDirectoryVariablesLogString(boolean ignoreNull) { + if (resultDirectoryPath == null) return "Result Directory Path: -"; + + StringBuilder resultDirectoryVariablesString = new StringBuilder(); + + resultDirectoryVariablesString.append(Logger.getSingleLineLogStringEntry("Result Directory Path", resultDirectoryPath, "-")); + + resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Single File", resultSingleFile, "-")); + if (!ignoreNull || resultFileBasename != null) + resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result File Basename", resultFileBasename, "-")); + if (!ignoreNull || resultFileOutputFormat != null) + resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result File Output Format", resultFileOutputFormat, "-")); + if (!ignoreNull || resultFileErrorFormat != null) + resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result File Error Format", resultFileErrorFormat, "-")); + if (!ignoreNull || resultFilesSuffix != null) + resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Files Suffix", resultFilesSuffix, "-")); + + return resultDirectoryVariablesString.toString(); + } + + /** + * Get a markdown {@link String} for {@link ResultConfig}. + * + * @param resultConfig The {@link ResultConfig} to convert. + * @return Returns the markdown {@link String}. + */ + public static String getResultConfigMarkdownString(final ResultConfig resultConfig) { + if (resultConfig == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + if (resultConfig.resultPendingIntent != null) + markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result PendingIntent Creator", resultConfig.resultPendingIntent.getCreatorPackage(), "-")); + else + markdownString.append("**Result PendingIntent Creator:** - "); + + if (resultConfig.resultDirectoryPath != null) { + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result Directory Path", resultConfig.resultDirectoryPath, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result Single File", resultConfig.resultSingleFile, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result File Basename", resultConfig.resultFileBasename, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result File Output Format", resultConfig.resultFileOutputFormat, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result File Error Format", resultConfig.resultFileErrorFormat, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result Files Suffix", resultConfig.resultFilesSuffix, "-")); + } + + return markdownString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java new file mode 100644 index 000000000..3e4253208 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java @@ -0,0 +1,258 @@ +package com.termux.shared.shell.command.result; + +import androidx.annotation.NonNull; + +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.errors.Errno; +import com.termux.shared.errors.Error; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ResultData implements Serializable { + + /** The stdout of command. */ + public final StringBuilder stdout = new StringBuilder(); + /** The stderr of command. */ + public final StringBuilder stderr = new StringBuilder(); + /** The exit code of command. */ + public Integer exitCode; + + /** The internal errors list of command. */ + public List errorsList = new ArrayList<>(); + + + public ResultData() { + } + + + public void clearStdout() { + stdout.setLength(0); + } + + public StringBuilder prependStdout(String message) { + return stdout.insert(0, message); + } + + public StringBuilder prependStdoutLn(String message) { + return stdout.insert(0, message + "\n"); + } + + public StringBuilder appendStdout(String message) { + return stdout.append(message); + } + + public StringBuilder appendStdoutLn(String message) { + return stdout.append(message).append("\n"); + } + + + public void clearStderr() { + stderr.setLength(0); + } + + public StringBuilder prependStderr(String message) { + return stderr.insert(0, message); + } + + public StringBuilder prependStderrLn(String message) { + return stderr.insert(0, message + "\n"); + } + + public StringBuilder appendStderr(String message) { + return stderr.append(message); + } + + public StringBuilder appendStderrLn(String message) { + return stderr.append(message).append("\n"); + } + + + public synchronized boolean setStateFailed(@NonNull Error error) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), null); + } + + public synchronized boolean setStateFailed(@NonNull Error error, Throwable throwable) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), Collections.singletonList(throwable)); + } + public synchronized boolean setStateFailed(@NonNull Error error, List throwablesList) { + return setStateFailed(error.getType(), error.getCode(), error.getMessage(), throwablesList); + } + + public synchronized boolean setStateFailed(int code, String message) { + return setStateFailed(null, code, message, null); + } + + public synchronized boolean setStateFailed(int code, String message, Throwable throwable) { + return setStateFailed(null, code, message, Collections.singletonList(throwable)); + } + + public synchronized boolean setStateFailed(int code, String message, List throwablesList) { + return setStateFailed(null, code, message, throwablesList); + } + + public synchronized boolean setStateFailed(String type, int code, String message, List throwablesList) { + if (errorsList == null) + errorsList = new ArrayList<>(); + + Error error = new Error(); + errorsList.add(error); + + return error.setStateFailed(type, code, message, throwablesList); + } + + public boolean isStateFailed() { + if (errorsList != null) { + for (Error error : errorsList) + if (error.isStateFailed()) + return true; + } + + return false; + } + + public int getErrCode() { + if (errorsList != null && errorsList.size() > 0) + return errorsList.get(errorsList.size() - 1).getCode(); + else + return Errno.ERRNO_SUCCESS.getCode(); + } + + + @NonNull + @Override + public String toString() { + return getResultDataLogString(this, true); + } + + /** + * Get a log friendly {@link String} for {@link ResultData} parameters. + * + * @param resultData The {@link ResultData} to convert. + * @param logStdoutAndStderr Set to {@code true} if {@link #stdout} and {@link #stderr} should be logged. + * @return Returns the log friendly {@link String}. + */ + public static String getResultDataLogString(final ResultData resultData, boolean logStdoutAndStderr) { + if (resultData == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + if (logStdoutAndStderr) { + logString.append("\n").append(resultData.getStdoutLogString()); + logString.append("\n").append(resultData.getStderrLogString()); + } + logString.append("\n").append(resultData.getExitCodeLogString()); + + logString.append("\n\n").append(getErrorsListLogString(resultData)); + + return logString.toString(); + } + + + + public String getStdoutLogString() { + if (stdout.toString().isEmpty()) + return Logger.getSingleLineLogStringEntry("Stdout", null, "-"); + else + return Logger.getMultiLineLogStringEntry("Stdout", DataUtils.getTruncatedCommandOutput(stdout.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD / 5, false, false, true), "-"); + } + + public String getStderrLogString() { + if (stderr.toString().isEmpty()) + return Logger.getSingleLineLogStringEntry("Stderr", null, "-"); + else + return Logger.getMultiLineLogStringEntry("Stderr", DataUtils.getTruncatedCommandOutput(stderr.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD / 5, false, false, true), "-"); + } + + public String getExitCodeLogString() { + return Logger.getSingleLineLogStringEntry("Exit Code", exitCode, "-"); + } + + public static String getErrorsListLogString(final ResultData resultData) { + if (resultData == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + if (resultData.errorsList != null) { + for (Error error : resultData.errorsList) { + if (error.isStateFailed()) { + if (!logString.toString().isEmpty()) + logString.append("\n"); + logString.append(Error.getErrorLogString(error)); + } + } + } + + return logString.toString(); + } + + /** + * Get a markdown {@link String} for {@link ResultData}. + * + * @param resultData The {@link ResultData} to convert. + * @return Returns the markdown {@link String}. + */ + public static String getResultDataMarkdownString(final ResultData resultData) { + if (resultData == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + if (resultData.stdout.toString().isEmpty()) + markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Stdout", null, "-")); + else + markdownString.append(MarkdownUtils.getMultiLineMarkdownStringEntry("Stdout", resultData.stdout.toString(), "-")); + + if (resultData.stderr.toString().isEmpty()) + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Stderr", null, "-")); + else + markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Stderr", resultData.stderr.toString(), "-")); + + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Exit Code", resultData.exitCode, "-")); + + markdownString.append("\n\n").append(getErrorsListMarkdownString(resultData)); + + + return markdownString.toString(); + } + + public static String getErrorsListMarkdownString(final ResultData resultData) { + if (resultData == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + if (resultData.errorsList != null) { + for (Error error : resultData.errorsList) { + if (error.isStateFailed()) { + if (!markdownString.toString().isEmpty()) + markdownString.append("\n"); + markdownString.append(Error.getErrorMarkdownString(error)); + } + } + } + + return markdownString.toString(); + } + + public static String getErrorsListMinimalString(final ResultData resultData) { + if (resultData == null) return "null"; + + StringBuilder minimalString = new StringBuilder(); + + if (resultData.errorsList != null) { + for (Error error : resultData.errorsList) { + if (error.isStateFailed()) { + if (!minimalString.toString().isEmpty()) + minimalString.append("\n"); + minimalString.append(Error.getMinimalErrorString(error)); + } + } + } + + return minimalString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java new file mode 100644 index 000000000..181afcaa2 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java @@ -0,0 +1,349 @@ +package com.termux.shared.shell.command.result; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.errors.FunctionErrno; +import com.termux.shared.android.AndroidUtils; +import com.termux.shared.shell.command.ShellCommandConstants.RESULT_SENDER; + +public class ResultSender { + + private static final String LOG_TAG = "ResultSender"; + + /** + * Send result stored in {@link ResultConfig} to command caller via + * {@link ResultConfig#resultPendingIntent} and/or by writing it to files in + * {@link ResultConfig#resultDirectoryPath}. If both are not {@code null}, then result will be + * sent via both. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param label The label for the command. + * @param resultConfig The {@link ResultConfig} object containing information on how to send the result. + * @param resultData The {@link ResultData} object containing result data. + * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr} + * should be logged. + * @return Returns the {@link Error} if failed to send the result, otherwise {@code null}. + */ + public static Error sendCommandResultData(Context context, String logTag, String label, ResultConfig resultConfig, ResultData resultData, boolean logStdoutAndStderr) { + if (context == null || resultConfig == null || resultData == null) + return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETERS.getError("context, resultConfig or resultData", "sendCommandResultData"); + + Error error; + + if (resultConfig.resultPendingIntent != null) { + error = sendCommandResultDataWithPendingIntent(context, logTag, label, resultConfig, resultData, logStdoutAndStderr); + if (error != null || resultConfig.resultDirectoryPath == null) + return error; + } + + if (resultConfig.resultDirectoryPath != null) { + return sendCommandResultDataToDirectory(context, logTag, label, resultConfig, resultData, logStdoutAndStderr); + } else { + return FunctionErrno.ERRNO_UNSET_PARAMETERS.getError("resultConfig.resultPendingIntent or resultConfig.resultDirectoryPath", "sendCommandResultData"); + } + } + + /** + * Send result stored in {@link ResultConfig} to command caller via {@link ResultConfig#resultPendingIntent}. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param label The label for the command. + * @param resultConfig The {@link ResultConfig} object containing information on how to send the result. + * @param resultData The {@link ResultData} object containing result data. + * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr} + * should be logged. + * @return Returns the {@link Error} if failed to send the result, otherwise {@code null}. + */ + public static Error sendCommandResultDataWithPendingIntent(Context context, String logTag, String label, ResultConfig resultConfig, ResultData resultData, boolean logStdoutAndStderr) { + if (context == null || resultConfig == null || resultData == null || resultConfig.resultPendingIntent == null || resultConfig.resultBundleKey == null) + return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError("context, resultConfig, resultData, resultConfig.resultPendingIntent or resultConfig.resultBundleKey", "sendCommandResultDataWithPendingIntent"); + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + Logger.logDebugExtended(logTag, "Sending result for command \"" + label + "\":\n" + resultConfig.toString() + "\n" + ResultData.getResultDataLogString(resultData, logStdoutAndStderr)); + + String resultDataStdout = resultData.stdout.toString(); + String resultDataStderr = resultData.stderr.toString(); + + String truncatedStdout = null; + String truncatedStderr = null; + + String stdoutOriginalLength = String.valueOf(resultDataStdout.length()); + String stderrOriginalLength = String.valueOf(resultDataStderr.length()); + + // Truncate stdout and stdout to max TRANSACTION_SIZE_LIMIT_IN_BYTES + if (resultDataStderr.isEmpty()) { + truncatedStdout = DataUtils.getTruncatedCommandOutput(resultDataStdout, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false); + } else if (resultDataStdout.isEmpty()) { + truncatedStderr = DataUtils.getTruncatedCommandOutput(resultDataStderr, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false); + } else { + truncatedStdout = DataUtils.getTruncatedCommandOutput(resultDataStdout, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 2, false, false, false); + truncatedStderr = DataUtils.getTruncatedCommandOutput(resultDataStderr, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 2, false, false, false); + } + + if (truncatedStdout != null && truncatedStdout.length() < resultDataStdout.length()) { + Logger.logWarn(logTag, "The result for command \"" + label + "\" stdout length truncated from " + stdoutOriginalLength + " to " + truncatedStdout.length()); + resultDataStdout = truncatedStdout; + } + + if (truncatedStderr != null && truncatedStderr.length() < resultDataStderr.length()) { + Logger.logWarn(logTag, "The result for command \"" + label + "\" stderr length truncated from " + stderrOriginalLength + " to " + truncatedStderr.length()); + resultDataStderr = truncatedStderr; + } + + String resultDataErrmsg = null; + if (resultData.isStateFailed()) { + resultDataErrmsg = ResultData.getErrorsListLogString(resultData); + if (resultDataErrmsg.isEmpty()) resultDataErrmsg = null; + } + + String errmsgOriginalLength = (resultDataErrmsg == null) ? null : String.valueOf(resultDataErrmsg.length()); + + // Truncate error to max TRANSACTION_SIZE_LIMIT_IN_BYTES / 4 + // trim from end to preserve start of stacktraces + String truncatedErrmsg = DataUtils.getTruncatedCommandOutput(resultDataErrmsg, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 4, true, false, false); + if (truncatedErrmsg != null && truncatedErrmsg.length() < resultDataErrmsg.length()) { + Logger.logWarn(logTag, "The result for command \"" + label + "\" error length truncated from " + errmsgOriginalLength + " to " + truncatedErrmsg.length()); + resultDataErrmsg = truncatedErrmsg; + } + + + final Bundle resultBundle = new Bundle(); + resultBundle.putString(resultConfig.resultStdoutKey, resultDataStdout); + resultBundle.putString(resultConfig.resultStdoutOriginalLengthKey, stdoutOriginalLength); + resultBundle.putString(resultConfig.resultStderrKey, resultDataStderr); + resultBundle.putString(resultConfig.resultStderrOriginalLengthKey, stderrOriginalLength); + if (resultData.exitCode != null) + resultBundle.putInt(resultConfig.resultExitCodeKey, resultData.exitCode); + resultBundle.putInt(resultConfig.resultErrCodeKey, resultData.getErrCode()); + resultBundle.putString(resultConfig.resultErrmsgKey, resultDataErrmsg); + + Intent resultIntent = new Intent(); + resultIntent.putExtra(resultConfig.resultBundleKey, resultBundle); + + try { + resultConfig.resultPendingIntent.send(context, Activity.RESULT_OK, resultIntent); + } catch (PendingIntent.CanceledException e) { + // The caller doesn't want the result? That's fine, just ignore + Logger.logDebug(logTag, "The command \"" + label + "\" creator " + resultConfig.resultPendingIntent.getCreatorPackage() + " does not want the results anymore"); + } + + return null; + + } + + /** + * Send result stored in {@link ResultConfig} to command caller by writing it to files in + * {@link ResultConfig#resultDirectoryPath}. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param label The label for the command. + * @param resultConfig The {@link ResultConfig} object containing information on how to send the result. + * @param resultData The {@link ResultData} object containing result data. + * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr} + * should be logged. + * @return Returns the {@link Error} if failed to send the result, otherwise {@code null}. + */ + public static Error sendCommandResultDataToDirectory(Context context, String logTag, String label, ResultConfig resultConfig, ResultData resultData, boolean logStdoutAndStderr) { + if (context == null || resultConfig == null || resultData == null || DataUtils.isNullOrEmpty(resultConfig.resultDirectoryPath)) + return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError("context, resultConfig, resultData or resultConfig.resultDirectoryPath", "sendCommandResultDataToDirectory"); + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + Error error; + + String resultDataStdout = resultData.stdout.toString(); + String resultDataStderr = resultData.stderr.toString(); + + String resultDataExitCode = ""; + if (resultData.exitCode != null) + resultDataExitCode = String.valueOf(resultData.exitCode); + + String resultDataErrmsg = null; + if (resultData.isStateFailed()) { + resultDataErrmsg = ResultData.getErrorsListLogString(resultData); + } + resultDataErrmsg = DataUtils.getDefaultIfNull(resultDataErrmsg, ""); + + resultConfig.resultDirectoryPath = FileUtils.getCanonicalPath(resultConfig.resultDirectoryPath, null); + + Logger.logDebugExtended(logTag, "Writing result for command \"" + label + "\":\n" + resultConfig.toString() + "\n" + ResultData.getResultDataLogString(resultData, logStdoutAndStderr)); + + // If resultDirectoryPath is not a directory, or is not readable or writable, then just return + // Creation of missing directory and setting of read, write and execute permissions are + // only done if resultDirectoryPath is under resultDirectoryAllowedParentPath. + // We try to set execute permissions, but ignore if they are missing, since only read and write + // permissions are required for working directories. + error = FileUtils.validateDirectoryFileExistenceAndPermissions("result", resultConfig.resultDirectoryPath, + resultConfig.resultDirectoryAllowedParentPath, true, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, true, true, + true, true); + if (error != null) { + error.appendMessage("\n" + context.getString(R.string.msg_directory_absolute_path, "Result", resultConfig.resultDirectoryPath)); + return error; + } + + if (resultConfig.resultSingleFile) { + // If resultFileBasename is null, empty or contains forward slashes "/" + if (DataUtils.isNullOrEmpty(resultConfig.resultFileBasename) || + resultConfig.resultFileBasename.contains("/")) { + error = ResultSenderErrno.ERROR_RESULT_FILE_BASENAME_NULL_OR_INVALID.getError(resultConfig.resultFileBasename); + return error; + } + + String error_or_output; + + if (resultData.isStateFailed()) { + try { + if (DataUtils.isNullOrEmpty(resultConfig.resultFileErrorFormat)) { + error_or_output = String.format(RESULT_SENDER.FORMAT_FAILED_ERR__ERRMSG__STDOUT__STDERR__EXIT_CODE, + MarkdownUtils.getMarkdownCodeForString(String.valueOf(resultData.getErrCode()), false), + MarkdownUtils.getMarkdownCodeForString(resultDataErrmsg, true), + MarkdownUtils.getMarkdownCodeForString(resultDataStdout, true), + MarkdownUtils.getMarkdownCodeForString(resultDataStderr, true), + MarkdownUtils.getMarkdownCodeForString(resultDataExitCode, false)); + } else { + error_or_output = String.format(resultConfig.resultFileErrorFormat, + resultData.getErrCode(), resultDataErrmsg, resultDataStdout, resultDataStderr, resultDataExitCode); + } + } catch (Exception e) { + error = ResultSenderErrno.ERROR_FORMAT_RESULT_ERROR_FAILED_WITH_EXCEPTION.getError(e.getMessage()); + return error; + } + } else { + try { + if (DataUtils.isNullOrEmpty(resultConfig.resultFileOutputFormat)) { + if (resultDataStderr.isEmpty() && resultDataExitCode.equals("0")) + error_or_output = String.format(RESULT_SENDER.FORMAT_SUCCESS_STDOUT, resultDataStdout); + else if (resultDataStderr.isEmpty()) + error_or_output = String.format(RESULT_SENDER.FORMAT_SUCCESS_STDOUT__EXIT_CODE, + resultDataStdout, + MarkdownUtils.getMarkdownCodeForString(resultDataExitCode, false)); + else + error_or_output = String.format(RESULT_SENDER.FORMAT_SUCCESS_STDOUT__STDERR__EXIT_CODE, + MarkdownUtils.getMarkdownCodeForString(resultDataStdout, true), + MarkdownUtils.getMarkdownCodeForString(resultDataStderr, true), + MarkdownUtils.getMarkdownCodeForString(resultDataExitCode, false)); + } else { + error_or_output = String.format(resultConfig.resultFileOutputFormat, + resultDataStdout, resultDataStderr, resultDataExitCode); + } + } catch (Exception e) { + error = ResultSenderErrno.ERROR_FORMAT_RESULT_OUTPUT_FAILED_WITH_EXCEPTION.getError(e.getMessage()); + return error; + } + } + + // Write error or output to temp file + // Check errCode file creation below for explanation for why temp file is used + String temp_filename = resultConfig.resultFileBasename + "-" + AndroidUtils.getCurrentMilliSecondLocalTimeStamp(); + error = FileUtils.writeTextToFile(temp_filename, resultConfig.resultDirectoryPath + "/" + temp_filename, + null, error_or_output, false); + if (error != null) { + return error; + } + + // Move error or output temp file to final destination + error = FileUtils.moveRegularFile("error or output temp file", resultConfig.resultDirectoryPath + "/" + temp_filename, + resultConfig.resultDirectoryPath + "/" + resultConfig.resultFileBasename, false); + if (error != null) { + return error; + } + } else { + String filename; + + // Default to no suffix, useful if user expects result in an empty directory, like created with mktemp + if (resultConfig.resultFilesSuffix == null) + resultConfig.resultFilesSuffix = ""; + + // If resultFilesSuffix contains forward slashes "/" + if (resultConfig.resultFilesSuffix.contains("/")) { + error = ResultSenderErrno.ERROR_RESULT_FILES_SUFFIX_INVALID.getError(resultConfig.resultFilesSuffix); + return error; + } + + // Write result to result files under resultDirectoryPath + + // Write stdout to file + if (!resultDataStdout.isEmpty()) { + filename = RESULT_SENDER.RESULT_FILE_STDOUT_PREFIX + resultConfig.resultFilesSuffix; + error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename, + null, resultDataStdout, false); + if (error != null) { + return error; + } + } + + // Write stderr to file + if (!resultDataStderr.isEmpty()) { + filename = RESULT_SENDER.RESULT_FILE_STDERR_PREFIX + resultConfig.resultFilesSuffix; + error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename, + null, resultDataStderr, false); + if (error != null) { + return error; + } + } + + // Write exitCode to file + if (!resultDataExitCode.isEmpty()) { + filename = RESULT_SENDER.RESULT_FILE_EXIT_CODE_PREFIX + resultConfig.resultFilesSuffix; + error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename, + null, resultDataExitCode, false); + if (error != null) { + return error; + } + } + + // Write errmsg to file + if (resultData.isStateFailed() && !resultDataErrmsg.isEmpty()) { + filename = RESULT_SENDER.RESULT_FILE_ERRMSG_PREFIX + resultConfig.resultFilesSuffix; + error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename, + null, resultDataErrmsg, false); + if (error != null) { + return error; + } + } + + // Write errCode to file + // This must be created after writing to other result files has already finished since + // caller should wait for this file to be created to be notified that the command has + // finished and should then start reading from the rest of the result files if they exist. + // Since there may be a delay between creation of errCode file and writing to it or flushing + // to disk, we create a temp file first and then move it to the final destination, since + // caller may otherwise read from an empty file in some cases. + + // Write errCode to temp file + String temp_filename = RESULT_SENDER.RESULT_FILE_ERR_PREFIX + "-" + AndroidUtils.getCurrentMilliSecondLocalTimeStamp(); + if (!resultConfig.resultFilesSuffix.isEmpty()) temp_filename = temp_filename + "-" + resultConfig.resultFilesSuffix; + error = FileUtils.writeTextToFile(temp_filename, resultConfig.resultDirectoryPath + "/" + temp_filename, + null, String.valueOf(resultData.getErrCode()), false); + if (error != null) { + return error; + } + + // Move errCode temp file to final destination + filename = RESULT_SENDER.RESULT_FILE_ERR_PREFIX + resultConfig.resultFilesSuffix; + error = FileUtils.moveRegularFile(RESULT_SENDER.RESULT_FILE_ERR_PREFIX + " temp file", resultConfig.resultDirectoryPath + "/" + temp_filename, + resultConfig.resultDirectoryPath + "/" + filename, false); + if (error != null) { + return error; + } + } + + return null; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java new file mode 100644 index 000000000..e21a146cd --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java @@ -0,0 +1,22 @@ +package com.termux.shared.shell.command.result; + +import com.termux.shared.errors.Errno; + +/** The {@link Class} that defines ResultSender error messages and codes. */ +public class ResultSenderErrno extends Errno { + + public static final String TYPE = "ResultSender Error"; + + + /* Errors for null or empty parameters (100-150) */ + public static final Errno ERROR_RESULT_FILE_BASENAME_NULL_OR_INVALID = new Errno(TYPE, 100, "The result file basename \"%1$s\" is null, empty or contains forward slashes \"/\"."); + public static final Errno ERROR_RESULT_FILES_SUFFIX_INVALID = new Errno(TYPE, 101, "The result files suffix \"%1$s\" contains forward slashes \"/\"."); + public static final Errno ERROR_FORMAT_RESULT_ERROR_FAILED_WITH_EXCEPTION = new Errno(TYPE, 102, "Formatting result error failed.\nException: %1$s"); + public static final Errno ERROR_FORMAT_RESULT_OUTPUT_FAILED_WITH_EXCEPTION = new Errno(TYPE, 103, "Formatting result output failed.\nException: %1$s"); + + + ResultSenderErrno(final String type, final int code, final String message) { + super(type, code, message); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java new file mode 100644 index 000000000..3f49e5aeb --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java @@ -0,0 +1,349 @@ +package com.termux.shared.shell.command.runner.app; + +import android.content.Context; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.base.Joiner; +import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.environment.ShellEnvironmentUtils; +import com.termux.shared.shell.command.result.ResultData; +import com.termux.shared.errors.Errno; +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.ExecutionCommand.ExecutionState; +import com.termux.shared.shell.command.environment.IShellEnvironment; +import com.termux.shared.shell.ShellUtils; +import com.termux.shared.shell.StreamGobbler; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +/** + * A class that maintains info for background app shells run with {@link Runtime#exec(String[], String[], File)}. + * It also provides a way to link each {@link Process} with the {@link ExecutionCommand} + * that started it. The shell is run in the app user context. + */ +public final class AppShell { + + private final Process mProcess; + private final ExecutionCommand mExecutionCommand; + private final AppShellClient mAppShellClient; + + private static final String LOG_TAG = "AppShell"; + + private AppShell(@NonNull final Process process, @NonNull final ExecutionCommand executionCommand, + final AppShellClient appShellClient) { + this.mProcess = process; + this.mExecutionCommand = executionCommand; + this.mAppShellClient = appShellClient; + } + + /** + * Start execution of an {@link ExecutionCommand} with {@link Runtime#exec(String[], String[], File)}. + * + * The {@link ExecutionCommand#executable}, must be set. + * The {@link ExecutionCommand#commandLabel}, {@link ExecutionCommand#arguments} and + * {@link ExecutionCommand#workingDirectory} may optionally be set. + * + * @param currentPackageContext The {@link Context} for operations. This must be the context for + * the current package and not the context of a `sharedUserId` package, + * since environment setup may be dependent on current package. + * @param executionCommand The {@link ExecutionCommand} containing the information for execution command. + * @param appShellClient The {@link AppShellClient} interface implementation. + * The {@link AppShellClient#onAppShellExited(AppShell)} will + * be called regardless of {@code isSynchronous} value but not if + * {@code null} is returned by this method. This can + * optionally be {@code null}. + * @param shellEnvironmentClient The {@link IShellEnvironment} interface implementation. + * @param additionalEnvironment The additional shell environment variables to export. Existing + * variables will be overridden. + * @param isSynchronous If set to {@code true}, then the command will be executed in the + * caller thread and results returned synchronously in the {@link ExecutionCommand} + * sub object of the {@link AppShell} returned. + * If set to {@code false}, then a new thread is started run the commands + * asynchronously in the background and control is returned to the caller thread. + * @return Returns the {@link AppShell}. This will be {@code null} if failed to start the execution command. + */ + public static AppShell execute(@NonNull final Context currentPackageContext, @NonNull ExecutionCommand executionCommand, + final AppShellClient appShellClient, + @NonNull final IShellEnvironment shellEnvironmentClient, + @Nullable HashMap additionalEnvironment, + final boolean isSynchronous) { + if (executionCommand.executable == null || executionCommand.executable.isEmpty()) { + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), + currentPackageContext.getString(R.string.error_executable_unset, executionCommand.getCommandIdAndLabelLogString())); + AppShell.processAppShellResult(null, executionCommand); + return null; + } + + if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty()) + executionCommand.workingDirectory = shellEnvironmentClient.getDefaultWorkingDirectoryPath(); + if (executionCommand.workingDirectory.isEmpty()) + executionCommand.workingDirectory = "/"; + + // Transform executable path to shell/session name, e.g. "/bin/do-something.sh" => "do-something.sh". + String executableBasename = ShellUtils.getExecutableBasename(executionCommand.executable); + + if (executionCommand.shellName == null) + executionCommand.shellName = executableBasename; + + if (executionCommand.commandLabel == null) + executionCommand.commandLabel = executableBasename; + + // Setup command args + final String[] commandArray = shellEnvironmentClient.setupShellCommandArguments(executionCommand.executable, executionCommand.arguments); + + // Setup command environment + HashMap environment = shellEnvironmentClient.setupShellCommandEnvironment(currentPackageContext, + executionCommand); + if (additionalEnvironment != null) + environment.putAll(additionalEnvironment); + List environmentList = ShellEnvironmentUtils.convertEnvironmentToEnviron(environment); + Collections.sort(environmentList); + String[] environmentArray = environmentList.toArray(new String[0]); + + if (!executionCommand.setState(ExecutionState.EXECUTING)) { + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), currentPackageContext.getString(R.string.error_failed_to_execute_app_shell_command, executionCommand.getCommandIdAndLabelLogString())); + AppShell.processAppShellResult(null, executionCommand); + return null; + } + + // No need to log stdin if logging is disabled, like for app internal scripts + Logger.logDebugExtended(LOG_TAG, ExecutionCommand.getExecutionInputLogString(executionCommand, + true, Logger.shouldEnableLoggingForCustomLogLevel(executionCommand.backgroundCustomLogLevel))); + Logger.logVerboseExtended(LOG_TAG, "\"" + executionCommand.getCommandIdAndLabelLogString() + "\" AppShell Environment:\n" + + Joiner.on("\n").join(environmentArray)); + + // Exec the process + final Process process; + try { + process = Runtime.getRuntime().exec(commandArray, environmentArray, new File(executionCommand.workingDirectory)); + } catch (IOException e) { + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), currentPackageContext.getString(R.string.error_failed_to_execute_app_shell_command, executionCommand.getCommandIdAndLabelLogString()), e); + AppShell.processAppShellResult(null, executionCommand); + return null; + } + + final AppShell appShell = new AppShell(process, executionCommand, appShellClient); + if (isSynchronous) { + try { + appShell.executeInner(currentPackageContext); + } catch (IllegalThreadStateException | InterruptedException e) { + // TODO: Should either of these be handled or returned? + } + } else { + new Thread() { + @Override + public void run() { + try { + appShell.executeInner(currentPackageContext); + } catch (IllegalThreadStateException | InterruptedException e) { + // TODO: Should either of these be handled or returned? + } + } + }.start(); + } + + return appShell; + } + + /** + * Sets up stdout and stderr readers for the {@link #mProcess} and waits for the process to end. + * + * If the processes finishes, then sets {@link ResultData#stdout}, {@link ResultData#stderr} + * and {@link ResultData#exitCode} for the {@link #mExecutionCommand} of the {@code appShell} + * and then calls {@link #processAppShellResult(AppShell, ExecutionCommand) to process the result}. + * + * @param context The {@link Context} for operations. + */ + private void executeInner(@NonNull final Context context) throws IllegalThreadStateException, InterruptedException { + mExecutionCommand.mPid = ShellUtils.getPid(mProcess); + + Logger.logDebug(LOG_TAG, "Running \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + mExecutionCommand.mPid); + + mExecutionCommand.resultData.exitCode = null; + + // setup stdin, and stdout and stderr gobblers + DataOutputStream STDIN = new DataOutputStream(mProcess.getOutputStream()); + StreamGobbler STDOUT = new StreamGobbler(mExecutionCommand.mPid + "-stdout", mProcess.getInputStream(), mExecutionCommand.resultData.stdout, mExecutionCommand.backgroundCustomLogLevel); + StreamGobbler STDERR = new StreamGobbler(mExecutionCommand.mPid + "-stderr", mProcess.getErrorStream(), mExecutionCommand.resultData.stderr, mExecutionCommand.backgroundCustomLogLevel); + + // start gobbling + STDOUT.start(); + STDERR.start(); + + if (!DataUtils.isNullOrEmpty(mExecutionCommand.stdin)) { + try { + STDIN.write((mExecutionCommand.stdin + "\n").getBytes(StandardCharsets.UTF_8)); + STDIN.flush(); + STDIN.close(); + //STDIN.write("exit\n".getBytes(StandardCharsets.UTF_8)); + //STDIN.flush(); + } catch(IOException e) { + if (e.getMessage() != null && (e.getMessage().contains("EPIPE") || e.getMessage().contains("Stream closed"))) { + // Method most horrid to catch broken pipe, in which case we + // do nothing. The command is not a shell, the shell closed + // STDIN, the script already contained the exit command, etc. + // these cases we want the output instead of returning null. + } else { + // other issues we don't know how to handle, leads to + // returning null + mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_exception_received_while_executing_app_shell_command, mExecutionCommand.getCommandIdAndLabelLogString(), e.getMessage()), e); + mExecutionCommand.resultData.exitCode = 1; + AppShell.processAppShellResult(this, null); + kill(); + return; + } + } + } + + // wait for our process to finish, while we gobble away in the background + int exitCode = mProcess.waitFor(); + + // make sure our threads are done gobbling + // and the process is destroyed - while the latter shouldn't be + // needed in theory, and may even produce warnings, in "normal" Java + // they are required for guaranteed cleanup of resources, so lets be + // safe and do this on Android as well + try { + STDIN.close(); + } catch (IOException e) { + // might be closed already + } + STDOUT.join(); + STDERR.join(); + mProcess.destroy(); + + // Process result + if (exitCode == 0) + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + mExecutionCommand.mPid + " exited normally"); + else + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + mExecutionCommand.mPid + " exited with code: " + exitCode); + + // If the execution command has already failed, like SIGKILL was sent, then don't continue + if (mExecutionCommand.isStateFailed()) { + Logger.logDebug(LOG_TAG, "Ignoring setting \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell state to ExecutionState.EXECUTED and processing results since it has already failed"); + return; + } + + mExecutionCommand.resultData.exitCode = exitCode; + + if (!mExecutionCommand.setState(ExecutionState.EXECUTED)) + return; + + AppShell.processAppShellResult(this, null); + } + + /** + * Kill this {@link AppShell} by sending a {@link OsConstants#SIGILL} to its {@link #mProcess} + * if its still executing. + * + * @param context The {@link Context} for operations. + * @param processResult If set to {@code true}, then the {@link #processAppShellResult(AppShell, ExecutionCommand)} + * will be called to process the failure. + */ + public void killIfExecuting(@NonNull final Context context, boolean processResult) { + // If execution command has already finished executing, then no need to process results or send SIGKILL + if (mExecutionCommand.hasExecuted()) { + Logger.logDebug(LOG_TAG, "Ignoring sending SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell since it has already finished executing"); + return; + } + + Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell"); + + if (mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_sending_sigkill_to_process))) { + if (processResult) { + mExecutionCommand.resultData.exitCode = 137; // SIGKILL + AppShell.processAppShellResult(this, null); + } + } + + if (mExecutionCommand.isExecuting()) { + kill(); + } + } + + /** + * Kill this {@link AppShell} by sending a {@link OsConstants#SIGILL} to its {@link #mProcess}. + */ + public void kill() { + int pid = ShellUtils.getPid(mProcess); + try { + // Send SIGKILL to process + Os.kill(pid, OsConstants.SIGKILL); + } catch (ErrnoException e) { + Logger.logWarn(LOG_TAG, "Failed to send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + pid + ": " + e.getMessage()); + } + } + + /** + * Process the results of {@link AppShell} or {@link ExecutionCommand}. + * + * Only one of {@code appShell} and {@code executionCommand} must be set. + * + * If the {@code appShell} and its {@link #mAppShellClient} are not {@code null}, + * then the {@link AppShellClient#onAppShellExited(AppShell)} callback will be called. + * + * @param appShell The {@link AppShell}, which should be set if + * {@link #execute(Context, ExecutionCommand, AppShellClient, IShellEnvironment, HashMap, boolean)} + * successfully started the process. + * @param executionCommand The {@link ExecutionCommand}, which should be set if + * {@link #execute(Context, ExecutionCommand, AppShellClient, IShellEnvironment, HashMap, boolean)} + * failed to start the process. + */ + private static void processAppShellResult(final AppShell appShell, ExecutionCommand executionCommand) { + if (appShell != null) + executionCommand = appShell.mExecutionCommand; + + if (executionCommand == null) return; + + if (executionCommand.shouldNotProcessResults()) { + Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" AppShell result"); + return; + } + + Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" AppShell result"); + + if (appShell != null && appShell.mAppShellClient != null) { + appShell.mAppShellClient.onAppShellExited(appShell); + } else { + // If a callback is not set and execution command didn't fail, then we set success state now + // Otherwise, the callback host can set it himself when its done with the appShell + if (!executionCommand.isStateFailed()) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + } + + public Process getProcess() { + return mProcess; + } + + public ExecutionCommand getExecutionCommand() { + return mExecutionCommand; + } + + + + public interface AppShellClient { + + /** + * Callback function for when {@link AppShell} exits. + * + * @param appShell The {@link AppShell} that exited. + */ + void onAppShellExited(AppShell appShell); + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java new file mode 100644 index 000000000..6635460e3 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java @@ -0,0 +1,159 @@ +package com.termux.shared.termux; + +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP; + +public class TermuxBootstrap { + + private static final String LOG_TAG = "TermuxBootstrap"; + + /** The field name used by Termux app to store package variant in + * {@link TERMUX_APP#BUILD_CONFIG_CLASS_NAME} class. */ + public static final String BUILD_CONFIG_FIELD_TERMUX_PACKAGE_VARIANT = "TERMUX_PACKAGE_VARIANT"; + + + /** The {@link PackageManager} for the bootstrap in the app APK added in app/build.gradle. */ + public static final PackageManager TERMUX_APP_PACKAGE_MANAGER = PackageManager.APT; + + /** The {@link PackageVariant} for the bootstrap in the app APK added in app/build.gradle. */ + public static final PackageVariant TERMUX_APP_PACKAGE_VARIANT = PackageVariant.APT_ANDROID_7; + + + /** Is {@link PackageManager#APT} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */ + public static boolean isAppPackageManagerAPT() { + return true; + } + + ///** Is {@link PackageManager#TAPM} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */ + //public static boolean isAppPackageManagerTAPM() { + // return PackageManager.TAPM.equals(TERMUX_APP_PACKAGE_MANAGER); + //} + + ///** Is {@link PackageManager#PACMAN} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */ + //public static boolean isAppPackageManagerPACMAN() { + // return PackageManager.PACMAN.equals(TERMUX_APP_PACKAGE_MANAGER); + //} + + + + /** Is {@link PackageVariant#APT_ANDROID_7} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */ + public static boolean isAppPackageVariantAPTAndroid7() { + return true; + } + + /** Is {@link PackageVariant#APT_ANDROID_5} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */ + public static boolean isAppPackageVariantAPTAndroid5() { + return false; + } + + ///** Is {@link PackageVariant#TAPM_ANDROID_7} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */ + //public static boolean isAppPackageVariantTAPMAndroid7() { + // return PackageVariant.TAPM_ANDROID_7.equals(TERMUX_APP_PACKAGE_VARIANT); + //} + + ///** Is {@link PackageVariant#PACMAN_ANDROID_7} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */ + //public static boolean isAppPackageVariantTPACMANAndroid7() { + // return PackageVariant.PACMAN_ANDROID_7.equals(TERMUX_APP_PACKAGE_VARIANT); + //} + + + + /** Termux package manager. */ + public enum PackageManager { + + /** + * Advanced Package Tool (APT) for managing debian deb package files. + * https://wiki.debian.org/Apt + * https://wiki.debian.org/deb + */ + APT("apt"); + + ///** + // * Termux Android Package Manager (TAPM) for managing termux apk package files. + // * https://en.wikipedia.org/wiki/Apk_(file_format) + // */ + //TAPM("tapm"); + + ///** + // * Package Manager (PACMAN) for managing arch linux pkg.tar package files. + // * https://wiki.archlinux.org/title/pacman + // * https://en.wikipedia.org/wiki/Arch_Linux#Pacman + // */ + //PACMAN("pacman"); + + private final String name; + + PackageManager(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public boolean equalsManager(String manager) { + return manager != null && manager.equals(this.name); + } + + /** Get {@link PackageManager} for {@code name} if found, otherwise {@code null}. */ + @Nullable + public static PackageManager managerOf(String name) { + if (name == null || name.isEmpty()) return null; + for (PackageManager v : PackageManager.values()) { + if (v.name.equals(name)) { + return v; + } + } + return null; + } + + } + + + + /** Termux package variant. The substring before first dash "-" must match one of the {@link PackageManager}. */ + public enum PackageVariant { + + /** {@link PackageManager#APT} variant for Android 7+. */ + APT_ANDROID_7("apt-android-7"), + + /** {@link PackageManager#APT} variant for Android 5+. */ + APT_ANDROID_5("apt-android-5"); + + ///** {@link PackageManager#TAPM} variant for Android 7+. */ + //TAPM_ANDROID_7("tapm-android-7"); + + ///** {@link PackageManager#PACMAN} variant for Android 7+. */ + //PACMAN_ANDROID_7("pacman-android-7"); + + private final String name; + + PackageVariant(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public boolean equalsVariant(String variant) { + return variant != null && variant.equals(this.name); + } + + /** Get {@link PackageVariant} for {@code name} if found, otherwise {@code null}. */ + @Nullable + public static PackageVariant variantOf(String name) { + if (name == null || name.isEmpty()) return null; + for (PackageVariant v : PackageVariant.values()) { + if (v.name.equals(name)) { + return v; + } + } + return null; + } + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java new file mode 100644 index 000000000..690f63c0a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java @@ -0,0 +1,953 @@ +package com.termux.shared.termux; + +import android.annotation.SuppressLint; + +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.ExecutionCommand.Runner; + +import java.io.File; +import java.util.Arrays; +import java.util.Formatter; +import java.util.List; + +/* + * Version: v0.52.0 + * SPDX-License-Identifier: MIT + * + * Changelog + * + * - 0.1.0 (2021-03-08) + * - Initial Release. + * + * - 0.2.0 (2021-03-11) + * - Added `_DIR` and `_FILE` substrings to paths. + * - Added `INTERNAL_PRIVATE_APP_DATA_DIR*`, `TERMUX_CACHE_DIR*`, `TERMUX_DATABASES_DIR*`, + * `TERMUX_SHARED_PREFERENCES_DIR*`, `TERMUX_BIN_PREFIX_DIR*`, `TERMUX_ETC_DIR*`, + * `TERMUX_INCLUDE_DIR*`, `TERMUX_LIB_DIR*`, `TERMUX_LIBEXEC_DIR*`, `TERMUX_SHARE_DIR*`, + * `TERMUX_TMP_DIR*`, `TERMUX_VAR_DIR*`, `TERMUX_STAGING_PREFIX_DIR*`, + * `TERMUX_STORAGE_HOME_DIR*`, `TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME*`, + * `TERMUX_DEFAULT_PREFERENCES_FILE`. + * - Renamed `DATA_HOME_PATH` to `TERMUX_DATA_HOME_DIR_PATH`. + * - Renamed `CONFIG_HOME_PATH` to `TERMUX_CONFIG_HOME_DIR_PATH`. + * - Updated javadocs and spacing. + * + * - 0.3.0 (2021-03-12) + * - Remove `TERMUX_CACHE_DIR_PATH*`, `TERMUX_DATABASES_DIR_PATH*`, + * `TERMUX_SHARED_PREFERENCES_DIR_PATH*` since they may not be consistent on all devices. + * - Renamed `TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME` to + * `TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`. This should be used for + * accessing shared preferences between Termux app and its plugins if ever needed by first + * getting shared package context with {@link Context.createPackageContext(String,int}). + * + * - 0.4.0 (2021-03-16) + * - Added `BROADCAST_TERMUX_OPENED`, + * `TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION` + * `TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`, + * `TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`, + * `TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`, + * `TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`, + * `TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`. + * + * - 0.5.0 (2021-03-16) + * - Renamed "Termux Plugin app" labels to "Termux:Tasker app". + * + * - 0.6.0 (2021-03-16) + * - Added `TERMUX_FILE_SHARE_URI_AUTHORITY`. + * + * - 0.7.0 (2021-03-17) + * - Fixed javadocs. + * + * - 0.8.0 (2021-03-18) + * - Fixed Intent extra types javadocs. + * - Added following to `TERMUX_SERVICE`: + * `EXTRA_PENDING_INTENT`, `EXTRA_RESULT_BUNDLE`, + * `EXTRA_STDOUT`, `EXTRA_STDERR`, `EXTRA_EXIT_CODE`, + * `EXTRA_ERR`, `EXTRA_ERRMSG`. + * + * - 0.9.0 (2021-03-18) + * - Fixed javadocs. + * + * - 0.10.0 (2021-03-19) + * - Added following to `TERMUX_SERVICE`: + * `EXTRA_SESSION_ACTION`, + * `VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY`, + * `VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY`, + * `VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY` + * `VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY`. + * - Added following to `RUN_COMMAND_SERVICE`: + * `EXTRA_SESSION_ACTION`. + * + * - 0.11.0 (2021-03-24) + * - Added following to `TERMUX_SERVICE`: + * `EXTRA_COMMAND_LABEL`, `EXTRA_COMMAND_DESCRIPTION`, `EXTRA_COMMAND_HELP`, `EXTRA_PLUGIN_API_HELP`. + * - Added following to `RUN_COMMAND_SERVICE`: + * `EXTRA_COMMAND_LABEL`, `EXTRA_COMMAND_DESCRIPTION`, `EXTRA_COMMAND_HELP`. + * - Updated `RESULT_BUNDLE` related extras with `PLUGIN_RESULT_BUNDLE` prefixes. + * + * - 0.12.0 (2021-03-25) + * - Added following to `TERMUX_SERVICE`: + * `EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH`, + * `EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH`. + * + * - 0.13.0 (2021-03-25) + * - Added following to `RUN_COMMAND_SERVICE`: + * `EXTRA_PENDING_INTENT`. + * + * - 0.14.0 (2021-03-25) + * - Added `FDROID_PACKAGES_BASE_URL`, + * `TERMUX_GITHUB_ORGANIZATION_NAME`, `TERMUX_GITHUB_ORGANIZATION_URL`, + * `TERMUX_GITHUB_REPO_NAME`, `TERMUX_GITHUB_REPO_URL`, `TERMUX_FDROID_PACKAGE_URL`, + * `TERMUX_API_GITHUB_REPO_NAME`,`TERMUX_API_GITHUB_REPO_URL`, `TERMUX_API_FDROID_PACKAGE_URL`, + * `TERMUX_BOOT_GITHUB_REPO_NAME`, `TERMUX_BOOT_GITHUB_REPO_URL`, `TERMUX_BOOT_FDROID_PACKAGE_URL`, + * `TERMUX_FLOAT_GITHUB_REPO_NAME`, `TERMUX_FLOAT_GITHUB_REPO_URL`, `TERMUX_FLOAT_FDROID_PACKAGE_URL`, + * `TERMUX_STYLING_GITHUB_REPO_NAME`, `TERMUX_STYLING_GITHUB_REPO_URL`, `TERMUX_STYLING_FDROID_PACKAGE_URL`, + * `TERMUX_TASKER_GITHUB_REPO_NAME`, `TERMUX_TASKER_GITHUB_REPO_URL`, `TERMUX_TASKER_FDROID_PACKAGE_URL`, + * `TERMUX_WIDGET_GITHUB_REPO_NAME`, `TERMUX_WIDGET_GITHUB_REPO_URL` `TERMUX_WIDGET_FDROID_PACKAGE_URL`. + * + * - 0.15.0 (2021-04-06) + * - Fixed some variables that had `PREFIX_` substring missing in their name. + * - Added `TERMUX_CRASH_LOG_FILE_PATH`, `TERMUX_CRASH_LOG_BACKUP_FILE_PATH`, + * `TERMUX_GITHUB_ISSUES_REPO_URL`, `TERMUX_API_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_BOOT_GITHUB_ISSUES_REPO_URL`, `TERMUX_FLOAT_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_STYLING_GITHUB_ISSUES_REPO_URL`, `TERMUX_TASKER_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_WIDGET_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_GITHUB_WIKI_REPO_URL`, `TERMUX_PACKAGES_GITHUB_WIKI_REPO_URL`, + * `TERMUX_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_PACKAGES_GITHUB_REPO_URL`, `TERMUX_PACKAGES_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_GAME_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_GAME_PACKAGES_GITHUB_REPO_URL`, `TERMUX_GAME_PACKAGES_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_SCIENCE_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_SCIENCE_PACKAGES_GITHUB_REPO_URL`, `TERMUX_SCIENCE_PACKAGES_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_ROOT_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_ROOT_PACKAGES_GITHUB_REPO_URL`, `TERMUX_ROOT_PACKAGES_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_UNSTABLE_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_UNSTABLE_PACKAGES_GITHUB_REPO_URL`, `TERMUX_UNSTABLE_PACKAGES_GITHUB_ISSUES_REPO_URL`, + * `TERMUX_X11_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_X11_PACKAGES_GITHUB_REPO_URL`, `TERMUX_X11_PACKAGES_GITHUB_ISSUES_REPO_URL`. + * - Added following to `RUN_COMMAND_SERVICE`: + * `RUN_COMMAND_API_HELP_URL`. + * + * - 0.16.0 (2021-04-06) + * - Added `TERMUX_SUPPORT_EMAIL`, `TERMUX_SUPPORT_EMAIL_URL`, `TERMUX_SUPPORT_EMAIL_MAILTO_URL`, + * `TERMUX_REDDIT_SUBREDDIT`, `TERMUX_REDDIT_SUBREDDIT_URL`. + * - The `TERMUX_SUPPORT_EMAIL_URL` value must be fixed later when email has been set up. + * + * - 0.17.0 (2021-04-07) + * - Added `TERMUX_APP_NOTIFICATION_CHANNEL_ID`, `TERMUX_APP_NOTIFICATION_CHANNEL_NAME`, `TERMUX_APP_NOTIFICATION_ID`, + * `TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID`, `TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME`, `TERMUX_RUN_COMMAND_NOTIFICATION_ID`, + * `TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID`, `TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME`, + * `TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID`, `TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME`. + * - Updated javadocs. + * + * - 0.18.0 (2021-04-11) + * - Updated `TERMUX_SUPPORT_EMAIL_URL` to a valid email. + * - Removed `TERMUX_SUPPORT_EMAIL`. + * + * - 0.19.0 (2021-04-12) + * - Added `TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS`. + * - Added `TERMUX_SERVICE.EXTRA_STDIN`. + * - Added `RUN_COMMAND_SERVICE.EXTRA_STDIN`. + * - Deprecated `TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE`. + * + * - 0.20.0 (2021-05-13) + * - Added `TERMUX_WIKI`, `TERMUX_WIKI_URL`, `TERMUX_PLUGIN_APP_NAMES_LIST`, `TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST`. + * - Added `TERMUX_SETTINGS_ACTIVITY_NAME`. + * + * - 0.21.0 (2021-05-13) + * - Added `APK_RELEASE_FDROID`, `APK_RELEASE_FDROID_SIGNING_CERTIFICATE_SHA256_DIGEST`, + * `APK_RELEASE_GITHUB_DEBUG_BUILD`, `APK_RELEASE_GITHUB_DEBUG_BUILD_SIGNING_CERTIFICATE_SHA256_DIGEST`, + * `APK_RELEASE_GOOGLE_PLAYSTORE`, `APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST`. + * + * - 0.22.0 (2021-05-13) + * - Added `TERMUX_DONATE_URL`. + * + * - 0.23.0 (2021-06-12) + * - Rename `INTERNAL_PRIVATE_APP_DATA_DIR_PATH` to `TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH`. + * + * - 0.24.0 (2021-06-27) + * - Add `COMMA_NORMAL`, `COMMA_ALTERNATIVE`. + * - Added following to `TERMUX_APP.TERMUX_SERVICE`: + * `EXTRA_RESULT_DIRECTORY`, `EXTRA_RESULT_SINGLE_FILE`, `EXTRA_RESULT_FILE_BASENAME`, + * `EXTRA_RESULT_FILE_OUTPUT_FORMAT`, `EXTRA_RESULT_FILE_ERROR_FORMAT`, `EXTRA_RESULT_FILES_SUFFIX`. + * - Added following to `TERMUX_APP.RUN_COMMAND_SERVICE`: + * `EXTRA_RESULT_DIRECTORY`, `EXTRA_RESULT_SINGLE_FILE`, `EXTRA_RESULT_FILE_BASENAME`, + * `EXTRA_RESULT_FILE_OUTPUT_FORMAT`, `EXTRA_RESULT_FILE_ERROR_FORMAT`, `EXTRA_RESULT_FILES_SUFFIX`, + * `EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS`, `EXTRA_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS`. + * - Added following to `RESULT_SENDER`: + * `FORMAT_SUCCESS_STDOUT`, `FORMAT_SUCCESS_STDOUT__EXIT_CODE`, `FORMAT_SUCCESS_STDOUT__STDERR__EXIT_CODE` + * `FORMAT_FAILED_ERR__ERRMSG__STDOUT__STDERR__EXIT_CODE`, + * `RESULT_FILE_ERR_PREFIX`, `RESULT_FILE_ERRMSG_PREFIX` `RESULT_FILE_STDOUT_PREFIX`, + * `RESULT_FILE_STDERR_PREFIX`, `RESULT_FILE_EXIT_CODE_PREFIX`. + * + * - 0.25.0 (2021-08-19) + * - Added following to `TERMUX_APP.TERMUX_SERVICE`: + * `EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL`. + * - Added following to `TERMUX_APP.RUN_COMMAND_SERVICE`: + * `EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL`. + * + * - 0.26.0 (2021-08-25) + * - Changed `TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION` to `TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION`. + * + * - 0.27.0 (2021-09-02) + * - Added `TERMUX_FLOAT_APP_NOTIFICATION_CHANNEL_ID`, `TERMUX_FLOAT_APP_NOTIFICATION_CHANNEL_NAME`, + * `TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE_NAME`. + * - Added following to `TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE`: + * `ACTION_STOP_SERVICE`, `ACTION_SHOW`, `ACTION_HIDE`. + * + * - 0.28.0 (2021-09-02) + * - Added `TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE*` and `TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE*`. + * + * - 0.29.0 (2021-09-04) + * - Added `TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR_BASENAME`, `TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_BASENAME`, + * `TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_PATH`, `TERMUX_SHORTCUT_SCRIPT_ICONS_DIR`. + * - Added following to `TERMUX_WIDGET.TERMUX_WIDGET_PROVIDER`: + * `ACTION_WIDGET_ITEM_CLICKED`, `ACTION_REFRESH_WIDGET`, `EXTRA_FILE_CLICKED`. + * - Changed naming convention of `TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE.ACTION_*`. + * - Fixed wrong path set for `TERMUX_SHORTCUT_SCRIPTS_DIR_PATH`. + * + * - 0.30.0 (2021-09-08) + * - Changed `APK_RELEASE_GITHUB_DEBUG_BUILD`to `APK_RELEASE_GITHUB` and + * `APK_RELEASE_GITHUB_DEBUG_BUILD_SIGNING_CERTIFICATE_SHA256_DIGEST` to + * `APK_RELEASE_GITHUB_SIGNING_CERTIFICATE_SHA256_DIGEST`. + * + * - 0.31.0 (2021-09-09) + * - Added following to `TERMUX_APP.TERMUX_SERVICE`: + * `MIN_VALUE_EXTRA_SESSION_ACTION` and `MAX_VALUE_EXTRA_SESSION_ACTION`. + * + * - 0.32.0 (2021-09-23) + * - Added `TERMUX_API.TERMUX_API_ACTIVITY_NAME`, `TERMUX_TASKER.TERMUX_TASKER_ACTIVITY_NAME` + * and `TERMUX_WIDGET.TERMUX_WIDGET_ACTIVITY_NAME`. + * + * - 0.33.0 (2021-10-08) + * - Added `TERMUX_PROPERTIES_FILE_PATHS_LIST` and `TERMUX_FLOAT_PROPERTIES_FILE_PATHS_LIST`. + * + * - 0.34.0 (2021-10-26) + * - Move `RESULT_SENDER` to `com.termux.shared.shell.command.ShellCommandConstants`. + * + * - 0.35.0 (2022-01-28) + * - Add `TERMUX_APP.TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY`. + * + * - 0.36.0 (2022-03-10) + * - Added `TERMUX_APP.TERMUX_SERVICE.EXTRA_RUNNER` and `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_RUNNER` + * + * - 0.37.0 (2022-03-15) + * - Added `TERMUX_API_APT_*`. + * + * - 0.38.0 (2022-03-16) + * - Added `TERMUX_APP.TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH`. + * + * - 0.39.0 (2022-03-18) + * - Added `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_NAME`, `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_NAME`, + * `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_CREATE_MODE` and `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_CREATE_MODE`. + * + * - 0.40.0 (2022-04-17) + * - Added `TERMUX_APPS_DIR_PATH` and `TERMUX_APP.APPS_DIR_PATH`. + * + * - 0.41.0 (2022-04-17) + * - Added `TERMUX_APP.TERMUX_AM_SOCKET_FILE_PATH`. + * + * - 0.42.0 (2022-04-29) + * - Added `APK_RELEASE_TERMUX_DEVS` and `APK_RELEASE_TERMUX_DEVS_SIGNING_CERTIFICATE_SHA256_DIGEST`. + * + * - 0.43.0 (2022-05-29) + * - Changed `TERMUX_SUPPORT_EMAIL_URL` to support@termux.dev. + * + * - 0.44.0 (2022-05-29) + * - Changed `TERMUX_APP.APPS_DIR_PATH` basename from `termux-app` to `com.termux`. + * + * - 0.45.0 (2022-06-01) + * - Added `TERMUX_APP.BUILD_CONFIG_CLASS_NAME`. + * + * - 0.46.0 (2022-06-03) + * - Rename `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_NAME` to `*.EXTRA_SHELL_NAME`, + * `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_NAME` to `*.EXTRA_SHELL_NAME`, + * `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_CREATE_MODE` to `*.EXTRA_SHELL_CREATE_MODE` and + * `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_CREATE_MODE` to `*.EXTRA_SHELL_CREATE_MODE`. + * + * - 0.47.0 (2022-06-04) + * - Added `TERMUX_SITE` and `TERMUX_SITE_URL`. + * - Changed `TERMUX_DONATE_URL`. + * + * - 0.48.0 (2022-06-04) + * - Removed `TERMUX_GAME_PACKAGES_GITHUB_*`, `TERMUX_SCIENCE_PACKAGES_GITHUB_*`, + * `TERMUX_ROOT_PACKAGES_GITHUB_*`, `TERMUX_UNSTABLE_PACKAGES_GITHUB_*` + * + * - 0.49.0 (2022-06-11) + * - Added `TERMUX_ENV_PREFIX_ROOT`. + * + * - 0.50.0 (2022-06-11) + * - Added `TERMUX_CONFIG_PREFIX_DIR_PATH`, `TERMUX_ENV_FILE_PATH` and `TERMUX_ENV_TEMP_FILE_PATH`. + * + * - 0.51.0 (2022-06-13) + * - Added `TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME` and `TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME`. + * + * - 0.52.0 (2022-06-18) + * - Added `TERMUX_PREFIX_DIR_IGNORED_SUB_FILES_PATHS_TO_CONSIDER_AS_EMPTY`. + */ + +/** + * A class that defines shared constants of the Termux app and its plugins. + * This class will be hosted by termux-shared lib and should be imported by other termux plugin + * apps as is instead of copying constants to random classes. The 3rd party apps can also import + * it for interacting with termux apps. If changes are made to this file, increment the version number + * and add an entry in the Changelog section above. + * + * Termux app default package name is "com.termux" and is used in {@link #TERMUX_PREFIX_DIR_PATH}. + * The binaries compiled for termux have {@link #TERMUX_PREFIX_DIR_PATH} hardcoded in them but it + * can be changed during compilation. + * + * The {@link #TERMUX_PACKAGE_NAME} must be the same as the applicationId of termux-app build.gradle + * since its also used by {@link #TERMUX_FILES_DIR_PATH}. + * If {@link #TERMUX_PACKAGE_NAME} is changed, then binaries, specially used in bootstrap need to be + * compiled appropriately. Check https://github.com/termux/termux-packages/wiki/Building-packages + * for more info. + * + * Ideally the only places where changes should be required if changing package name are the following: + * - The {@link #TERMUX_PACKAGE_NAME} in {@link TermuxConstants}. + * - The "applicationId" in "build.gradle" of termux-app. This is package name that android and app + * stores will use and is also the final package name stored in "AndroidManifest.xml". + * - The "manifestPlaceholders" values for {@link #TERMUX_PACKAGE_NAME} and *_APP_NAME in + * "build.gradle" of termux-app. + * - The "ENTITY" values for {@link #TERMUX_PACKAGE_NAME} and *_APP_NAME in "strings.xml" of + * termux-app and of termux-shared. + * - The "shortcut.xml" and "*_preferences.xml" files of termux-app since dynamic variables don't + * work in it. + * - Optionally the "package" in "AndroidManifest.xml" if modifying project structure of termux-app. + * This is package name for java classes project structure and is prefixed if activity and service + * names use dot (.) notation. This is currently not advisable since this will break lot of + * stuff, including termux-* packages. + * - Optionally the *_PATH variables in {@link TermuxConstants} containing the string "termux". + * + * Check https://developer.android.com/studio/build/application-id for info on "package" in + * "AndroidManifest.xml" and "applicationId" in "build.gradle". + * + * The {@link #TERMUX_PACKAGE_NAME} must be used in source code of Termux app and its plugins instead + * of hardcoded "com.termux" paths. + */ +public final class TermuxConstants { + + + /* + * Termux organization variables. + */ + + /** Termux GitHub organization name */ + public static final String TERMUX_GITHUB_ORGANIZATION_NAME = "AndroidIDEOfficial"; // Default: "termux" + /** Termux GitHub organization url */ + public static final String TERMUX_GITHUB_ORGANIZATION_URL = "https://github.com" + "/" + TERMUX_GITHUB_ORGANIZATION_NAME; // Default: "https://github.com/termux" + + + + + + /* + * Termux and its plugin app and package names and urls. + */ + + /** Termux app name */ + public static final String TERMUX_APP_NAME = "AndroidIDE"; // Default: "Termux" + /** Termux package name */ + public static final String TERMUX_PACKAGE_NAME = "com.itsaky.androidide"; // Default: "com.termux" + /** Termux GitHub repo name */ + public static final String TERMUX_GITHUB_REPO_NAME = "AndroidIDE"; // Default: "termux-app" + /** Termux GitHub repo url */ + public static final String TERMUX_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-app" + /** Termux GitHub issues repo url */ + public static final String TERMUX_GITHUB_ISSUES_REPO_URL = TERMUX_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-app/issues" + + + + + + /* + * Termux APK releases. + */ + + /** F-Droid APK release */ + public static final String APK_RELEASE_FDROID = "F-Droid"; // Default: "F-Droid" + + /** F-Droid APK release signing certificate SHA-256 digest */ + public static final String APK_RELEASE_FDROID_SIGNING_CERTIFICATE_SHA256_DIGEST = "228FB2CFE90831C1499EC3CCAF61E96E8E1CE70766B9474672CE427334D41C42"; // Default: "228FB2CFE90831C1499EC3CCAF61E96E8E1CE70766B9474672CE427334D41C42" + + /** GitHub APK release */ + public static final String APK_RELEASE_GITHUB = "Github"; // Default: "Github" + + /** GitHub APK release signing certificate SHA-256 digest */ + public static final String APK_RELEASE_GITHUB_SIGNING_CERTIFICATE_SHA256_DIGEST = "B6DA01480EEFD5FBF2CD3771B8D1021EC791304BDD6C4BF41D3FAABAD48EE5E1"; // Default: "B6DA01480EEFD5FBF2CD3771B8D1021EC791304BDD6C4BF41D3FAABAD48EE5E1" + + /** Google Play Store APK release */ + public static final String APK_RELEASE_GOOGLE_PLAYSTORE = "Google Play Store"; // Default: "Google Play Store" + + /** Google Play Store APK release signing certificate SHA-256 digest */ + public static final String APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST = "738F0A30A04D3C8A1BE304AF18D0779BCF3EA88FB60808F657A3521861C2EBF9"; // Default: "738F0A30A04D3C8A1BE304AF18D0779BCF3EA88FB60808F657A3521861C2EBF9" + + /** Termux Devs APK release */ + public static final String APK_RELEASE_TERMUX_DEVS = "Termux Devs"; // Default: "Termux Devs" + + /** Termux Devs APK release signing certificate SHA-256 digest */ + public static final String APK_RELEASE_TERMUX_DEVS_SIGNING_CERTIFICATE_SHA256_DIGEST = "F7A038EB551F1BE8FDF388686B784ABAB4552A5D82DF423E3D8F1B5CBE1C69AE"; // Default: "F7A038EB551F1BE8FDF388686B784ABAB4552A5D82DF423E3D8F1B5CBE1C69AE" + + + + + + /* + * Termux packages urls. + */ + + /** Termux Packages GitHub repo name */ + public static final String TERMUX_PACKAGES_GITHUB_REPO_NAME = "terminal-packages"; // Default: "terminal-packages" + /** Termux Packages GitHub repo url */ + public static final String TERMUX_PACKAGES_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_PACKAGES_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-packages" + /** Termux Packages GitHub issues repo url */ + public static final String TERMUX_PACKAGES_GITHUB_ISSUES_REPO_URL = TERMUX_PACKAGES_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-packages/issues" + + + /** Termux API apt package name */ + public static final String TERMUX_API_APT_PACKAGE_NAME = "termux-api"; // Default: "termux-api" + /** Termux API apt GitHub repo name */ + public static final String TERMUX_API_APT_GITHUB_REPO_NAME = "termux-api-package"; // Default: "termux-api-package" + /** Termux API apt GitHub repo url */ + public static final String TERMUX_API_APT_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_API_APT_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-api-package" + /** Termux API apt GitHub issues repo url */ + public static final String TERMUX_API_APT_GITHUB_ISSUES_REPO_URL = TERMUX_API_APT_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-api-package/issues" + + + + + + /* + * Termux miscellaneous urls. + */ + + /** Termux Site */ + public static final String TERMUX_SITE = TERMUX_APP_NAME + " Site"; // Default: "Termux Site" + + /** Termux Wiki */ + public static final String TERMUX_WIKI = TERMUX_APP_NAME + " Wiki"; // Default: "Termux Wiki" + + /** Termux Wiki url */ + public static final String TERMUX_WIKI_URL = "https://wiki.termux.com"; // Default: "https://wiki.termux.com" + + /** Termux GitHub wiki repo url */ + public static final String TERMUX_GITHUB_WIKI_REPO_URL = TERMUX_GITHUB_REPO_URL + "/wiki"; // Default: "https://github.com/termux/termux-app/wiki" + + /** Termux Packages wiki repo url */ + public static final String TERMUX_PACKAGES_GITHUB_WIKI_REPO_URL = TERMUX_PACKAGES_GITHUB_REPO_URL + "/wiki"; // Default: "https://github.com/termux/termux-packages/wiki" + + + /** Termux support email url */ + public static final String TERMUX_SUPPORT_EMAIL_URL = "contact@androidide.com"; // Default: "support@termux.dev" + + /** Termux support email mailto url */ + public static final String TERMUX_SUPPORT_EMAIL_MAILTO_URL = "mailto:" + TERMUX_SUPPORT_EMAIL_URL; // Default: "mailto:support@termux.dev" + + + + + + /* + * Termux app core directory paths. + */ + + /** Termux app internal private app data directory path */ + @SuppressLint("SdCardPath") + public static final String TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH = "/data/data/" + TERMUX_PACKAGE_NAME; // Default: "/data/data/com.termux" + /** Termux app internal private app data directory */ + public static final File TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR = new File(TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH); + + + + /** Termux app Files directory path */ + public static final String TERMUX_FILES_DIR_PATH = TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH + "/files"; // Default: "/data/data/com.termux/files" + /** Termux app Files directory */ + public static final File TERMUX_FILES_DIR = new File(TERMUX_FILES_DIR_PATH); + + + + /** Termux app $PREFIX directory path */ + public static final String TERMUX_PREFIX_DIR_PATH = TERMUX_FILES_DIR_PATH + "/usr"; // Default: "/data/data/com.termux/files/usr" + /** Termux app $PREFIX directory */ + public static final File TERMUX_PREFIX_DIR = new File(TERMUX_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/bin directory path */ + public static final String TERMUX_BIN_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/bin"; // Default: "/data/data/com.termux/files/usr/bin" + /** Termux app $PREFIX/bin directory */ + public static final File TERMUX_BIN_PREFIX_DIR = new File(TERMUX_BIN_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/etc directory path */ + public static final String TERMUX_ETC_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/etc"; // Default: "/data/data/com.termux/files/usr/etc" + /** Termux app $PREFIX/etc directory */ + public static final File TERMUX_ETC_PREFIX_DIR = new File(TERMUX_ETC_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/include directory path */ + public static final String TERMUX_INCLUDE_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/include"; // Default: "/data/data/com.termux/files/usr/include" + /** Termux app $PREFIX/include directory */ + public static final File TERMUX_INCLUDE_PREFIX_DIR = new File(TERMUX_INCLUDE_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/lib directory path */ + public static final String TERMUX_LIB_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/lib"; // Default: "/data/data/com.termux/files/usr/lib" + /** Termux app $PREFIX/lib directory */ + public static final File TERMUX_LIB_PREFIX_DIR = new File(TERMUX_LIB_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/libexec directory path */ + public static final String TERMUX_LIBEXEC_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/libexec"; // Default: "/data/data/com.termux/files/usr/libexec" + /** Termux app $PREFIX/libexec directory */ + public static final File TERMUX_LIBEXEC_PREFIX_DIR = new File(TERMUX_LIBEXEC_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/share directory path */ + public static final String TERMUX_SHARE_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/share"; // Default: "/data/data/com.termux/files/usr/share" + /** Termux app $PREFIX/share directory */ + public static final File TERMUX_SHARE_PREFIX_DIR = new File(TERMUX_SHARE_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/tmp and $TMPDIR directory path */ + public static final String TERMUX_TMP_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/tmp"; // Default: "/data/data/com.termux/files/usr/tmp" + /** Termux app $PREFIX/tmp and $TMPDIR directory */ + public static final File TERMUX_TMP_PREFIX_DIR = new File(TERMUX_TMP_PREFIX_DIR_PATH); + + + /** Termux app $PREFIX/var directory path */ + public static final String TERMUX_VAR_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/var"; // Default: "/data/data/com.termux/files/usr/var" + /** Termux app $PREFIX/var directory */ + public static final File TERMUX_VAR_PREFIX_DIR = new File(TERMUX_VAR_PREFIX_DIR_PATH); + + + + /** Termux app usr-staging directory path */ + public static final String TERMUX_STAGING_PREFIX_DIR_PATH = TERMUX_FILES_DIR_PATH + "/usr-staging"; // Default: "/data/data/com.termux/files/usr-staging" + /** Termux app usr-staging directory */ + public static final File TERMUX_STAGING_PREFIX_DIR = new File(TERMUX_STAGING_PREFIX_DIR_PATH); + + + + /** Termux app $HOME directory path */ + public static final String TERMUX_HOME_DIR_PATH = TERMUX_FILES_DIR_PATH + "/home"; // Default: "/data/data/com.termux/files/home" + /** Termux app $HOME directory */ + public static final File TERMUX_HOME_DIR = new File(TERMUX_HOME_DIR_PATH); + + + /** Termux app config home directory path */ + public static final String TERMUX_CONFIG_HOME_DIR_PATH = TERMUX_HOME_DIR_PATH + "/.config/termux"; // Default: "/data/data/com.termux/files/home/.config/termux" + /** Termux app config home directory */ + public static final File TERMUX_CONFIG_HOME_DIR = new File(TERMUX_CONFIG_HOME_DIR_PATH); + + /** Termux app config $PREFIX directory path */ + public static final String TERMUX_CONFIG_PREFIX_DIR_PATH = TERMUX_ETC_PREFIX_DIR_PATH + "/termux"; // Default: "/data/data/com.termux/files/usr/etc/termux" + /** Termux app config $PREFIX directory */ + public static final File TERMUX_CONFIG_PREFIX_DIR = new File(TERMUX_CONFIG_PREFIX_DIR_PATH); + + + /** Termux app data home directory path */ + public static final String TERMUX_DATA_HOME_DIR_PATH = TERMUX_HOME_DIR_PATH + "/.termux"; // Default: "/data/data/com.termux/files/home/.termux" + /** Termux app data home directory */ + public static final File TERMUX_DATA_HOME_DIR = new File(TERMUX_DATA_HOME_DIR_PATH); + + + /** Termux app storage home directory path */ + public static final String TERMUX_STORAGE_HOME_DIR_PATH = TERMUX_HOME_DIR_PATH + "/storage"; // Default: "/data/data/com.termux/files/home/storage" + /** Termux app storage home directory */ + public static final File TERMUX_STORAGE_HOME_DIR = new File(TERMUX_STORAGE_HOME_DIR_PATH); + + + + /** Termux and plugin apps directory path */ + public static final String TERMUX_APPS_DIR_PATH = TERMUX_FILES_DIR_PATH + "/apps"; // Default: "/data/data/com.termux/files/apps" + /** Termux and plugin apps directory */ + public static final File TERMUX_APPS_DIR = new File(TERMUX_APPS_DIR_PATH); + + + /** Termux app $PREFIX directory path ignored sub file paths to consider it empty */ + public static final List TERMUX_PREFIX_DIR_IGNORED_SUB_FILES_PATHS_TO_CONSIDER_AS_EMPTY = Arrays.asList( + TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, TermuxConstants.TERMUX_ENV_TEMP_FILE_PATH, TermuxConstants.TERMUX_ENV_FILE_PATH); + + + + /* + * Termux app and plugin preferences and properties file paths. + */ + + /** Termux app default SharedPreferences file basename without extension */ + public static final String TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_PACKAGE_NAME + "_termux_preferences"; // Default: "com.termux_preferences" + + /** Termux app properties primary file path */ + public static final String TERMUX_PROPERTIES_PRIMARY_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/termux.properties"; // Default: "/data/data/com.termux/files/home/.termux/termux.properties" + /** Termux app properties primary file */ + public static final File TERMUX_PROPERTIES_PRIMARY_FILE = new File(TERMUX_PROPERTIES_PRIMARY_FILE_PATH); + + /** Termux app properties secondary file path */ + public static final String TERMUX_PROPERTIES_SECONDARY_FILE_PATH = TERMUX_CONFIG_HOME_DIR_PATH + "/termux.properties"; // Default: "/data/data/com.termux/files/home/.config/termux/termux.properties" + /** Termux app properties secondary file */ + public static final File TERMUX_PROPERTIES_SECONDARY_FILE = new File(TERMUX_PROPERTIES_SECONDARY_FILE_PATH); + + /** Termux app properties file paths list. **DO NOT** allow these files to be modified by + * {@link android.content.ContentProvider} exposed to external apps, since they may silently + * modify the values for security properties like {@link #PROP_ALLOW_EXTERNAL_APPS} set by users + * without their explicit consent. */ + public static final List TERMUX_PROPERTIES_FILE_PATHS_LIST = Arrays.asList( + TERMUX_PROPERTIES_PRIMARY_FILE_PATH, + TERMUX_PROPERTIES_SECONDARY_FILE_PATH); + + + /** Termux app and Termux:Styling colors.properties file path */ + public static final String TERMUX_COLOR_PROPERTIES_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/colors.properties"; // Default: "/data/data/com.termux/files/home/.termux/colors.properties" + /** Termux app and Termux:Styling colors.properties file */ + public static final File TERMUX_COLOR_PROPERTIES_FILE = new File(TERMUX_COLOR_PROPERTIES_FILE_PATH); + + /** Termux app and Termux:Styling font.ttf file path */ + public static final String TERMUX_FONT_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/font.ttf"; // Default: "/data/data/com.termux/files/home/.termux/font.ttf" + /** Termux app and Termux:Styling font.ttf file */ + public static final File TERMUX_FONT_FILE = new File(TERMUX_FONT_FILE_PATH); + + + /** Termux app and plugins crash log file path */ + public static final String TERMUX_CRASH_LOG_FILE_PATH = TERMUX_HOME_DIR_PATH + "/crash_log.md"; // Default: "/data/data/com.termux/files/home/crash_log.md" + + /** Termux app and plugins crash log backup file path */ + public static final String TERMUX_CRASH_LOG_BACKUP_FILE_PATH = TERMUX_HOME_DIR_PATH + "/crash_log_backup.md"; // Default: "/data/data/com.termux/files/home/crash_log_backup.md" + + + /** Termux app environment file path */ + public static final String TERMUX_ENV_FILE_PATH = TERMUX_CONFIG_PREFIX_DIR_PATH + "/termux.env"; // Default: "/data/data/com.termux/files/usr/etc/termux/termux.env" + + /** Termux app environment temp file path */ + public static final String TERMUX_ENV_TEMP_FILE_PATH = TERMUX_CONFIG_PREFIX_DIR_PATH + "/termux.env.tmp"; // Default: "/data/data/com.termux/files/usr/etc/termux/termux.env.tmp" + + /* + * Termux app and plugins notification variables. + */ + + /** Termux app notification channel id used by {@link TERMUX_APP.TERMUX_SERVICE} */ + public static final String TERMUX_APP_NOTIFICATION_CHANNEL_ID = "termux_notification_channel"; + /** Termux app notification channel name used by {@link TERMUX_APP.TERMUX_SERVICE} */ + public static final String TERMUX_APP_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " App"; + /** Termux app unique notification id used by {@link TERMUX_APP.TERMUX_SERVICE} */ + public static final int TERMUX_APP_NOTIFICATION_ID = 1337; + + public static final int TERMUX_RUN_COMMAND_NOTIFICATION_ID = 1338; + + /** Termux app notification channel id used for plugin command errors */ + public static final String TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID = "termux_plugin_command_errors_notification_channel"; + /** Termux app notification channel name used for plugin command errors */ + public static final String TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " Plugin Commands Errors"; + + /** Termux app notification channel id used for crash reports */ + public static final String TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID = "termux_crash_reports_notification_channel"; + /** Termux app notification channel name used for crash reports */ + public static final String TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " Crash Reports"; + + + + + /* + * Termux app and plugins miscellaneous variables. + */ + + /** Termux property defined in termux.properties file as a secondary check to PERMISSION_RUN_COMMAND + * to allow 3rd party apps to run various commands in Termux app context */ + public static final String PROP_ALLOW_EXTERNAL_APPS = "allow-external-apps"; // Default: "allow-external-apps" + /** Default value for {@link #PROP_ALLOW_EXTERNAL_APPS} */ + public static final String PROP_DEFAULT_VALUE_ALLOW_EXTERNAL_APPS = "false"; // Default: "false" + + /** The broadcast action sent when Termux App opens */ + public static final String BROADCAST_TERMUX_OPENED = TERMUX_PACKAGE_NAME + ".app.OPENED"; + + /** The Uri authority for Termux app file shares */ + public static final String TERMUX_FILE_SHARE_URI_AUTHORITY = TERMUX_PACKAGE_NAME + ".files"; // Default: "com.termux.files" + + /** The normal comma character (U+002C, ,, ,, comma) */ + public static final String COMMA_NORMAL = ","; // Default: "," + + /** The alternate comma character (U+201A, ‚, ‚, single low-9 quotation mark) that + * may be used instead of {@link #COMMA_NORMAL} */ + public static final String COMMA_ALTERNATIVE = "‚"; // Default: "‚" + + /** Environment variable prefix root for the Termux app. */ + public static final String TERMUX_ENV_PREFIX_ROOT = "TERMUX"; + + + + + + + /** + * Termux app constants. + */ + public static final class TERMUX_APP { + + /** Termux apps directory path */ + public static final String APPS_DIR_PATH = TERMUX_APPS_DIR_PATH + "/" + TERMUX_PACKAGE_NAME; // Default: "/data/data/com.termux/files/apps/com.termux" + + /** termux-am socket file path */ + public static final String TERMUX_AM_SOCKET_FILE_PATH = APPS_DIR_PATH + "/termux-am/am.sock"; // Default: "/data/data/com.termux/files/apps/com.termux/termux-am/am.sock" + + + /** Termux app BuildConfig class name */ + public static final String BUILD_CONFIG_CLASS_NAME = TERMUX_PACKAGE_NAME + ".BuildConfig"; // Default: "com.termux.BuildConfig" + + /** Termux app FileShareReceiverActivity class name */ + public static final String FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME = TERMUX_PACKAGE_NAME + ".app.api.file.FileShareReceiverActivity"; // Default: "com.termux.app.api.file.FileShareReceiverActivity" + + /** Termux app FileViewReceiverActivity class name */ + public static final String FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME = TERMUX_PACKAGE_NAME + ".app.api.file.FileViewReceiverActivity"; // Default: "com.termux.app.api.file.FileViewReceiverActivity" + + + /** Termux app core activity name. */ + public static final String TERMUX_ACTIVITY_NAME = TERMUX_PACKAGE_NAME + ".app.TermuxActivity"; // Default: "com.termux.app.TermuxActivity" + + /** + * Termux app core activity. + */ + public static final class TERMUX_ACTIVITY { + + /** Intent extra for if termux failsafe session needs to be started and is used by {@link TERMUX_ACTIVITY} and {@link TERMUX_SERVICE#ACTION_STOP_SERVICE} */ + public static final String EXTRA_FAILSAFE_SESSION = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.failsafe_session"; // Default: "com.termux.app.failsafe_session" + + + /** Intent action to make termux app notify user that a crash happened. */ + public static final String ACTION_NOTIFY_APP_CRASH = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.notify_app_crash"; // Default: "com.termux.app.notify_app_crash" + + + /** Intent action to make termux reload its termux session styling */ + public static final String ACTION_RELOAD_STYLE = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.reload_style"; // Default: "com.termux.app.reload_style" + /** Intent {@code String} extra for what to reload for the TERMUX_ACTIVITY.ACTION_RELOAD_STYLE intent. This has been deperecated. */ + @Deprecated + public static final String EXTRA_RELOAD_STYLE = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.reload_style"; // Default: "com.termux.app.reload_style" + + /** Intent {@code boolean} extra for whether to recreate activity for the TERMUX_ACTIVITY.ACTION_RELOAD_STYLE intent. */ + public static final String EXTRA_RECREATE_ACTIVITY = TERMUX_APP.TERMUX_ACTIVITY_NAME + ".EXTRA_RECREATE_ACTIVITY"; // Default: "com.termux.app.TermuxActivity.EXTRA_RECREATE_ACTIVITY" + + + /** Intent action to make termux request storage permissions */ + public static final String ACTION_REQUEST_PERMISSIONS = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.request_storage_permissions"; // Default: "com.termux.app.request_storage_permissions" + } + + + + + + /** Termux app settings activity name. */ + public static final String TERMUX_SETTINGS_ACTIVITY_NAME = TERMUX_PACKAGE_NAME + ".app.activities.SettingsActivity"; // Default: "com.termux.app.activities.SettingsActivity" + + + + + + /** Termux app core service name. */ + public static final String TERMUX_SERVICE_NAME = TERMUX_PACKAGE_NAME + ".app.TermuxService"; // Default: "com.termux.app.TermuxService" + + /** + * Termux app core service. + */ + public static final class TERMUX_SERVICE { + + /** Intent action to stop TERMUX_SERVICE */ + public static final String ACTION_STOP_SERVICE = TERMUX_PACKAGE_NAME + ".service_stop"; // Default: "com.termux.service_stop" + + + /** Intent action to make TERMUX_SERVICE acquire a wakelock */ + public static final String ACTION_WAKE_LOCK = TERMUX_PACKAGE_NAME + ".service_wake_lock"; // Default: "com.termux.service_wake_lock" + + + /** Intent action to make TERMUX_SERVICE release wakelock */ + public static final String ACTION_WAKE_UNLOCK = TERMUX_PACKAGE_NAME + ".service_wake_unlock"; // Default: "com.termux.service_wake_unlock" + + + /** Intent action to execute command with TERMUX_SERVICE */ + public static final String ACTION_SERVICE_EXECUTE = TERMUX_PACKAGE_NAME + ".service_execute"; // Default: "com.termux.service_execute" + + /** Uri scheme for paths sent via intent to TERMUX_SERVICE */ + public static final String URI_SCHEME_SERVICE_EXECUTE = TERMUX_PACKAGE_NAME + ".file"; // Default: "com.termux.file" + /** Intent {@code String[]} extra for arguments to the executable of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_ARGUMENTS = TERMUX_PACKAGE_NAME + ".execute.arguments"; // Default: "com.termux.execute.arguments" + /** Intent {@code String} extra for stdin of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_STDIN = TERMUX_PACKAGE_NAME + ".execute.stdin"; // Default: "com.termux.execute.stdin" + /** Intent {@code String} extra for command current working directory for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_WORKDIR = TERMUX_PACKAGE_NAME + ".execute.cwd"; // Default: "com.termux.execute.cwd" + /** Intent {@code boolean} extra for whether to run command in background {@link Runner#APP_SHELL} or foreground {@link Runner#TERMINAL_SESSION} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + @Deprecated + public static final String EXTRA_BACKGROUND = TERMUX_PACKAGE_NAME + ".execute.background"; // Default: "com.termux.execute.background" + /** Intent {@code String} extra for command the {@link Runner} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RUNNER = TERMUX_PACKAGE_NAME + ".execute.runner"; // Default: "com.termux.execute.runner" + /** Intent {@code String} extra for custom log level for background commands defined by {@link com.termux.shared.logger.Logger} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL = TERMUX_PACKAGE_NAME + ".execute.background_custom_log_level"; // Default: "com.termux.execute.background_custom_log_level" + /** Intent {@code String} extra for session action for {@link Runner#TERMINAL_SESSION} commands for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_SESSION_ACTION = TERMUX_PACKAGE_NAME + ".execute.session_action"; // Default: "com.termux.execute.session_action" + /** Intent {@code String} extra for shell name for commands for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_SHELL_NAME = TERMUX_PACKAGE_NAME + ".execute.shell_name"; // Default: "com.termux.execute.shell_name" + /** Intent {@code String} extra for the {@link ExecutionCommand.ShellCreateMode} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent. */ + public static final String EXTRA_SHELL_CREATE_MODE = TERMUX_PACKAGE_NAME + ".execute.shell_create_mode"; // Default: "com.termux.execute.shell_create_mode" + /** Intent {@code String} extra for label of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_COMMAND_LABEL = TERMUX_PACKAGE_NAME + ".execute.command_label"; // Default: "com.termux.execute.command_label" + /** Intent markdown {@code String} extra for description of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_COMMAND_DESCRIPTION = TERMUX_PACKAGE_NAME + ".execute.command_description"; // Default: "com.termux.execute.command_description" + /** Intent markdown {@code String} extra for help of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_COMMAND_HELP = TERMUX_PACKAGE_NAME + ".execute.command_help"; // Default: "com.termux.execute.command_help" + /** Intent markdown {@code String} extra for help of the plugin API for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent (Internal Use Only) */ + public static final String EXTRA_PLUGIN_API_HELP = TERMUX_PACKAGE_NAME + ".execute.plugin_api_help"; // Default: "com.termux.execute.plugin_help" + /** Intent {@code Parcelable} extra for the pending intent that should be sent with the + * result of the execution command to the execute command caller for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_PENDING_INTENT = "pendingIntent"; // Default: "pendingIntent" + /** Intent {@code String} extra for the directory path in which to write the result of the + * execution command for the execute command caller for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RESULT_DIRECTORY = TERMUX_PACKAGE_NAME + ".execute.result_directory"; // Default: "com.termux.execute.result_directory" + /** Intent {@code boolean} extra for whether the result should be written to a single file + * or multiple files (err, errmsg, stdout, stderr, exit_code) in + * {@link #EXTRA_RESULT_DIRECTORY} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RESULT_SINGLE_FILE = TERMUX_PACKAGE_NAME + ".execute.result_single_file"; // Default: "com.termux.execute.result_single_file" + /** Intent {@code String} extra for the basename of the result file that should be created + * in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is {@code true} + * for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RESULT_FILE_BASENAME = TERMUX_PACKAGE_NAME + ".execute.result_file_basename"; // Default: "com.termux.execute.result_file_basename" + /** Intent {@code String} extra for the output {@link Formatter} format of the + * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RESULT_FILE_OUTPUT_FORMAT = TERMUX_PACKAGE_NAME + ".execute.result_file_output_format"; // Default: "com.termux.execute.result_file_output_format" + /** Intent {@code String} extra for the error {@link Formatter} format of the + * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RESULT_FILE_ERROR_FORMAT = TERMUX_PACKAGE_NAME + ".execute.result_file_error_format"; // Default: "com.termux.execute.result_file_error_format" + /** Intent {@code String} extra for the optional suffix of the result files that should + * be created in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is + * {@code false} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */ + public static final String EXTRA_RESULT_FILES_SUFFIX = TERMUX_PACKAGE_NAME + ".execute.result_files_suffix"; // Default: "com.termux.execute.result_files_suffix" + + + + /** + * The value for {@link #EXTRA_SESSION_ACTION} extra that will set the new session as + * the current session and will start {@link TERMUX_ACTIVITY} if its not running to bring + * the new session to foreground. + */ + public static final int VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY = 0; + + /** + * The value for {@link #EXTRA_SESSION_ACTION} extra that will keep any existing session + * as the current session and will start {@link TERMUX_ACTIVITY} if its not running to + * bring the existing session to foreground. The new session will be added to the left + * sidebar in the sessions list. + */ + public static final int VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY = 1; + + /** + * The value for {@link #EXTRA_SESSION_ACTION} extra that will set the new session as + * the current session but will not start {@link TERMUX_ACTIVITY} if its not running + * and session(s) will be seen in Termux notification and can be clicked to bring new + * session to foreground. If the {@link TERMUX_ACTIVITY} is already running, then this + * will behave like {@link #VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY}. + */ + public static final int VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY = 2; + + /** + * The value for {@link #EXTRA_SESSION_ACTION} extra that will keep any existing session + * as the current session but will not start {@link TERMUX_ACTIVITY} if its not running + * and session(s) will be seen in Termux notification and can be clicked to bring + * existing session to foreground. If the {@link TERMUX_ACTIVITY} is already running, + * then this will behave like {@link #VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY}. + */ + public static final int VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY = 3; + + /** The minimum allowed value for {@link #EXTRA_SESSION_ACTION}. */ + public static final int MIN_VALUE_EXTRA_SESSION_ACTION = VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY; + + /** The maximum allowed value for {@link #EXTRA_SESSION_ACTION}. */ + public static final int MAX_VALUE_EXTRA_SESSION_ACTION = VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY; + + + /** Intent {@code Bundle} extra to store result of execute command that is sent back for the + * TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent if the {@link #EXTRA_PENDING_INTENT} is not + * {@code null} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE = "result"; // Default: "result" + /** Intent {@code String} extra for stdout value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT = "stdout"; // Default: "stdout" + /** Intent {@code String} extra for original length of stdout value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH = "stdout_original_length"; // Default: "stdout_original_length" + /** Intent {@code String} extra for stderr value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDERR = "stderr"; // Default: "stderr" + /** Intent {@code String} extra for original length of stderr value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH = "stderr_original_length"; // Default: "stderr_original_length" + /** Intent {@code int} extra for exit code value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE = "exitCode"; // Default: "exitCode" + /** Intent {@code int} extra for err value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_ERR = "err"; // Default: "err" + /** Intent {@code String} extra for errmsg value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */ + public static final String EXTRA_PLUGIN_RESULT_BUNDLE_ERRMSG = "errmsg"; // Default: "errmsg" + + } + + + + + + /** Termux app run command service name. */ + public static final String RUN_COMMAND_SERVICE_NAME = TERMUX_PACKAGE_NAME + ".app.RunCommandService"; // Termux app service to receive commands from 3rd party apps "com.termux.app.RunCommandService" + + /** + * Termux app run command service to receive commands sent by 3rd party apps. + */ + public static final class RUN_COMMAND_SERVICE { + + /** Termux RUN_COMMAND Intent help url */ + public static final String RUN_COMMAND_API_HELP_URL = TERMUX_GITHUB_WIKI_REPO_URL + "/RUN_COMMAND-Intent"; // Default: "https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent" + + + /** Intent action to execute command with RUN_COMMAND_SERVICE */ + public static final String ACTION_RUN_COMMAND = TERMUX_PACKAGE_NAME + ".RUN_COMMAND"; // Default: "com.termux.RUN_COMMAND" + + /** Intent {@code String} extra for absolute path of command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_COMMAND_PATH = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_PATH"; // Default: "com.termux.RUN_COMMAND_PATH" + /** Intent {@code String[]} extra for arguments to the executable of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_ARGUMENTS = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_ARGUMENTS"; // Default: "com.termux.RUN_COMMAND_ARGUMENTS" + /** Intent {@code boolean} extra for whether to replace comma alternative characters in arguments with comma characters for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS"; // Default: "com.termux.RUN_COMMAND_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS" + /** Intent {@code String} extra for the comma alternative characters in arguments that should be replaced instead of the default {@link #COMMA_ALTERNATIVE} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS"; // Default: "com.termux.RUN_COMMAND_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS" + + /** Intent {@code String} extra for stdin of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_STDIN = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_STDIN"; // Default: "com.termux.RUN_COMMAND_STDIN" + /** Intent {@code String} extra for current working directory of command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_WORKDIR = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_WORKDIR"; // Default: "com.termux.RUN_COMMAND_WORKDIR" + /** Intent {@code boolean} extra for whether to run command in background {@link Runner#APP_SHELL} or foreground {@link Runner#TERMINAL_SESSION} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + @Deprecated + public static final String EXTRA_BACKGROUND = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_BACKGROUND"; // Default: "com.termux.RUN_COMMAND_BACKGROUND" + /** Intent {@code String} extra for command the {@link Runner} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RUNNER = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RUNNER"; // Default: "com.termux.RUN_COMMAND_RUNNER" + /** Intent {@code String} extra for custom log level for background commands defined by {@link com.termux.shared.logger.Logger} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_BACKGROUND_CUSTOM_LOG_LEVEL"; // Default: "com.termux.RUN_COMMAND_BACKGROUND_CUSTOM_LOG_LEVEL" + /** Intent {@code String} extra for session action of {@link Runner#TERMINAL_SESSION} commands for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_SESSION_ACTION = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_SESSION_ACTION"; // Default: "com.termux.RUN_COMMAND_SESSION_ACTION" + /** Intent {@code String} extra for shell name of commands for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_SHELL_NAME = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_SHELL_NAME"; // Default: "com.termux.RUN_COMMAND_SHELL_NAME" + /** Intent {@code String} extra for the {@link ExecutionCommand.ShellCreateMode} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent. */ + public static final String EXTRA_SHELL_CREATE_MODE = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_SHELL_CREATE_MODE"; // Default: "com.termux.RUN_COMMAND_SHELL_CREATE_MODE" + /** Intent {@code String} extra for label of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_COMMAND_LABEL = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMAND_LABEL"; // Default: "com.termux.RUN_COMMAND_COMMAND_LABEL" + /** Intent markdown {@code String} extra for description of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_COMMAND_DESCRIPTION = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMAND_DESCRIPTION"; // Default: "com.termux.RUN_COMMAND_COMMAND_DESCRIPTION" + /** Intent markdown {@code String} extra for help of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_COMMAND_HELP = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMAND_HELP"; // Default: "com.termux.RUN_COMMAND_COMMAND_HELP" + /** Intent {@code Parcelable} extra for the pending intent that should be sent with the result of the execution command to the execute command caller for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_PENDING_INTENT = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_PENDING_INTENT"; // Default: "com.termux.RUN_COMMAND_PENDING_INTENT" + /** Intent {@code String} extra for the directory path in which to write the result of + * the execution command for the execute command caller for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RESULT_DIRECTORY = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_DIRECTORY"; // Default: "com.termux.RUN_COMMAND_RESULT_DIRECTORY" + /** Intent {@code boolean} extra for whether the result should be written to a single file + * or multiple files (err, errmsg, stdout, stderr, exit_code) in + * {@link #EXTRA_RESULT_DIRECTORY} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RESULT_SINGLE_FILE = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_SINGLE_FILE"; // Default: "com.termux.RUN_COMMAND_RESULT_SINGLE_FILE" + /** Intent {@code String} extra for the basename of the result file that should be created + * in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is {@code true} + * for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RESULT_FILE_BASENAME = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILE_BASENAME"; // Default: "com.termux.RUN_COMMAND_RESULT_FILE_BASENAME" + /** Intent {@code String} extra for the output {@link Formatter} format of the + * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RESULT_FILE_OUTPUT_FORMAT = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILE_OUTPUT_FORMAT"; // Default: "com.termux.RUN_COMMAND_RESULT_FILE_OUTPUT_FORMAT" + /** Intent {@code String} extra for the error {@link Formatter} format of the + * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RESULT_FILE_ERROR_FORMAT = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILE_ERROR_FORMAT"; // Default: "com.termux.RUN_COMMAND_RESULT_FILE_ERROR_FORMAT" + /** Intent {@code String} extra for the optional suffix of the result files that should be + * created in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is + * {@code false} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */ + public static final String EXTRA_RESULT_FILES_SUFFIX = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILES_SUFFIX"; // Default: "com.termux.RUN_COMMAND_RESULT_FILES_SUFFIX" + + } + } +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java new file mode 100644 index 000000000..b300e7e0f --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java @@ -0,0 +1,556 @@ +package com.termux.shared.termux; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.termux.shared.R; +import com.termux.shared.android.AndroidUtils; +import com.termux.shared.android.PackageUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.reflection.ReflectionUtils; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.runner.app.AppShell; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP; +import com.termux.shared.termux.file.TermuxFileUtils; +import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.regex.Pattern; +import org.apache.commons.io.IOUtils; + +public class TermuxUtils { + + /** The modes used by {@link #getAppInfoMarkdownString(Context, AppInfoMode, String)}. */ + public enum AppInfoMode { + /** Get info for Termux app only. */ + TERMUX_PACKAGE, + /** Get info for Termux app and plugin app if context is of plugin app. */ + TERMUX_AND_PLUGIN_PACKAGE, + TERMUX_AND_PLUGIN_PACKAGES, + /* Get info for Termux app and the calling package that called a Termux API. */ + TERMUX_AND_CALLING_PACKAGE, + } + + private static final String LOG_TAG = "TermuxUtils"; + + /** + * Get the {@link Context} for {@link TermuxConstants#TERMUX_PACKAGE_NAME} package with the + * {@link Context#CONTEXT_RESTRICTED} flag. + * + * @param context The {@link Context} to use to get the {@link Context} of the package. + * @return Returns the {@link Context}. This will {@code null} if an exception is raised. + */ + public static Context getTermuxPackageContext(@NonNull Context context) { + return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_PACKAGE_NAME); + } + + /** + * Get the {@link Context} for {@link TermuxConstants#TERMUX_PACKAGE_NAME} package with the + * {@link Context#CONTEXT_INCLUDE_CODE} flag. + * + * @param context The {@link Context} to use to get the {@link Context} of the package. + * @return Returns the {@link Context}. This will {@code null} if an exception is raised. + */ + public static Context getTermuxPackageContextWithCode(@NonNull Context context) { + return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_PACKAGE_NAME, Context.CONTEXT_INCLUDE_CODE); + } + + /** Wrapper for {@link PackageUtils#getContextForPackageOrExitApp(Context, String, boolean, String)}. */ + public static Context getContextForPackageOrExitApp(@NonNull Context context, String packageName, + final boolean exitAppOnError) { + return PackageUtils.getContextForPackageOrExitApp(context, packageName, exitAppOnError, TermuxConstants.TERMUX_GITHUB_REPO_URL); + } + + /** + * Check if Termux app is installed and enabled. This can be used by external apps that don't + * share `sharedUserId` with the Termux app. + * + * If your third-party app is targeting sdk `30` (android `11`), then it needs to add `com.termux` + * package to the `queries` element or request `QUERY_ALL_PACKAGES` permission in its + * `AndroidManifest.xml`. Otherwise it will get `PackageSetting{...... com.termux/......} BLOCKED` + * errors in `logcat` and `RUN_COMMAND` won't work. + * Check [package-visibility](https://developer.android.com/training/basics/intents/package-visibility#package-name), + * `QUERY_ALL_PACKAGES` [googleplay policy](https://support.google.com/googleplay/android-developer/answer/10158779 + * and this [article](https://medium.com/androiddevelopers/working-with-package-visibility-dc252829de2d) for more info. + * + * {@code + * + * + * + * + * } + * + * @param context The context for operations. + * @return Returns {@code errmsg} if {@link TermuxConstants#TERMUX_PACKAGE_NAME} is not installed + * or disabled, otherwise {@code null}. + */ + public static String isTermuxAppInstalled(@NonNull final Context context) { + return PackageUtils.isAppInstalled(context, TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_PACKAGE_NAME); + } + + /** + * Check if Termux app is installed and accessible. This can only be used by apps that share + * `sharedUserId` with the Termux app. + * + * This is done by checking if first checking if app is installed and enabled and then if + * {@code currentPackageContext} can be used to get the {@link Context} of the app with + * {@link TermuxConstants#TERMUX_PACKAGE_NAME} and then if + * {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} exists and has + * {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions. The directory will not + * be automatically created and neither the missing permissions automatically set. + * + * @param currentPackageContext The context of current package. + * @return Returns {@code errmsg} if failed to get termux package {@link Context} or + * {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} is accessible, otherwise {@code null}. + */ + public static String isTermuxAppAccessible(@NonNull final Context currentPackageContext) { + String errmsg = isTermuxAppInstalled(currentPackageContext); + if (errmsg == null) { + Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext); + // If failed to get Termux app package context + if (termuxPackageContext == null) + errmsg = currentPackageContext.getString(R.string.error_termux_app_package_context_not_accessible); + + if (errmsg == null) { + // If TermuxConstants.TERMUX_PREFIX_DIR_PATH is not a directory or does not have required permissions + Error error = TermuxFileUtils.isTermuxPrefixDirectoryAccessible(false, false); + if (error != null) + errmsg = currentPackageContext.getString(R.string.error_termux_prefix_dir_path_not_accessible, + PackageUtils.getAppNameForPackage(currentPackageContext)); + } + } + + if (errmsg != null) + return errmsg + " " + currentPackageContext.getString(R.string.msg_termux_app_required_by_app, + PackageUtils.getAppNameForPackage(currentPackageContext)); + else + return null; + } + + + + /** + * Get a field value from the {@link TERMUX_APP#BUILD_CONFIG_CLASS_NAME} class of the Termux app + * APK installed on the device. + * This can only be used by apps that share `sharedUserId` with the Termux app. + * + * This is a wrapper for {@link #getTermuxAppAPKClassField(Context, String, String)}. + * + * @param currentPackageContext The context of current package. + * @param fieldName The name of the field to get. + * @return Returns the field value, otherwise {@code null} if an exception was raised or failed + * to get termux app package context. + */ + public static Object getTermuxAppAPKBuildConfigClassField(@NonNull Context currentPackageContext, + @NonNull String fieldName) { + return getTermuxAppAPKClassField(currentPackageContext, TERMUX_APP.BUILD_CONFIG_CLASS_NAME, fieldName); + } + + /** + * Get a field value from a class of the Termux app APK installed on the device. + * This can only be used by apps that share `sharedUserId` with the Termux app. + * + * This is done by getting first getting termux app package context and then getting in class + * loader (instead of current app's) that contains termux app class info, and then using that to + * load the required class and then getting required field from it. + * + * Note that the value returned is from the APK file and not the current value loaded in Termux + * app process, so only default values will be returned. + * + * Trying to access {@code null} fields will result in {@link NoSuchFieldException}. + * + * @param currentPackageContext The context of current package. + * @param clazzName The name of the class from which to get the field. + * @param fieldName The name of the field to get. + * @return Returns the field value, otherwise {@code null} if an exception was raised or failed + * to get termux app package context. + */ + public static Object getTermuxAppAPKClassField(@NonNull Context currentPackageContext, + @NonNull String clazzName, @NonNull String fieldName) { + try { + Context termuxPackageContext = TermuxUtils.getTermuxPackageContextWithCode(currentPackageContext); + if (termuxPackageContext == null) + return null; + + Class clazz = termuxPackageContext.getClassLoader().loadClass(clazzName); + return ReflectionUtils.invokeField(clazz, fieldName, null).value; + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" value from \"" + clazzName + "\" class", e); + return null; + } + } + + + + /** Returns {@code true} if {@link Uri} has `package:` scheme for {@link TermuxConstants#TERMUX_PACKAGE_NAME} or its sub plugin package. */ + public static boolean isUriDataForTermuxOrPluginPackage(@NonNull Uri data) { + return data.toString().equals("package:" + TermuxConstants.TERMUX_PACKAGE_NAME) || + data.toString().startsWith("package:" + TermuxConstants.TERMUX_PACKAGE_NAME + "."); + } + + /** Returns {@code true} if {@link Uri} has `package:` scheme for {@link TermuxConstants#TERMUX_PACKAGE_NAME} sub plugin package. */ + public static boolean isUriDataForTermuxPluginPackage(@NonNull Uri data) { + return data.toString().startsWith("package:" + TermuxConstants.TERMUX_PACKAGE_NAME + "."); + } + + /** + * 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(@NonNull 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); + } + } + + + + /** + * Wrapper for {@link #getAppInfoMarkdownString(Context, AppInfoMode, String)}. + * + * @param currentPackageContext The context of current package. + * @param appInfoMode The {@link AppInfoMode} to decide the app info required. + * @return Returns the markdown {@link String}. + */ + public static String getAppInfoMarkdownString(final Context currentPackageContext, final AppInfoMode appInfoMode) { + return getAppInfoMarkdownString(currentPackageContext, appInfoMode, null); + } + + /** + * Get a markdown {@link String} for the apps info of termux app, its installed plugin apps or + * external apps that called a Termux API depending on {@link AppInfoMode} passed. + * + * Also check {@link PackageUtils#isAppInstalled(Context, String, String) if targetting targeting + * sdk `30` (android `11`) since {@link PackageManager.NameNotFoundException} may be thrown while + * getting info of {@code callingPackageName} app. + * + * @param currentPackageContext The context of current package. + * @param appInfoMode The {@link AppInfoMode} to decide the app info required. + * @param callingPackageName The optional package name for a plugin or external app. + * @return Returns the markdown {@link String}. + */ + public static String getAppInfoMarkdownString(final Context currentPackageContext, final AppInfoMode appInfoMode, @Nullable String callingPackageName) { + if (appInfoMode == null) return null; + + if (appInfoMode == AppInfoMode.TERMUX_PACKAGE) { + return getAppInfoMarkdownString(currentPackageContext, false); + } + return null; + + } + + /** + * Get a markdown {@link String} for the app info. If the {@code context} passed is different + * from the {@link TermuxConstants#TERMUX_PACKAGE_NAME} package context, then this function + * must have been called by a different package like a plugin, so we return info for both packages + * if {@code returnTermuxPackageInfoToo} is {@code true}. + * + * @param currentPackageContext The context of current package. + * @param returnTermuxPackageInfoToo If set to {@code true}, then will return info of the + * {@link TermuxConstants#TERMUX_PACKAGE_NAME} package as well if its different from current package. + * @return Returns the markdown {@link String}. + */ + public static String getAppInfoMarkdownString(final Context currentPackageContext, final boolean returnTermuxPackageInfoToo) { + if (currentPackageContext == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + Context termuxPackageContext = getTermuxPackageContext(currentPackageContext); + + String termuxPackageName = null; + String termuxAppName = null; + if (termuxPackageContext != null) { + termuxPackageName = PackageUtils.getPackageNameForPackage(termuxPackageContext); + termuxAppName = PackageUtils.getAppNameForPackage(termuxPackageContext); + } + + String currentPackageName = PackageUtils.getPackageNameForPackage(currentPackageContext); + String currentAppName = PackageUtils.getAppNameForPackage(currentPackageContext); + + boolean isTermuxPackage = (termuxPackageName != null && termuxPackageName.equals(currentPackageName)); + + + if (returnTermuxPackageInfoToo && !isTermuxPackage) + markdownString.append("## ").append(currentAppName).append(" App Info (Current)\n"); + else + markdownString.append("## ").append(currentAppName).append(" App Info\n"); + markdownString.append(getAppInfoMarkdownStringInner(currentPackageContext)); + markdownString.append("\n##\n"); + + if (returnTermuxPackageInfoToo && termuxPackageContext != null && !isTermuxPackage) { + markdownString.append("\n\n## ").append(termuxAppName).append(" App Info\n"); + markdownString.append(getAppInfoMarkdownStringInner(termuxPackageContext)); + markdownString.append("\n##\n"); + } + + + return markdownString.toString(); + } + + /** + * Get a markdown {@link String} for the app info for the package associated with the {@code context}. + * + * @param context The context for operations for the package. + * @return Returns the markdown {@link String}. + */ + public static String getAppInfoMarkdownStringInner(@NonNull final Context context) { + StringBuilder markdownString = new StringBuilder(); + + markdownString.append((AndroidUtils.getAppInfoMarkdownString(context))); + + if (context.getPackageName().equals(TermuxConstants.TERMUX_PACKAGE_NAME)) { + AndroidUtils.appendPropertyToMarkdown(markdownString, "TERMUX_APP_PACKAGE_MANAGER", TermuxBootstrap.TERMUX_APP_PACKAGE_MANAGER); + AndroidUtils.appendPropertyToMarkdown(markdownString, "TERMUX_APP_PACKAGE_VARIANT", TermuxBootstrap.TERMUX_APP_PACKAGE_VARIANT); + } + + Error error; + error = TermuxFileUtils.isTermuxFilesDirectoryAccessible(context, true, true); + if (error != null) { + AndroidUtils.appendPropertyToMarkdown(markdownString, "TERMUX_FILES_DIR", TermuxConstants.TERMUX_FILES_DIR_PATH); + AndroidUtils.appendPropertyToMarkdown(markdownString, "IS_TERMUX_FILES_DIR_ACCESSIBLE", "false - " + Error.getMinimalErrorString(error)); + } + + String signingCertificateSHA256Digest = PackageUtils.getSigningCertificateSHA256DigestForPackage(context); + if (signingCertificateSHA256Digest != null) { + AndroidUtils.appendPropertyToMarkdown(markdownString,"APK_RELEASE", getAPKRelease(signingCertificateSHA256Digest)); + AndroidUtils.appendPropertyToMarkdown(markdownString,"SIGNING_CERTIFICATE_SHA256_DIGEST", signingCertificateSHA256Digest); + } + + return markdownString.toString(); + } + + /** + * Get a markdown {@link String} for reporting an issue. + * + * @param context The context for operations. + * @return Returns the markdown {@link String}. + */ + public static String getReportIssueMarkdownString(@NonNull final Context context) { + if (context == null) return "null"; + + final var markdownString = new StringBuilder(); + + markdownString.append("## Where To Report An Issue"); + + markdownString.append("\n\n").append(context.getString(R.string.msg_report_issue, TermuxConstants.TERMUX_WIKI_URL)).append("\n"); + + markdownString.append("\n\n### Email\n"); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_SUPPORT_EMAIL_URL, TermuxConstants.TERMUX_SUPPORT_EMAIL_MAILTO_URL)).append(" "); + + markdownString.append("\n\n### GitHub Issues for AndroidIDE apps\n"); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_ISSUES_REPO_URL)).append(" "); + + markdownString.append("\n\n### GitHub Issues for AndroidIDE Terminal packages\n"); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_PACKAGES_GITHUB_ISSUES_REPO_URL)).append(" "); + + markdownString.append("\n##\n"); + + return markdownString.toString(); + } + + /** + * Get a markdown {@link String} for important links. + * + * @param context The context for operations. + * @return Returns the markdown {@link String}. + */ + public static String getImportantLinksMarkdownString(@NonNull final Context context) { + if (context == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## Important Links"); + + markdownString.append("\n\n### GitHub\n"); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_REPO_URL)).append(" "); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_URL)).append(" "); + + markdownString.append("\n\n### Email\n"); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_SUPPORT_EMAIL_URL, TermuxConstants.TERMUX_SUPPORT_EMAIL_MAILTO_URL)).append(" "); + + markdownString.append("\n\n### Wiki\n"); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_WIKI, TermuxConstants.TERMUX_WIKI_URL)).append(" "); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_WIKI_REPO_URL)).append(" "); + markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_PACKAGES_GITHUB_WIKI_REPO_URL)).append(" "); + + markdownString.append("\n##\n"); + + return markdownString.toString(); + } + + + + /** + * Get a markdown {@link String} for APT info of the app. + * + * This will take a few seconds to run due to running {@code apt update} command. + * + * @param context The context for operations. + * @return Returns the markdown {@link String}. + */ + public static String geAPTInfoMarkdownString(@NonNull final Context context) { + + String aptInfoScript; + InputStream inputStream = context.getResources().openRawResource(com.termux.shared.R.raw.apt_info_script); + try { + aptInfoScript = IOUtils.toString(inputStream, Charset.defaultCharset()); + } catch (IOException e) { + Logger.logError(LOG_TAG, "Failed to get APT info script: " + e.getMessage()); + return null; + } + + IOUtils.closeQuietly(inputStream); + + if (aptInfoScript == null || aptInfoScript.isEmpty()) { + Logger.logError(LOG_TAG, "The APT info script is null or empty"); + return null; + } + + aptInfoScript = aptInfoScript.replaceAll(Pattern.quote("@TERMUX_PREFIX@"), TermuxConstants.TERMUX_PREFIX_DIR_PATH); + + ExecutionCommand executionCommand = new ExecutionCommand(-1, + TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/bash", null, aptInfoScript, + null, ExecutionCommand.Runner.APP_SHELL.getName(), false); + executionCommand.commandLabel = "APT Info Command"; + executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF; + AppShell appShell = AppShell.execute(context, executionCommand, null, new TermuxShellEnvironment(), null, true); + if (appShell == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0) { + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + return null; + } + + if (!executionCommand.resultData.stderr.toString().isEmpty()) + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + + StringBuilder markdownString = new StringBuilder(); + + markdownString.append("## ").append(TermuxConstants.TERMUX_APP_NAME).append(" APT Info\n\n"); + markdownString.append(executionCommand.resultData.stdout.toString()); + markdownString.append("\n##\n"); + + return markdownString.toString(); + } + + /** + * Get a markdown {@link String} for info for termux debugging. + * + * @param context The context for operations. + * @return Returns the markdown {@link String}. + */ + public static String getTermuxDebugMarkdownString(@NonNull final Context context) { + String statInfo = TermuxFileUtils.getTermuxFilesStatMarkdownString(context); + String logcatInfo = getLogcatDumpMarkdownString(context); + + if (statInfo != null && logcatInfo != null) + return statInfo + "\n\n" + logcatInfo; + else if (statInfo != null) + return statInfo; + else + return logcatInfo; + + } + + /** + * Get a markdown {@link String} for logcat command dump. + * + * @param context The context for operations. + * @return Returns the markdown {@link String}. + */ + public static String getLogcatDumpMarkdownString(@NonNull final Context context) { + // Build script + // We need to prevent OutOfMemoryError since StreamGobbler StringBuilder + StringBuilder.toString() + // may require lot of memory if dump is too large. + // Putting a limit at 3000 lines. Assuming average 160 chars/line will result in 500KB usage + // per object. + // That many lines should be enough for debugging for recent issues anyways assuming termux + // has not been granted READ_LOGS permission s. + String logcatScript = "/system/bin/logcat -d -t 3000 2>&1"; + + // Run script + // Logging must be disabled for output of logcat command itself in StreamGobbler + ExecutionCommand executionCommand = new ExecutionCommand(-1, "/system/bin/sh", + null, logcatScript + "\n", "/", ExecutionCommand.Runner.APP_SHELL.getName(), true); + executionCommand.commandLabel = "Logcat dump command"; + executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF; + AppShell appShell = AppShell.execute(context, executionCommand, null, new TermuxShellEnvironment(), null, true); + if (appShell == null || !executionCommand.isSuccessful()) { + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + return null; + } + + // Build script output + StringBuilder logcatOutput = new StringBuilder(); + logcatOutput.append("$ ").append(logcatScript); + logcatOutput.append("\n").append(executionCommand.resultData.stdout.toString()); + + boolean stderrSet = !executionCommand.resultData.stderr.toString().isEmpty(); + if (executionCommand.resultData.exitCode != 0 || stderrSet) { + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + if (stderrSet) + logcatOutput.append("\n").append(executionCommand.resultData.stderr.toString()); + logcatOutput.append("\n").append("exit code: ").append(executionCommand.resultData.exitCode.toString()); + } + + // Build markdown output + StringBuilder markdownString = new StringBuilder(); + markdownString.append("## Logcat Dump\n\n"); + markdownString.append("\n\n").append(MarkdownUtils.getMarkdownCodeForString(logcatOutput.toString(), true)); + markdownString.append("\n##\n"); + + return markdownString.toString(); + } + + + + public static String getAPKRelease(String signingCertificateSHA256Digest) { + if (signingCertificateSHA256Digest == null) return "null"; + + switch (signingCertificateSHA256Digest.toUpperCase()) { + case TermuxConstants.APK_RELEASE_FDROID_SIGNING_CERTIFICATE_SHA256_DIGEST: + return TermuxConstants.APK_RELEASE_FDROID; + case TermuxConstants.APK_RELEASE_GITHUB_SIGNING_CERTIFICATE_SHA256_DIGEST: + return TermuxConstants.APK_RELEASE_GITHUB; + case TermuxConstants.APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST: + return TermuxConstants.APK_RELEASE_GOOGLE_PLAYSTORE; + case TermuxConstants.APK_RELEASE_TERMUX_DEVS_SIGNING_CERTIFICATE_SHA256_DIGEST: + return TermuxConstants.APK_RELEASE_TERMUX_DEVS; + default: + return "Unknown"; + } + } + + + /** + * Get a process id of the main app process of the {@link TermuxConstants#TERMUX_PACKAGE_NAME} + * package. + * + * @param context The context for operations. + * @return Returns the process if found and running, otherwise {@code null}. + */ + public static String getTermuxAppPID(final Context context) { + return PackageUtils.getPackagePID(context, TermuxConstants.TERMUX_PACKAGE_NAME); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java new file mode 100644 index 000000000..508198314 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java @@ -0,0 +1,410 @@ +package com.termux.shared.termux.crash; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.android.AndroidUtils; +import com.termux.shared.crash.CrashHandler; +import com.termux.shared.data.DataUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.models.ReportInfo; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.models.UserAction; +import com.termux.shared.termux.notification.TermuxNotificationUtils; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; + +import java.nio.charset.Charset; + +public class TermuxCrashUtils implements CrashHandler.CrashHandlerClient { + + public enum TYPE { + UNCAUGHT_EXCEPTION, + CAUGHT_EXCEPTION; + } + + private final TYPE mType; + + private static final String LOG_TAG = "TermuxCrashUtils"; + + TermuxCrashUtils(TYPE type) { + mType = type; + } + + /** + * Set default uncaught crash handler of the app to {@link CrashHandler} for Termux app + * and its plugins to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. + */ + public static void setDefaultCrashHandler(@NonNull final Context context) { + CrashHandler.setDefaultCrashHandler(context, new TermuxCrashUtils(TYPE.UNCAUGHT_EXCEPTION)); + } + + /** + * Set uncaught crash handler of current non-main thread to {@link CrashHandler} for Termux app + * and its plugins to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. + */ + public static void setCrashHandler(@NonNull final Context context) { + CrashHandler.setCrashHandler(context, new TermuxCrashUtils(TYPE.CAUGHT_EXCEPTION)); + } + + /** + * Get {@link CrashHandler} for Termux app and its plugins that can be set as the uncaught + * crash handler of a non-main thread to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. + */ + public static CrashHandler getCrashHandler(@NonNull final Context context) { + return CrashHandler.getCrashHandler(context, new TermuxCrashUtils(TYPE.CAUGHT_EXCEPTION)); + } + + /** + * Log a crash to {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH} and notify termux app + * by sending it the {@link TERMUX_APP.TERMUX_ACTIVITY#ACTION_NOTIFY_APP_CRASH} broadcast. + * + * @param context The {@link Context} for operations. + * @param throwable The {@link Throwable} thrown for the crash. + */ + public static void logCrash(@NonNull final Context context, final Throwable throwable) { + if (throwable == null) return; + CrashHandler.logCrash(context, new TermuxCrashUtils(TYPE.CAUGHT_EXCEPTION), Thread.currentThread(), throwable); + } + + @Override + public boolean onPreLogCrash(Context context, Thread thread, Throwable throwable) { + return false; + } + + @Override + public void onPostLogCrash(final Context currentPackageContext, Thread thread, Throwable throwable) { + if (currentPackageContext == null) return; + String currentPackageName = currentPackageContext.getPackageName(); + + // Do not notify if is a non-termux app + final Context context = TermuxUtils.getTermuxPackageContext(currentPackageContext); + if (context == null) { + Logger.logWarn(LOG_TAG, "Ignoring call to onPostLogCrash() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context"); + return; + } + + // If an uncaught exception, then do not notify since the termux app itself would be crashing + if (TYPE.UNCAUGHT_EXCEPTION.equals(mType) && TermuxConstants.TERMUX_PACKAGE_NAME.equals(currentPackageName)) + return; + + String message = TERMUX_APP.TERMUX_ACTIVITY_NAME + " that \"" + currentPackageName + "\" app crashed"; + + try { + Logger.logInfo(LOG_TAG, "Sending broadcast to notify " + message); + Intent intent = new Intent(TERMUX_APP.TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH); + intent.setPackage(TermuxConstants.TERMUX_PACKAGE_NAME); + context.sendBroadcast(intent); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG,"Failed to notify " + message, e); + } + } + + @NonNull + @Override + public String getCrashLogFilePath(Context context) { + return TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH; + } + + @Override + public String getAppInfoMarkdownString(Context context) { + return TermuxUtils.getAppInfoMarkdownString(context, true); + } + + /** + * Notify the user of an app crash by reading the crash info from the crash log file + * at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. The crash log file would have been + * created by {@link com.termux.shared.crash.CrashHandler}. + * + * If the crash log file exists and is not empty and + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} is + * enabled, then a notification will be shown for the crash on the + * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME} channel, otherwise nothing will be done. + * + * After reading from the crash log file, it will be moved to {@link TermuxConstants#TERMUX_CRASH_LOG_BACKUP_FILE_PATH}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTagParam The log tag to use for logging. + */ + public static void notifyAppCrashFromCrashLogFile(final Context currentPackageContext, final String logTagParam) { + if (currentPackageContext == null) return; + String currentPackageName = currentPackageContext.getPackageName(); + + final Context context = TermuxUtils.getTermuxPackageContext(currentPackageContext); + if (context == null) { + Logger.logWarn(LOG_TAG, "Ignoring call to notifyAppCrash() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context"); + return; + } + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; + + // If user has disabled notifications for crashes + if (!preferences.areCrashReportNotificationsEnabled(false)) + return; + + new Thread() { + @Override + public void run() { + notifyAppCrashFromCrashLogFileInner(context, logTagParam); + } + }.start(); + } + + private static synchronized void notifyAppCrashFromCrashLogFileInner(final Context context, final String logTagParam) { + String logTag = DataUtils.getDefaultIfNull(logTagParam, LOG_TAG); + + if (!FileUtils.regularFileExists(TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, false)) + return; + + Error error; + StringBuilder reportStringBuilder = new StringBuilder(); + + // Read report string from crash log file + error = FileUtils.readTextFromFile("crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, Charset.defaultCharset(), reportStringBuilder, false); + if (error != null) { + Logger.logErrorExtended(logTag, error.toString()); + return; + } + + // Move crash log file to backup location if it exists + error = FileUtils.moveRegularFile("crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, TermuxConstants.TERMUX_CRASH_LOG_BACKUP_FILE_PATH, true); + if (error != null) { + Logger.logErrorExtended(logTag, error.toString()); + } + + String reportString = reportStringBuilder.toString(); + + if (reportString.isEmpty()) + return; + + Logger.logDebug(logTag, "A crash log file found at \"" + TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH + "\"."); + + sendCrashReportNotification(context, logTag, null, null, reportString, false, false, null, false); + } + + + + + /** + * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the crash report and notification. + * @param message The message for the crash report. + * @param throwable The {@link Throwable} for the crash report. + */ + public static void sendCrashReportNotification(final Context currentPackageContext, String logTag, + CharSequence title, String message, Throwable throwable) { + sendCrashReportNotification(currentPackageContext, logTag, + title, message, + MarkdownUtils.getMarkdownCodeForString(Logger.getMessageAndStackTraceString(message, throwable), true), + false, false, true); + } + + /** + * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the crash report and notification. + * @param notificationTextString The text of the notification. + * @param message The message for the crash report. + */ + public static void sendCrashReportNotification(final Context currentPackageContext, String logTag, + CharSequence title, String notificationTextString, + String message) { + sendCrashReportNotification(currentPackageContext, logTag, + title, notificationTextString, message, + false, false, true); + } + + /** + * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the crash report and notification. + * @param notificationTextString The text of the notification. + * @param message The message for the crash report. + * @param forceNotification If set to {@code true}, then a notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} + * is {@code false}. + * @param showToast If set to {@code true}, then a toast will be shown for {@code notificationTextString}. + * @param addDeviceInfo If set to {@code true}, then device info should be appended to the message. + */ + public static void sendCrashReportNotification(final Context currentPackageContext, String logTag, + CharSequence title, String notificationTextString, + String message, boolean forceNotification, + boolean showToast, + boolean addDeviceInfo) { + sendCrashReportNotification(currentPackageContext, logTag, + title, notificationTextString, "## " + title + "\n\n" + message + "\n\n", + forceNotification, showToast, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGE, addDeviceInfo); + } + + /** + * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the crash report and notification. + * @param notificationTextString The text of the notification. + * @param message The message for the crash report. + * @param forceNotification If set to {@code true}, then a notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} + * is {@code false}. + * @param showToast If set to {@code true}, then a toast will be shown for {@code notificationTextString}. + * @param appInfoMode The {@link TermuxUtils.AppInfoMode} to use to add app info to the message. + * Set to {@code null} if app info should not be appended to the message. + * @param addDeviceInfo If set to {@code true}, then device info should be appended to the message. + */ + public static void sendCrashReportNotification(final Context currentPackageContext, String logTag, + CharSequence title, + String notificationTextString, + String message, boolean forceNotification, + boolean showToast, + TermuxUtils.AppInfoMode appInfoMode, + boolean addDeviceInfo) { + // Note: Do not change currentPackageContext or termuxPackageContext passed to functions or things will break + + if (currentPackageContext == null) return; + String currentPackageName = currentPackageContext.getPackageName(); + + final Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext); + if (termuxPackageContext == null) { + Logger.logWarn(LOG_TAG, "Ignoring call to sendCrashReportNotification() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context"); + return; + } + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(termuxPackageContext); + if (preferences == null) return; + + // If user has disabled notifications for crashes + if (!preferences.areCrashReportNotificationsEnabled(true) && !forceNotification) + return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + if (showToast) + Logger.showToast(currentPackageContext, notificationTextString, true); + + // Send a notification to show the crash log which when clicked will open the {@link ReportActivity} + // to show the details of the crash + if (title == null || title.toString().isEmpty()) + title = TermuxConstants.TERMUX_APP_NAME + " Crash Report"; + + Logger.logDebug(logTag, "Sending \"" + title + "\" notification."); + + StringBuilder reportString = new StringBuilder(message); + + if (appInfoMode != null) + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(currentPackageContext, appInfoMode, currentPackageName)); + + if (addDeviceInfo) + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(currentPackageContext, true)); + + String userActionName = UserAction.CRASH_REPORT.getName(); + + ReportInfo reportInfo = new ReportInfo(userActionName, logTag, title.toString()); + reportInfo.setReportString(reportString.toString()); + reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(currentPackageContext)); + reportInfo.setAddReportInfoHeaderToMarkdown(true); + reportInfo.setReportSaveFileLabelAndPath(userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)); + + ReportActivity.NewInstanceResult result = ReportActivity.newInstance(termuxPackageContext, reportInfo); + if (result.contentIntent == null) return; + + // Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous + int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(termuxPackageContext); + + PendingIntent contentIntent = PendingIntent.getActivity(termuxPackageContext, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + PendingIntent deleteIntent = null; + if (result.deleteIntent != null) + deleteIntent = PendingIntent.getBroadcast(termuxPackageContext, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Setup the notification channel if not already set up + setupCrashReportsNotificationChannel(termuxPackageContext); + + // Use markdown in notification + CharSequence notificationTextCharSequence = MarkdownUtils.getSpannedMarkdownText(termuxPackageContext, notificationTextString); + //CharSequence notificationTextCharSequence = notificationTextString; + + // Build the notification + Notification.Builder builder = getCrashReportsNotificationBuilder(currentPackageContext, termuxPackageContext, + title, notificationTextCharSequence, notificationTextCharSequence, contentIntent, deleteIntent, + NotificationUtils.NOTIFICATION_MODE_VIBRATE); + if (builder == null) return; + + // Send the notification + NotificationManager notificationManager = NotificationUtils.getNotificationManager(termuxPackageContext); + if (notificationManager != null) + notificationManager.notify(nextNotificationId, builder.build()); + } + + /** + * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param termuxPackageContext The {@link Context} of termux package. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getCrashReportsNotificationBuilder(final Context currentPackageContext, + final Context termuxPackageContext, + final CharSequence title, + final CharSequence notificationText, + final CharSequence notificationBigText, + final PendingIntent contentIntent, + final PendingIntent deleteIntent, + final int notificationMode) { + return TermuxNotificationUtils.getTermuxOrPluginAppNotificationBuilder( + currentPackageContext, termuxPackageContext, + TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH, + title, notificationText, notificationBigText, contentIntent, deleteIntent, notificationMode); + } + + /** + * Setup the notification channel for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} and + * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + */ + public static void setupCrashReportsNotificationChannel(final Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java new file mode 100644 index 000000000..d35d9f81d --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java @@ -0,0 +1,104 @@ +package com.termux.shared.termux.data; + +import java.util.LinkedHashSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class TermuxUrlUtils { + + public static Pattern URL_MATCH_REGEX; + + public static Pattern getUrlMatchRegex() { + if (URL_MATCH_REGEX != null) return URL_MATCH_REGEX; + + 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("gemini|"); // The Gemini 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-\\uffff0-9]-*){1,}[a-z\\u00a1-\\uffff0-9]{1,}))?|"); + + // 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(")"); + + URL_MATCH_REGEX = Pattern.compile( + regex_sb.toString(), + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); + + return URL_MATCH_REGEX; + } + + public static LinkedHashSet extractUrls(String text) { + LinkedHashSet urlSet = new LinkedHashSet<>(); + Matcher matcher = getUrlMatchRegex().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/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java new file mode 100644 index 000000000..bcec59daa --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java @@ -0,0 +1,151 @@ +package com.termux.shared.termux.extrakeys; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public class ExtraKeyButton { + + /** The key name for the name of the extra key if using a dict to define the extra key. {key: name, ...} */ + public static final String KEY_KEY_NAME = "key"; + + /** The key name for the macro value of the extra key if using a dict to define the extra key. {macro: value, ...} */ + public static final String KEY_MACRO = "macro"; + + /** The key name for the alternate display name of the extra key if using a dict to define the extra key. {display: name, ...} */ + public static final String KEY_DISPLAY_NAME = "display"; + + /** The key name for the nested dict to define popup extra key info if using a dict to define the extra key. {popup: {key: name, ...}, ...} */ + public static final String KEY_POPUP = "popup"; + + + /** + * The key that will be sent to the terminal, either a control character, like defined in + * {@link ExtraKeysConstants#PRIMARY_KEY_CODES_FOR_STRINGS} (LEFT, RIGHT, PGUP...) or some text. + */ + private final String key; + + /** + * If the key is a macro, i.e. a sequence of keys separated by space. + */ + private final boolean macro; + + /** + * The text that will be displayed on the button. + */ + private final String display; + + /** + * The {@link ExtraKeyButton} containing the information of the popup button (triggered by swipe up). + */ + @Nullable + private final ExtraKeyButton popup; + + + /** + * Initialize a {@link ExtraKeyButton}. + * + * @param config The {@link JSONObject} containing the info to create the {@link ExtraKeyButton}. + * @param extraKeyDisplayMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * display text mapping for the keys if a custom value is not defined + * by {@link #KEY_DISPLAY_NAME}. + * @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * aliases for the actual key names. + */ + public ExtraKeyButton(@NonNull JSONObject config, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException { + this(config, null, extraKeyDisplayMap, extraKeyAliasMap); + } + + /** + * Initialize a {@link ExtraKeyButton}. + * + * @param config The {@link JSONObject} containing the info to create the {@link ExtraKeyButton}. + * @param popup The {@link ExtraKeyButton} optional {@link #popup} button. + * @param extraKeyDisplayMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * display text mapping for the keys if a custom value is not defined + * by {@link #KEY_DISPLAY_NAME}. + * @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * aliases for the actual key names. + */ + public ExtraKeyButton(@NonNull JSONObject config, @Nullable ExtraKeyButton popup, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException { + String keyFromConfig = getStringFromJson(config, KEY_KEY_NAME); + String macroFromConfig = getStringFromJson(config, KEY_MACRO); + String[] keys; + if (keyFromConfig != null && macroFromConfig != null) { + throw new JSONException("Both key and macro can't be set for the same key. key: \"" + keyFromConfig + "\", macro: \"" + macroFromConfig + "\""); + } else if (keyFromConfig != null) { + keys = new String[]{keyFromConfig}; + this.macro = false; + } else if (macroFromConfig != null) { + keys = macroFromConfig.split(" "); + this.macro = true; + } else { + throw new JSONException("All keys have to specify either key or macro"); + } + + for (int i = 0; i < keys.length; i++) { + keys[i] = replaceAlias(extraKeyAliasMap, keys[i]); + } + + this.key = TextUtils.join(" ", keys); + + String displayFromConfig = getStringFromJson(config, KEY_DISPLAY_NAME); + if (displayFromConfig != null) { + this.display = displayFromConfig; + } else { + this.display = Arrays.stream(keys) + .map(key -> extraKeyDisplayMap.get(key, key)) + .collect(Collectors.joining(" ")); + } + + this.popup = popup; + } + + public String getStringFromJson(@NonNull JSONObject config, @NonNull String key) { + try { + return config.getString(key); + } catch (JSONException e) { + return null; + } + } + + /** Get {@link #key}. */ + public String getKey() { + return key; + } + + /** Check whether a {@link #macro} is defined or not. */ + public boolean isMacro() { + return macro; + } + + /** Get {@link #display}. */ + public String getDisplay() { + return display; + } + + /** Get {@link #popup}. */ + @Nullable + public ExtraKeyButton getPopup() { + return popup; + } + + /** + * Replace the alias with its actual key name if found in extraKeyAliasMap. + */ + public static String replaceAlias(@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap, String key) { + return extraKeyAliasMap.get(key, key); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java new file mode 100644 index 000000000..b747381c0 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java @@ -0,0 +1,212 @@ +package com.termux.shared.termux.extrakeys; + +import android.view.KeyEvent; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ExtraKeysConstants { + + /** Defines the repetitive keys that can be passed to {@link ExtraKeysView#setRepetitiveKeys(List)}. */ + public static List PRIMARY_REPETITIVE_KEYS = Arrays.asList( + "UP", "DOWN", "LEFT", "RIGHT", + "BKSP", "DEL", + "PGUP", "PGDN"); + + + + /** Defines the {@link KeyEvent} for common keys. */ + public static Map PRIMARY_KEY_CODES_FOR_STRINGS = new HashMap() {{ + put("SPACE", KeyEvent.KEYCODE_SPACE); + put("ESC", KeyEvent.KEYCODE_ESCAPE); + put("TAB", KeyEvent.KEYCODE_TAB); + put("HOME", KeyEvent.KEYCODE_MOVE_HOME); + put("END", KeyEvent.KEYCODE_MOVE_END); + put("PGUP", KeyEvent.KEYCODE_PAGE_UP); + put("PGDN", KeyEvent.KEYCODE_PAGE_DOWN); + put("INS", KeyEvent.KEYCODE_INSERT); + put("DEL", KeyEvent.KEYCODE_FORWARD_DEL); + put("BKSP", KeyEvent.KEYCODE_DEL); + put("UP", KeyEvent.KEYCODE_DPAD_UP); + put("LEFT", KeyEvent.KEYCODE_DPAD_LEFT); + put("RIGHT", KeyEvent.KEYCODE_DPAD_RIGHT); + put("DOWN", KeyEvent.KEYCODE_DPAD_DOWN); + put("ENTER", KeyEvent.KEYCODE_ENTER); + put("F1", KeyEvent.KEYCODE_F1); + put("F2", KeyEvent.KEYCODE_F2); + put("F3", KeyEvent.KEYCODE_F3); + put("F4", KeyEvent.KEYCODE_F4); + put("F5", KeyEvent.KEYCODE_F5); + put("F6", KeyEvent.KEYCODE_F6); + put("F7", KeyEvent.KEYCODE_F7); + put("F8", KeyEvent.KEYCODE_F8); + put("F9", KeyEvent.KEYCODE_F9); + put("F10", KeyEvent.KEYCODE_F10); + put("F11", KeyEvent.KEYCODE_F11); + put("F12", KeyEvent.KEYCODE_F12); + }}; + + + + /** + * HashMap that implements Python dict.get(key, default) function. + * Default java.util .get(key) is then the same as .get(key, null); + */ + static class CleverMap extends HashMap { + V get(K key, V defaultValue) { + if (containsKey(key)) + return get(key); + else + return defaultValue; + } + } + + public static class ExtraKeyDisplayMap extends CleverMap {} + + + + /* + * Multiple maps are available to quickly change + * the style of the keys. + */ + + public static class EXTRA_KEY_DISPLAY_MAPS { + /** + * Keys are displayed in a natural looking way, like "→" for "RIGHT" + */ + public static final ExtraKeyDisplayMap CLASSIC_ARROWS_DISPLAY = new ExtraKeyDisplayMap() {{ + // classic arrow keys (for ◀ ▶ ▲ ▼ @see arrowVariationDisplay) + put("LEFT", "←"); // U+2190 ← LEFTWARDS ARROW + put("RIGHT", "→"); // U+2192 → RIGHTWARDS ARROW + put("UP", "↑"); // U+2191 ↑ UPWARDS ARROW + put("DOWN", "↓"); // U+2193 ↓ DOWNWARDS ARROW + }}; + + public static final ExtraKeyDisplayMap WELL_KNOWN_CHARACTERS_DISPLAY = new ExtraKeyDisplayMap() {{ + // well known characters // https://en.wikipedia.org/wiki/{Enter_key, Tab_key, Delete_key} + put("ENTER", "↲"); // U+21B2 ↲ DOWNWARDS ARROW WITH TIP LEFTWARDS + put("TAB", "↹"); // U+21B9 ↹ LEFTWARDS ARROW TO BAR OVER RIGHTWARDS ARROW TO BAR + put("BKSP", "⌫"); // U+232B ⌫ ERASE TO THE LEFT sometimes seen and easy to understand + put("DEL", "⌦"); // U+2326 ⌦ ERASE TO THE RIGHT not well known but easy to understand + put("DRAWER", "☰"); // U+2630 ☰ TRIGRAM FOR HEAVEN not well known but easy to understand + put("KEYBOARD", "⌨"); // U+2328 ⌨ KEYBOARD not well known but easy to understand + put("PASTE", "⎘"); // U+2398 + put("SCROLL", "⇳"); // U+21F3 + }}; + + public static final ExtraKeyDisplayMap LESS_KNOWN_CHARACTERS_DISPLAY = new ExtraKeyDisplayMap() {{ + // https://en.wikipedia.org/wiki/{Home_key, End_key, Page_Up_and_Page_Down_keys} + // home key can mean "goto the beginning of line" or "goto first page" depending on context, hence the diagonal + put("HOME", "⇱"); // from IEC 9995 // U+21F1 ⇱ NORTH WEST ARROW TO CORNER + put("END", "⇲"); // from IEC 9995 // ⇲ // U+21F2 ⇲ SOUTH EAST ARROW TO CORNER + put("PGUP", "⇑"); // no ISO character exists, U+21D1 ⇑ UPWARDS DOUBLE ARROW will do the trick + put("PGDN", "⇓"); // no ISO character exists, U+21D3 ⇓ DOWNWARDS DOUBLE ARROW will do the trick + }}; + + public static final ExtraKeyDisplayMap ARROW_TRIANGLE_VARIATION_DISPLAY = new ExtraKeyDisplayMap() {{ + // alternative to classic arrow keys + put("LEFT", "◀"); // U+25C0 ◀ BLACK LEFT-POINTING TRIANGLE + put("RIGHT", "▶"); // U+25B6 ▶ BLACK RIGHT-POINTING TRIANGLE + put("UP", "▲"); // U+25B2 ▲ BLACK UP-POINTING TRIANGLE + put("DOWN", "▼"); // U+25BC ▼ BLACK DOWN-POINTING TRIANGLE + }}; + + public static final ExtraKeyDisplayMap NOT_KNOWN_ISO_CHARACTERS = new ExtraKeyDisplayMap() {{ + // Control chars that are more clear as text // https://en.wikipedia.org/wiki/{Function_key, Alt_key, Control_key, Esc_key} + // put("FN", "FN"); // no ISO character exists + put("CTRL", "⎈"); // ISO character "U+2388 ⎈ HELM SYMBOL" is unknown to people and never printed on computers, however "U+25C7 ◇ WHITE DIAMOND" is a nice presentation, and "^" for terminal app and mac is often used + put("ALT", "⎇"); // ISO character "U+2387 ⎇ ALTERNATIVE KEY SYMBOL'" is unknown to people and only printed as the Option key "⌥" on Mac computer + put("ESC", "⎋"); // ISO character "U+238B ⎋ BROKEN CIRCLE WITH NORTHWEST ARROW" is unknown to people and not often printed on computers + }}; + + public static final ExtraKeyDisplayMap NICER_LOOKING_DISPLAY = new ExtraKeyDisplayMap() {{ + // nicer looking for most cases + put("-", "―"); // U+2015 ― HORIZONTAL BAR + }}; + + /** + * Full Iso + */ + public static final ExtraKeyDisplayMap FULL_ISO_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{ + putAll(CLASSIC_ARROWS_DISPLAY); + putAll(WELL_KNOWN_CHARACTERS_DISPLAY); + putAll(LESS_KNOWN_CHARACTERS_DISPLAY); // NEW + putAll(NICER_LOOKING_DISPLAY); + putAll(NOT_KNOWN_ISO_CHARACTERS); // NEW + }}; + + /** + * Only arrows + */ + public static final ExtraKeyDisplayMap ARROWS_ONLY_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{ + putAll(CLASSIC_ARROWS_DISPLAY); + // putAll(wellKnownCharactersDisplay); // REMOVED + // putAll(lessKnownCharactersDisplay); // REMOVED + putAll(NICER_LOOKING_DISPLAY); + }}; + + /** + * Classic symbols and less known symbols + */ + public static final ExtraKeyDisplayMap LOTS_OF_ARROWS_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{ + putAll(CLASSIC_ARROWS_DISPLAY); + putAll(WELL_KNOWN_CHARACTERS_DISPLAY); + putAll(LESS_KNOWN_CHARACTERS_DISPLAY); // NEW + putAll(NICER_LOOKING_DISPLAY); + }}; + + /** + * Some classic symbols everybody knows + */ + public static final ExtraKeyDisplayMap DEFAULT_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{ + putAll(CLASSIC_ARROWS_DISPLAY); + putAll(WELL_KNOWN_CHARACTERS_DISPLAY); + putAll(NICER_LOOKING_DISPLAY); + // all other characters are displayed as themselves + }}; + + } + + + + /** + * Aliases for the keys + */ + public static final ExtraKeyDisplayMap CONTROL_CHARS_ALIASES = new ExtraKeyDisplayMap() {{ + put("ESCAPE", "ESC"); + put("CONTROL", "CTRL"); + put("SHFT", "SHIFT"); + put("RETURN", "ENTER"); // Technically different keys, but most applications won't see the difference + put("FUNCTION", "FN"); + // no alias for ALT + + // Directions are sometimes written as first and last letter for brevety + put("LT", "LEFT"); + put("RT", "RIGHT"); + put("DN", "DOWN"); + // put("UP", "UP"); well, "UP" is already two letters + + put("PAGEUP", "PGUP"); + put("PAGE_UP", "PGUP"); + put("PAGE UP", "PGUP"); + put("PAGE-UP", "PGUP"); + + // no alias for HOME + // no alias for END + + put("PAGEDOWN", "PGDN"); + put("PAGE_DOWN", "PGDN"); + put("PAGE-DOWN", "PGDN"); + + put("DELETE", "DEL"); + put("BACKSPACE", "BKSP"); + + // easier for writing in termux.properties + put("BACKSLASH", "\\"); + put("QUOTE", "\""); + put("APOSTROPHE", "'"); + }}; + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysInfo.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysInfo.java new file mode 100644 index 000000000..2f5bb4a7b --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysInfo.java @@ -0,0 +1,213 @@ +package com.termux.shared.termux.extrakeys; + +import android.view.View; +import android.widget.Button; + +import androidx.annotation.NonNull; + +import com.google.android.material.button.MaterialButton; +import com.termux.shared.termux.extrakeys.ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS; +import com.termux.shared.termux.terminal.io.TerminalExtraKeys; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * A {@link Class} that defines the info needed by {@link ExtraKeysView} to display the extra key + * views. + * + * The {@code propertiesInfo} passed to the constructors of this class must be json array of arrays. + * Each array element of the json array will be considered a separate row of keys. + * Each key can either be simple string that defines the name of the key or a json dict that defines + * advance info for the key. The syntax can be `'KEY'` or `{key: 'KEY'}`. + * For example `HOME` or `{key: 'HOME', ...}. + * + * In advance json dict mode, the key can also be a sequence of space separated keys instead of one + * key. This can be done by replacing `key` key/value pair of the dict with a `macro` key/value pair. + * The syntax is `{macro: 'KEY COMBINATION'}`. For example {macro: 'HOME RIGHT', ...}. + * + * In advance json dict mode, you can define a nested json dict with the `popup` key which will be + * used as the popup key and will be triggered on swipe up. The syntax can be + * `{key: 'KEY', popup: 'POPUP_KEY'}` or `{key: 'KEY', popup: {macro: 'KEY COMBINATION', display: 'Key combo'}}`. + * For example `{key: 'HOME', popup: {KEY: 'END', ...}, ...}`. + * + * In advance json dict mode, the key can also have a custom display name that can be used as the + * text to display on the button by defining the `display` key. The syntax is `{display: 'DISPLAY'}`. + * For example {display: 'Custom name', ...}. + * + * Examples: + * {@code + * # Empty: + * [] + * + * # Single row: + * [[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]] + * + * # 2 row: + * [['ESC','/',{key: '-', popup: '|'},'HOME','UP','END','PGUP'], + * ['TAB','CTRL','ALT','LEFT','DOWN','RIGHT','PGDN']] + * + * # Advance: + * [[ + * {key: ESC, popup: {macro: "CTRL f d", display: "tmux exit"}}, + * {key: CTRL, popup: {macro: "CTRL f BKSP", display: "tmux ←"}}, + * {key: ALT, popup: {macro: "CTRL f TAB", display: "tmux →"}}, + * {key: TAB, popup: {macro: "ALT a", display: A-a}}, + * {key: LEFT, popup: HOME}, + * {key: DOWN, popup: PGDN}, + * {key: UP, popup: PGUP}, + * {key: RIGHT, popup: END}, + * {macro: "ALT j", display: A-j, popup: {macro: "ALT g", display: A-g}}, + * {key: KEYBOARD, popup: {macro: "CTRL d", display: exit}} + * ]] + * + * } + * + * Aliases are also allowed for the keys that you can pass as {@code extraKeyAliasMap}. Check + * {@link ExtraKeysConstants#CONTROL_CHARS_ALIASES}. + * + * Its up to the {@link ExtraKeysView.IExtraKeysView} client on how to handle individual key values + * of an {@link ExtraKeyButton}. They are sent as is via + * {@link ExtraKeysView.IExtraKeysView#onExtraKeyButtonClick(View, ExtraKeyButton, MaterialButton)}. The + * {@link TerminalExtraKeys} which is an implementation of the interface, + * checks if the key is one of {@link ExtraKeysConstants#PRIMARY_KEY_CODES_FOR_STRINGS} and generates + * a {@link android.view.KeyEvent} for it, and if its not, then converts the key to code points by + * calling {@link CharSequence#codePoints()} and passes them to the terminal as literal strings. + * + * Examples: + * {@code + * "ENTER" will trigger the ENTER keycode + * "LEFT" will trigger the LEFT keycode and be displayed as "←" + * "→" will input a "→" character + * "−" will input a "−" character + * "-_-" will input the string "-_-" + * } + * + * For more info, check https://wiki.termux.com/wiki/Touch_Keyboard. + */ +public class ExtraKeysInfo { + + /** + * Matrix of buttons to be displayed in {@link ExtraKeysView}. + */ + private final ExtraKeyButton[][] mButtons; + + /** + * Initialize {@link ExtraKeysInfo}. + * + * @param propertiesInfo The {@link String} containing the info to create the {@link ExtraKeysInfo}. + * Check the class javadoc for details. + * @param style The style to pass to {@link #getCharDisplayMapForStyle(String)} to get the + * {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the display text + * mapping for the keys if a custom value is not defined by + * {@link ExtraKeyButton#KEY_DISPLAY_NAME} for a key. + * @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * aliases for the actual key names. You can create your own or + * optionally pass {@link ExtraKeysConstants#CONTROL_CHARS_ALIASES}. + */ + public ExtraKeysInfo(@NonNull String propertiesInfo, String style, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException { + mButtons = initExtraKeysInfo(propertiesInfo, getCharDisplayMapForStyle(style), extraKeyAliasMap); + } + + /** + * Initialize {@link ExtraKeysInfo}. + * + * @param propertiesInfo The {@link String} containing the info to create the {@link ExtraKeysInfo}. + * Check the class javadoc for details. + * @param extraKeyDisplayMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * display text mapping for the keys if a custom value is not defined + * by {@link ExtraKeyButton#KEY_DISPLAY_NAME} for a key. You can create + * your own or optionally pass one of the values defined in + * {@link #getCharDisplayMapForStyle(String)}. + * @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the + * aliases for the actual key names. You can create your own or + * optionally pass {@link ExtraKeysConstants#CONTROL_CHARS_ALIASES}. + */ + public ExtraKeysInfo(@NonNull String propertiesInfo, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException { + mButtons = initExtraKeysInfo(propertiesInfo, extraKeyDisplayMap, extraKeyAliasMap); + } + + private ExtraKeyButton[][] initExtraKeysInfo(@NonNull String propertiesInfo, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap, + @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException { + // Convert String propertiesInfo to Array of Arrays + JSONArray arr = new JSONArray(propertiesInfo); + Object[][] matrix = new Object[arr.length()][]; + for (int i = 0; i < arr.length(); i++) { + JSONArray line = arr.getJSONArray(i); + matrix[i] = new Object[line.length()]; + for (int j = 0; j < line.length(); j++) { + matrix[i][j] = line.get(j); + } + } + + // convert matrix to buttons + ExtraKeyButton[][] buttons = new ExtraKeyButton[matrix.length][]; + for (int i = 0; i < matrix.length; i++) { + buttons[i] = new ExtraKeyButton[matrix[i].length]; + for (int j = 0; j < matrix[i].length; j++) { + Object key = matrix[i][j]; + + JSONObject jobject = normalizeKeyConfig(key); + + ExtraKeyButton button; + + if (!jobject.has(ExtraKeyButton.KEY_POPUP)) { + // no popup + button = new ExtraKeyButton(jobject, extraKeyDisplayMap, extraKeyAliasMap); + } else { + // a popup + JSONObject popupJobject = normalizeKeyConfig(jobject.get(ExtraKeyButton.KEY_POPUP)); + ExtraKeyButton popup = new ExtraKeyButton(popupJobject, extraKeyDisplayMap, extraKeyAliasMap); + button = new ExtraKeyButton(jobject, popup, extraKeyDisplayMap, extraKeyAliasMap); + } + + buttons[i][j] = button; + } + } + + return buttons; + } + + /** + * Convert "value" -> {"key": "value"}. Required by + * {@link ExtraKeyButton#ExtraKeyButton(JSONObject, ExtraKeyButton, ExtraKeysConstants.ExtraKeyDisplayMap, ExtraKeysConstants.ExtraKeyDisplayMap)}. + */ + private static JSONObject normalizeKeyConfig(Object key) throws JSONException { + JSONObject jobject; + if (key instanceof String) { + jobject = new JSONObject(); + jobject.put(ExtraKeyButton.KEY_KEY_NAME, key); + } else if (key instanceof JSONObject) { + jobject = (JSONObject) key; + } else { + throw new JSONException("An key in the extra-key matrix must be a string or an object"); + } + return jobject; + } + + public ExtraKeyButton[][] getMatrix() { + return mButtons; + } + + @NonNull + public static ExtraKeysConstants.ExtraKeyDisplayMap getCharDisplayMapForStyle(String style) { + switch (style) { + case "arrows-only": + return EXTRA_KEY_DISPLAY_MAPS.ARROWS_ONLY_CHAR_DISPLAY; + case "arrows-all": + return EXTRA_KEY_DISPLAY_MAPS.LOTS_OF_ARROWS_CHAR_DISPLAY; + case "all": + return EXTRA_KEY_DISPLAY_MAPS.FULL_ISO_CHAR_DISPLAY; + case "none": + return new ExtraKeysConstants.ExtraKeyDisplayMap(); + default: + return EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY; + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java new file mode 100644 index 000000000..4fbbf1e17 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java @@ -0,0 +1,681 @@ +package com.termux.shared.termux.extrakeys; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.AttributeSet; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ScheduledExecutorService; + +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; + +import android.view.HapticFeedbackConstants; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.GridLayout; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.button.MaterialButton; +import com.termux.shared.R; +import com.termux.shared.termux.terminal.io.TerminalExtraKeys; +import com.termux.shared.theme.ThemeUtils; + +/** + * A {@link View} showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft + * keyboards. + * + * To use it, add following to a layout file and import it in your activity layout file or inflate + * it with a {@link androidx.viewpager.widget.ViewPager}.: + * {@code + * + * + * } + * + * Then in your activity, get its reference by a call to {@link android.app.Activity#findViewById(int)} + * or {@link LayoutInflater#inflate(int, ViewGroup)} if using {@link androidx.viewpager.widget.ViewPager}. + * Then call {@link #setExtraKeysViewClient(IExtraKeysView)} and pass it the implementation of + * {@link IExtraKeysView} so that you can receive callbacks. You can also override other values set + * in {@link ExtraKeysView#ExtraKeysView(Context, AttributeSet)} by calling the respective functions. + * If you extend {@link ExtraKeysView}, you can also set them in the constructor, but do call super(). + * + * After this you will have to make a call to {@link ExtraKeysView#reload(ExtraKeysInfo, float) and pass + * it the {@link ExtraKeysInfo} to load and display the extra keys. Read its class javadocs for more + * info on how to create it. + * + * Termux app defines the view in res/layout/view_terminal_toolbar_extra_keys and + * inflates it in TerminalToolbarViewPager.instantiateItem() and sets the {@link ExtraKeysView} client + * and calls {@link ExtraKeysView#reload(ExtraKeysInfo). + * The {@link ExtraKeysInfo} is created by TermuxAppSharedProperties.setExtraKeys(). + * Then its got and the view height is adjusted in TermuxActivity.setTerminalToolbarHeight(). + * The client used is TermuxTerminalExtraKeys, which extends + * {@link TerminalExtraKeys } to handle Termux app specific logic and + * leave the rest to the super class. + */ +public final class ExtraKeysView extends GridLayout { + + /** The client for the {@link ExtraKeysView}. */ + public interface IExtraKeysView { + + /** + * This is called by {@link ExtraKeysView} when a button is clicked. This is also called + * for {@link #mRepetitiveKeys} and {@link ExtraKeyButton} that have a popup set. + * However, this is not called for {@link #mSpecialButtons}, whose state can instead be read + * via a call to {@link #readSpecialButton(SpecialButton, boolean)}. + * + * @param view The view that was clicked. + * @param buttonInfo The {@link ExtraKeyButton} for the button that was clicked. + * The button may be a {@link ExtraKeyButton#KEY_MACRO} set which can be + * checked with a call to {@link ExtraKeyButton#isMacro()}. + * @param button The {@link MaterialButton} that was clicked. + */ + void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, MaterialButton button); + + /** + * This is called by {@link ExtraKeysView} when a button is clicked so that the client + * can perform any hepatic feedback. This is only called in the {@link MaterialButton.OnClickListener} + * and not for every repeat. Its also called for {@link #mSpecialButtons}. + * + * @param view The view that was clicked. + * @param buttonInfo The {@link ExtraKeyButton} for the button that was clicked. + * @param button The {@link MaterialButton} that was clicked. + * @return Return {@code true} if the client handled the feedback, otherwise {@code false} + * so that {@link ExtraKeysView#performExtraKeyButtonHapticFeedback(View, ExtraKeyButton, MaterialButton)} + * can handle it depending on system settings. + */ + boolean performExtraKeyButtonHapticFeedback(View view, ExtraKeyButton buttonInfo, MaterialButton button); + + } + + + /** Defines the default value for {@link #mButtonTextColor} defined by current theme. */ + public static final int ATTR_BUTTON_TEXT_COLOR = R.attr.extraKeysButtonTextColor; + /** Defines the default value for {@link #mButtonActiveTextColor} defined by current theme. */ + public static final int ATTR_BUTTON_ACTIVE_TEXT_COLOR = R.attr.extraKeysButtonActiveTextColor; + /** Defines the default value for {@link #mButtonBackgroundColor} defined by current theme. */ + public static final int ATTR_BUTTON_BACKGROUND_COLOR = R.attr.extraKeysButtonBackgroundColor; + /** Defines the default value for {@link #mButtonActiveBackgroundColor} defined by current theme. */ + public static final int ATTR_BUTTON_ACTIVE_BACKGROUND_COLOR = R.attr.extraKeysButtonActiveBackgroundColor; + + /** Defines the default fallback value for {@link #mButtonTextColor} if {@link #ATTR_BUTTON_TEXT_COLOR} is undefined. */ + public static final int DEFAULT_BUTTON_TEXT_COLOR = 0xFFFFFFFF; + /** Defines the default fallback value for {@link #mButtonActiveTextColor} if {@link #ATTR_BUTTON_ACTIVE_TEXT_COLOR} is undefined. */ + public static final int DEFAULT_BUTTON_ACTIVE_TEXT_COLOR = 0xFF80DEEA; + /** Defines the default fallback value for {@link #mButtonBackgroundColor} if {@link #ATTR_BUTTON_BACKGROUND_COLOR} is undefined. */ + public static final int DEFAULT_BUTTON_BACKGROUND_COLOR = 0x00000000; + /** Defines the default fallback value for {@link #mButtonActiveBackgroundColor} if {@link #ATTR_BUTTON_ACTIVE_BACKGROUND_COLOR} is undefined. */ + public static final int DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR = 0xFF7F7F7F; + + + + /** Defines the minimum allowed duration in milliseconds for {@link #mLongPressTimeout}. */ + public static final int MIN_LONG_PRESS_DURATION = 200; + /** Defines the maximum allowed duration in milliseconds for {@link #mLongPressTimeout}. */ + public static final int MAX_LONG_PRESS_DURATION = 3000; + /** Defines the fallback duration in milliseconds for {@link #mLongPressTimeout}. */ + public static final int FALLBACK_LONG_PRESS_DURATION = 400; + + /** Defines the minimum allowed duration in milliseconds for {@link #mLongPressRepeatDelay}. */ + public static final int MIN_LONG_PRESS__REPEAT_DELAY = 5; + /** Defines the maximum allowed duration in milliseconds for {@link #mLongPressRepeatDelay}. */ + public static final int MAX_LONG_PRESS__REPEAT_DELAY = 2000; + /** Defines the default duration in milliseconds for {@link #mLongPressRepeatDelay}. */ + public static final int DEFAULT_LONG_PRESS_REPEAT_DELAY = 80; + + + + /** The implementation of the {@link IExtraKeysView} that acts as a client for the {@link ExtraKeysView}. */ + protected IExtraKeysView mExtraKeysViewClient; + + /** The map for the {@link SpecialButton} and their {@link SpecialButtonState}. Defaults to + * the one returned by {@link #getDefaultSpecialButtons(ExtraKeysView)}. */ + protected Map mSpecialButtons; + + /** The keys for the {@link SpecialButton} added to {@link #mSpecialButtons}. This is automatically + * set when the call to {@link #setSpecialButtons(Map)} is made. */ + protected Set mSpecialButtonsKeys; + + + /** + * The list of keys for which auto repeat of key should be triggered if its extra keys button + * is long pressed. This is done by calling {@link IExtraKeysView#onExtraKeyButtonClick(View, ExtraKeyButton, MaterialButton)} + * every {@link #mLongPressRepeatDelay} seconds after {@link #mLongPressTimeout} has passed. + * The default keys are defined by {@link ExtraKeysConstants#PRIMARY_REPETITIVE_KEYS}. + */ + protected List mRepetitiveKeys; + + + /** The text color for the extra keys button. Defaults to {@link #DEFAULT_BUTTON_TEXT_COLOR}. */ + protected int mButtonTextColor; + /** The text color for the extra keys button when its active. + * Defaults to {@link #DEFAULT_BUTTON_ACTIVE_TEXT_COLOR}. */ + protected int mButtonActiveTextColor; + /** The background color for the extra keys button. Defaults to {@link #DEFAULT_BUTTON_BACKGROUND_COLOR}. */ + protected int mButtonBackgroundColor; + /** The background color for the extra keys button when its active. Defaults to + * {@link #DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR}. */ + protected int mButtonActiveBackgroundColor; + + /** Defines whether text for the extra keys button should be all capitalized automatically. */ + protected boolean mButtonTextAllCaps = true; + + + /** + * Defines the duration in milliseconds before a press turns into a long press. The default + * duration used is the one returned by a call to {@link ViewConfiguration#getLongPressTimeout()} + * which will return the system defined duration which can be changed in accessibility settings. + * The duration must be in between {@link #MIN_LONG_PRESS_DURATION} and {@link #MAX_LONG_PRESS_DURATION}, + * otherwise {@link #FALLBACK_LONG_PRESS_DURATION} is used. + */ + protected int mLongPressTimeout; + + /** + * Defines the duration in milliseconds for the delay between trigger of each repeat of + * {@link #mRepetitiveKeys}. The default value is defined by {@link #DEFAULT_LONG_PRESS_REPEAT_DELAY}. + * The duration must be in between {@link #MIN_LONG_PRESS__REPEAT_DELAY} and + * {@link #MAX_LONG_PRESS__REPEAT_DELAY}, otherwise {@link #DEFAULT_LONG_PRESS_REPEAT_DELAY} is used. + */ + protected int mLongPressRepeatDelay; + + + /** The popup window shown if {@link ExtraKeyButton#getPopup()} returns a {@code non-null} value + * and a swipe up action is done on an extra key. */ + protected PopupWindow mPopupWindow; + + protected ScheduledExecutorService mScheduledExecutor; + protected Handler mHandler; + protected SpecialButtonsLongHoldRunnable mSpecialButtonsLongHoldRunnable; + protected int mLongPressCount; + + + public ExtraKeysView(Context context, AttributeSet attrs) { + super(context, attrs); + + setRepetitiveKeys(ExtraKeysConstants.PRIMARY_REPETITIVE_KEYS); + setSpecialButtons(getDefaultSpecialButtons(this)); + + setButtonColors( + ThemeUtils.getSystemAttrColor(context, ATTR_BUTTON_TEXT_COLOR, DEFAULT_BUTTON_TEXT_COLOR), + ThemeUtils.getSystemAttrColor(context, ATTR_BUTTON_ACTIVE_TEXT_COLOR, DEFAULT_BUTTON_ACTIVE_TEXT_COLOR), + ThemeUtils.getSystemAttrColor(context, ATTR_BUTTON_BACKGROUND_COLOR, DEFAULT_BUTTON_BACKGROUND_COLOR), + ThemeUtils.getSystemAttrColor(context, ATTR_BUTTON_ACTIVE_BACKGROUND_COLOR, DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR)); + + setLongPressTimeout(ViewConfiguration.getLongPressTimeout()); + setLongPressRepeatDelay(DEFAULT_LONG_PRESS_REPEAT_DELAY); + } + + + /** Get {@link #mExtraKeysViewClient}. */ + public IExtraKeysView getExtraKeysViewClient() { + return mExtraKeysViewClient; + } + + /** Set {@link #mExtraKeysViewClient}. */ + public void setExtraKeysViewClient(IExtraKeysView extraKeysViewClient) { + mExtraKeysViewClient = extraKeysViewClient; + } + + + /** Get {@link #mRepetitiveKeys}. */ + public List getRepetitiveKeys() { + if (mRepetitiveKeys == null) return null; + return mRepetitiveKeys.stream().map(String::new).collect(Collectors.toList()); + } + + /** Set {@link #mRepetitiveKeys}. Must not be {@code null}. */ + public void setRepetitiveKeys(@NonNull List repetitiveKeys) { + mRepetitiveKeys = repetitiveKeys; + } + + + /** Get {@link #mSpecialButtons}. */ + public Map getSpecialButtons() { + if (mSpecialButtons == null) return null; + return mSpecialButtons.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /** Get {@link #mSpecialButtonsKeys}. */ + public Set getSpecialButtonsKeys() { + if (mSpecialButtonsKeys == null) return null; + return mSpecialButtonsKeys.stream().map(String::new).collect(Collectors.toSet()); + } + + /** Set {@link #mSpecialButtonsKeys}. Must not be {@code null}. */ + public void setSpecialButtons(@NonNull Map specialButtons) { + mSpecialButtons = specialButtons; + mSpecialButtonsKeys = this.mSpecialButtons.keySet().stream().map(SpecialButton::getKey).collect(Collectors.toSet()); + } + + + /** + * Set the {@link ExtraKeysView} button colors. + * + * @param buttonTextColor The value for {@link #mButtonTextColor}. + * @param buttonActiveTextColor The value for {@link #mButtonActiveTextColor}. + * @param buttonBackgroundColor The value for {@link #mButtonBackgroundColor}. + * @param buttonActiveBackgroundColor The value for {@link #mButtonActiveBackgroundColor}. + */ + public void setButtonColors(int buttonTextColor, int buttonActiveTextColor, int buttonBackgroundColor, int buttonActiveBackgroundColor) { + mButtonTextColor = buttonTextColor; + mButtonActiveTextColor = buttonActiveTextColor; + mButtonBackgroundColor = buttonBackgroundColor; + mButtonActiveBackgroundColor = buttonActiveBackgroundColor; + } + + + /** Get {@link #mButtonTextColor}. */ + public int getButtonTextColor() { + return mButtonTextColor; + } + + /** Set {@link #mButtonTextColor}. */ + public void setButtonTextColor(int buttonTextColor) { + mButtonTextColor = buttonTextColor; + } + + + /** Get {@link #mButtonActiveTextColor}. */ + public int getButtonActiveTextColor() { + return mButtonActiveTextColor; + } + + /** Set {@link #mButtonActiveTextColor}. */ + public void setButtonActiveTextColor(int buttonActiveTextColor) { + mButtonActiveTextColor = buttonActiveTextColor; + } + + + /** Get {@link #mButtonBackgroundColor}. */ + public int getButtonBackgroundColor() { + return mButtonBackgroundColor; + } + + /** Set {@link #mButtonBackgroundColor}. */ + public void setButtonBackgroundColor(int buttonBackgroundColor) { + mButtonBackgroundColor = buttonBackgroundColor; + } + + + /** Get {@link #mButtonActiveBackgroundColor}. */ + public int getButtonActiveBackgroundColor() { + return mButtonActiveBackgroundColor; + } + + /** Set {@link #mButtonActiveBackgroundColor}. */ + public void setButtonActiveBackgroundColor(int buttonActiveBackgroundColor) { + mButtonActiveBackgroundColor = buttonActiveBackgroundColor; + } + + /** Set {@link #mButtonTextAllCaps}. */ + public void setButtonTextAllCaps(boolean buttonTextAllCaps) { + mButtonTextAllCaps = buttonTextAllCaps; + } + + + /** Get {@link #mLongPressTimeout}. */ + public int getLongPressTimeout() { + return mLongPressTimeout; + } + + /** Set {@link #mLongPressTimeout}. */ + public void setLongPressTimeout(int longPressDuration) { + if (longPressDuration >= MIN_LONG_PRESS_DURATION && longPressDuration <= MAX_LONG_PRESS_DURATION) { + mLongPressTimeout = longPressDuration; + } else { + mLongPressTimeout = FALLBACK_LONG_PRESS_DURATION; + } + } + + /** Get {@link #mLongPressRepeatDelay}. */ + public int getLongPressRepeatDelay() { + return mLongPressRepeatDelay; + } + + /** Set {@link #mLongPressRepeatDelay}. */ + public void setLongPressRepeatDelay(int longPressRepeatDelay) { + if (mLongPressRepeatDelay >= MIN_LONG_PRESS__REPEAT_DELAY && mLongPressRepeatDelay <= MAX_LONG_PRESS__REPEAT_DELAY) { + mLongPressRepeatDelay = longPressRepeatDelay; + } else { + mLongPressRepeatDelay = DEFAULT_LONG_PRESS_REPEAT_DELAY; + } + } + + + /** Get the default map that can be used for {@link #mSpecialButtons}. */ + @NonNull + public Map getDefaultSpecialButtons(ExtraKeysView extraKeysView) { + return new HashMap() {{ + put(SpecialButton.CTRL, new SpecialButtonState(extraKeysView)); + put(SpecialButton.ALT, new SpecialButtonState(extraKeysView)); + put(SpecialButton.SHIFT, new SpecialButtonState(extraKeysView)); + put(SpecialButton.FN, new SpecialButtonState(extraKeysView)); + }}; + } + + + + /** + * Reload this instance of {@link ExtraKeysView} with the info passed in {@code extraKeysInfo}. + * + * @param extraKeysInfo The {@link ExtraKeysInfo} that defines the necessary info for the extra keys. + * @param heightPx The height in pixels of the parent surrounding the {@link ExtraKeysView}. It must + * be a single child. + */ + @SuppressLint("ClickableViewAccessibility") + public void reload(ExtraKeysInfo extraKeysInfo, float heightPx) { + if (extraKeysInfo == null) + return; + + for(SpecialButtonState state : mSpecialButtons.values()) + state.buttons = new ArrayList<>(); + + removeAllViews(); + + ExtraKeyButton[][] buttons = extraKeysInfo.getMatrix(); + + setRowCount(buttons.length); + setColumnCount(maximumLength(buttons)); + + for (int row = 0; row < buttons.length; row++) { + for (int col = 0; col < buttons[row].length; col++) { + final ExtraKeyButton buttonInfo = buttons[row][col]; + + MaterialButton button; + if (isSpecialButton(buttonInfo)) { + button = createSpecialButton(buttonInfo.getKey(), true); + if (button == null) return; + } else { + button = new MaterialButton(getContext(), null, android.R.attr.buttonBarButtonStyle); + } + + button.setText(buttonInfo.getDisplay()); + button.setTextColor(mButtonTextColor); + button.setAllCaps(mButtonTextAllCaps); + button.setPadding(0, 0, 0, 0); + + button.setOnClickListener(view -> { + performExtraKeyButtonHapticFeedback(view, buttonInfo, button); + onAnyExtraKeyButtonClick(view, buttonInfo, button); + }); + + button.setOnTouchListener((view, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + view.setBackgroundColor(mButtonActiveBackgroundColor); + // Start long press scheduled executors which will be stopped in next MotionEvent + startScheduledExecutors(view, buttonInfo, button); + return true; + + case MotionEvent.ACTION_MOVE: + if (buttonInfo.getPopup() != null) { + // Show popup on swipe up + if (mPopupWindow == null && event.getY() < 0) { + stopScheduledExecutors(); + view.setBackgroundColor(mButtonBackgroundColor); + showPopup(view, buttonInfo.getPopup()); + } + if (mPopupWindow != null && event.getY() > 0) { + view.setBackgroundColor(mButtonActiveBackgroundColor); + dismissPopup(); + } + } + return true; + + case MotionEvent.ACTION_CANCEL: + view.setBackgroundColor(mButtonBackgroundColor); + stopScheduledExecutors(); + return true; + + case MotionEvent.ACTION_UP: + view.setBackgroundColor(mButtonBackgroundColor); + stopScheduledExecutors(); + // If ACTION_UP up was not from a repetitive key or was with a key with a popup button + if (mLongPressCount == 0 || mPopupWindow != null) { + // Trigger popup button click if swipe up complete + if (mPopupWindow != null) { + dismissPopup(); + if (buttonInfo.getPopup() != null) { + onAnyExtraKeyButtonClick(view, buttonInfo.getPopup(), button); + } + } else { + view.performClick(); + } + } + return true; + + default: + return true; + } + }); + + LayoutParams param = new GridLayout.LayoutParams(); + param.width = 0; + if(Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) { + param.height = (int)(heightPx + 0.5); + } else { + param.height = 0; + } + param.setMargins(0, 0, 0, 0); + param.columnSpec = GridLayout.spec(col, GridLayout.FILL, 1.f); + param.rowSpec = GridLayout.spec(row, GridLayout.FILL, 1.f); + button.setLayoutParams(param); + + addView(button); + } + } + } + + + + public void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, MaterialButton button) { + if (mExtraKeysViewClient != null) + mExtraKeysViewClient.onExtraKeyButtonClick(view, buttonInfo, button); + } + + public void performExtraKeyButtonHapticFeedback(View view, ExtraKeyButton buttonInfo, MaterialButton button) { + if (mExtraKeysViewClient != null) { + // If client handled the feedback, then just return + if (mExtraKeysViewClient.performExtraKeyButtonHapticFeedback(view, buttonInfo, button)) + return; + } + + if (Settings.System.getInt(getContext().getContentResolver(), + Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) { + + if (Build.VERSION.SDK_INT >= 28) { + button.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + } else { + // Perform haptic feedback only if no total silence mode enabled. + if (Settings.Global.getInt(getContext().getContentResolver(), "zen_mode", 0) != 2) { + button.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + } + } + } + } + + + + public void onAnyExtraKeyButtonClick(View view, @NonNull ExtraKeyButton buttonInfo, MaterialButton button) { + if (isSpecialButton(buttonInfo)) { + if (mLongPressCount > 0) return; + SpecialButtonState state = mSpecialButtons.get(SpecialButton.valueOf(buttonInfo.getKey())); + if (state == null) return; + + // Toggle active state and disable lock state if new state is not active + state.setIsActive(!state.isActive); + if (!state.isActive) + state.setIsLocked(false); + } else { + onExtraKeyButtonClick(view, buttonInfo, button); + } + } + + + public void startScheduledExecutors(View view, ExtraKeyButton buttonInfo, MaterialButton button) { + stopScheduledExecutors(); + mLongPressCount = 0; + if (mRepetitiveKeys.contains(buttonInfo.getKey())) { + // Auto repeat key if long pressed until ACTION_UP stops it by calling stopScheduledExecutors. + // Currently, only one (last) repeat key can run at a time. Old ones are stopped. + mScheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + mScheduledExecutor.scheduleWithFixedDelay(() -> { + mLongPressCount++; + onExtraKeyButtonClick(view, buttonInfo, button); + }, mLongPressTimeout, mLongPressRepeatDelay, TimeUnit.MILLISECONDS); + } else if (isSpecialButton(buttonInfo)) { + // Lock the key if long pressed by running mSpecialButtonsLongHoldRunnable after + // waiting for mLongPressTimeout milliseconds. If user does not long press, then the + // ACTION_UP triggered will cancel the runnable by calling stopScheduledExecutors before + // it has a chance to run. + SpecialButtonState state = mSpecialButtons.get(SpecialButton.valueOf(buttonInfo.getKey())); + if (state == null) return; + if (mHandler == null) + mHandler = new Handler(Looper.getMainLooper()); + mSpecialButtonsLongHoldRunnable = new SpecialButtonsLongHoldRunnable(state); + mHandler.postDelayed(mSpecialButtonsLongHoldRunnable, mLongPressTimeout); + } + } + + public void stopScheduledExecutors() { + if (mScheduledExecutor != null) { + mScheduledExecutor.shutdownNow(); + mScheduledExecutor = null; + } + + if (mSpecialButtonsLongHoldRunnable != null && mHandler != null) { + mHandler.removeCallbacks(mSpecialButtonsLongHoldRunnable); + mSpecialButtonsLongHoldRunnable = null; + } + } + + public class SpecialButtonsLongHoldRunnable implements Runnable { + public final SpecialButtonState mState; + + public SpecialButtonsLongHoldRunnable(SpecialButtonState state) { + mState = state; + } + + public void run() { + // Toggle active and lock state + mState.setIsLocked(!mState.isActive); + mState.setIsActive(!mState.isActive); + mLongPressCount++; + } + } + + + + void showPopup(View view, ExtraKeyButton extraButton) { + int width = view.getMeasuredWidth(); + int height = view.getMeasuredHeight(); + MaterialButton button; + if (isSpecialButton(extraButton)) { + button = createSpecialButton(extraButton.getKey(), false); + if (button == null) return; + } else { + button = new MaterialButton(getContext(), null, android.R.attr.buttonBarButtonStyle); + button.setTextColor(mButtonTextColor); + } + button.setText(extraButton.getDisplay()); + button.setAllCaps(mButtonTextAllCaps); + button.setPadding(0, 0, 0, 0); + button.setMinHeight(0); + button.setMinWidth(0); + button.setMinimumWidth(0); + button.setMinimumHeight(0); + button.setWidth(width); + button.setHeight(height); + button.setBackgroundColor(mButtonActiveBackgroundColor); + mPopupWindow = new PopupWindow(this); + mPopupWindow.setWidth(LayoutParams.WRAP_CONTENT); + mPopupWindow.setHeight(LayoutParams.WRAP_CONTENT); + mPopupWindow.setContentView(button); + mPopupWindow.setOutsideTouchable(true); + mPopupWindow.setFocusable(false); + mPopupWindow.showAsDropDown(view, 0, -2 * height); + } + + public void dismissPopup() { + mPopupWindow.setContentView(null); + mPopupWindow.dismiss(); + mPopupWindow = null; + } + + + + /** Check whether a {@link ExtraKeyButton} is a {@link SpecialButton}. */ + public boolean isSpecialButton(ExtraKeyButton button) { + return mSpecialButtonsKeys.contains(button.getKey()); + } + + /** + * Read whether {@link SpecialButton} registered in {@link #mSpecialButtons} is active or not. + * + * @param specialButton The {@link SpecialButton} to read. + * @param autoSetInActive Set to {@code true} if {@link SpecialButtonState#isActive} should be + * set {@code false} if button is not locked. + * @return Returns {@code null} if button does not exist in {@link #mSpecialButtons}. If button + * exists, then returns {@code true} if the button is created in {@link ExtraKeysView} + * and is active, otherwise {@code false}. + */ + @Nullable + public Boolean readSpecialButton(SpecialButton specialButton, boolean autoSetInActive) { + SpecialButtonState state = mSpecialButtons.get(specialButton); + if (state == null) return null; + + if (!state.isCreated || !state.isActive) + return false; + + // Disable active state only if not locked + if (autoSetInActive && !state.isLocked) + state.setIsActive(false); + + return true; + } + + public MaterialButton createSpecialButton(String buttonKey, boolean needUpdate) { + SpecialButtonState state = mSpecialButtons.get(SpecialButton.valueOf(buttonKey)); + if (state == null) return null; + state.setIsCreated(true); + MaterialButton button = new MaterialButton(getContext(), null, android.R.attr.buttonBarButtonStyle); + button.setTextColor(state.isActive ? mButtonActiveTextColor : mButtonTextColor); + if (needUpdate) { + state.buttons.add(button); + } + return button; + } + + + + /** + * General util function to compute the longest column length in a matrix. + */ + public static int maximumLength(Object[][] matrix) { + int m = 0; + for (Object[] row : matrix) + m = Math.max(m, row.length); + return m; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButton.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButton.java new file mode 100644 index 000000000..382377b4b --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButton.java @@ -0,0 +1,52 @@ +package com.termux.shared.termux.extrakeys; + +import androidx.annotation.NonNull; + +import java.util.HashMap; + +/** The {@link Class} that implements special buttons for {@link ExtraKeysView}. */ +public class SpecialButton { + + private static final HashMap map = new HashMap<>(); + + public static final SpecialButton CTRL = new SpecialButton("CTRL"); + public static final SpecialButton ALT = new SpecialButton("ALT"); + public static final SpecialButton SHIFT = new SpecialButton("SHIFT"); + public static final SpecialButton FN = new SpecialButton("FN"); + + /** The special button key. */ + private final String key; + + /** + * Initialize a {@link SpecialButton}. + * + * @param key The unique key name for the special button. The key is registered in {@link #map} + * with which the {@link SpecialButton} can be retrieved via a call to + * {@link #valueOf(String)}. + */ + public SpecialButton(@NonNull final String key) { + this.key = key; + map.put(key, this); + } + + /** Get {@link #key} for this {@link SpecialButton}. */ + public String getKey() { + return key; + } + + /** + * Get the {@link SpecialButton} for {@code key}. + * + * @param key The unique key name for the special button. + */ + public static SpecialButton valueOf(String key) { + return map.get(key); + } + + @NonNull + @Override + public String toString() { + return key; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java new file mode 100644 index 000000000..606dcc4f5 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java @@ -0,0 +1,51 @@ +package com.termux.shared.termux.extrakeys; + +import com.google.android.material.button.MaterialButton; + +import java.util.ArrayList; +import java.util.List; + +/** The {@link Class} that maintains a state of a {@link SpecialButton} */ +public class SpecialButtonState { + + /** If special button has been created for the {@link ExtraKeysView}. */ + boolean isCreated = false; + /** If special button is active. */ + boolean isActive = false; + /** If special button is locked due to long hold on it and should not be deactivated if its + * state is read. */ + boolean isLocked = false; + + List buttons = new ArrayList<>(); + + ExtraKeysView mExtraKeysView; + + /** + * Initialize a {@link SpecialButtonState} to maintain state of a {@link SpecialButton}. + * + * @param extraKeysView The {@link ExtraKeysView} instance in which the {@link SpecialButton} + * is to be registered. + */ + public SpecialButtonState(ExtraKeysView extraKeysView) { + mExtraKeysView = extraKeysView; + } + + /** Set {@link #isCreated}. */ + public void setIsCreated(boolean value) { + isCreated = value; + } + + /** Set {@link #isActive}. */ + public void setIsActive(boolean value) { + isActive = value; + for (MaterialButton button : buttons) { + button.setTextColor(value ? mExtraKeysView.getButtonActiveTextColor() : mExtraKeysView.getButtonTextColor()); + } + } + + /** Set {@link #isLocked}. */ + public void setIsLocked(boolean value) { + isLocked = value; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/file/TermuxFileUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/file/TermuxFileUtils.java new file mode 100644 index 000000000..6fc79a7e2 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/file/TermuxFileUtils.java @@ -0,0 +1,414 @@ +package com.termux.shared.termux.file; + +import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR_PATH; + +import android.content.Context; +import android.os.Environment; + +import androidx.annotation.NonNull; + +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtilsErrno; +import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; +import com.termux.shared.shell.command.runner.app.AppShell; +import com.termux.shared.android.AndroidUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class TermuxFileUtils { + + private static final String LOG_TAG = "TermuxFileUtils"; + + /** + * Replace "$PREFIX/" or "~/" prefix with termux absolute paths. + * + * @param paths The {@code paths} to expand. + * @return Returns the {@code expand paths}. + */ + public static List getExpandedTermuxPaths(List paths) { + if (paths == null) return null; + List expandedPaths = new ArrayList<>(); + + for (int i = 0; i < paths.size(); i++) { + expandedPaths.add(getExpandedTermuxPath(paths.get(i))); + } + + return expandedPaths; + } + + /** + * Replace "$PREFIX/" or "~/" prefix with termux absolute paths. + * + * @param path The {@code path} to expand. + * @return Returns the {@code expand path}. + */ + public static String getExpandedTermuxPath(String path) { + if (path != null && !path.isEmpty()) { + path = path.replaceAll("^\\$PREFIX$", TermuxConstants.TERMUX_PREFIX_DIR_PATH); + path = path.replaceAll("^\\$PREFIX/", TermuxConstants.TERMUX_PREFIX_DIR_PATH + "/"); + path = path.replaceAll("^~/$", TermuxConstants.TERMUX_HOME_DIR_PATH); + path = path.replaceAll("^~/", TermuxConstants.TERMUX_HOME_DIR_PATH + "/"); + } + + return path; + } + + /** + * Replace termux absolute paths with "$PREFIX/" or "~/" prefix. + * + * @param paths The {@code paths} to unexpand. + * @return Returns the {@code unexpand paths}. + */ + public static List getUnExpandedTermuxPaths(List paths) { + if (paths == null) return null; + List unExpandedPaths = new ArrayList<>(); + + for (int i = 0; i < paths.size(); i++) { + unExpandedPaths.add(getUnExpandedTermuxPath(paths.get(i))); + } + + return unExpandedPaths; + } + + /** + * Replace termux absolute paths with "$PREFIX/" or "~/" prefix. + * + * @param path The {@code path} to unexpand. + * @return Returns the {@code unexpand path}. + */ + public static String getUnExpandedTermuxPath(String path) { + if (path != null && !path.isEmpty()) { + path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_PREFIX_DIR_PATH) + "/", "\\$PREFIX/"); + path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_HOME_DIR_PATH) + "/", "~/"); + } + + return path; + } + + /** + * Get canonical path. + * + * @param path The {@code path} to convert. + * @param prefixForNonAbsolutePath Optional prefix path to prefix before non-absolute paths. This + * can be set to {@code null} if non-absolute paths should + * be prefixed with "/". The call to {@link File#getCanonicalPath()} + * will automatically do this anyways. + * @param expandPath The {@code boolean} that decides if input path is first attempted to be expanded by calling + * {@link TermuxFileUtils#getExpandedTermuxPath(String)} before its passed to + * {@link FileUtils#getCanonicalPath(String, String)}. + + * @return Returns the {@code canonical path}. + */ + public static String getCanonicalPath(String path, final String prefixForNonAbsolutePath, final boolean expandPath) { + if (path == null) path = ""; + + if (expandPath) + path = getExpandedTermuxPath(path); + + return FileUtils.getCanonicalPath(path, prefixForNonAbsolutePath); + } + + /** + * Check if {@code path} is under the allowed termux working directory paths. If it is, then + * allowed parent path is returned. + * + * @param path The {@code path} to check. + * @return Returns the allowed path if it {@code path} is under it, otherwise {@link TermuxConstants#TERMUX_FILES_DIR_PATH}. + */ + public static String getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(String path) { + if (path == null || path.isEmpty()) return TermuxConstants.TERMUX_FILES_DIR_PATH; + + if (path.startsWith(TermuxConstants.TERMUX_STORAGE_HOME_DIR_PATH + "/")) { + return TermuxConstants.TERMUX_STORAGE_HOME_DIR_PATH; + } if (path.startsWith(Environment.getExternalStorageDirectory().getAbsolutePath() + "/")) { + return Environment.getExternalStorageDirectory().getAbsolutePath(); + } else if (path.startsWith("/sdcard/")) { + return "/sdcard"; + } else { + return TermuxConstants.TERMUX_FILES_DIR_PATH; + } + } + + /** + * Validate the existence and permissions of directory file at path as a working directory for + * termux app. + * + * The creation of missing directory and setting of missing permissions will only be done if + * {@code path} is under paths returned by {@link #getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(String)}. + * + * The permissions set to directory will be {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS}. + * + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to validate or create. Symlinks will not be followed. + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @param ignoreErrorsIfPathIsInParentDirPath The {@code boolean} that decides if existence + * and permission errors are to be ignored if path is + * in {@code parentDirPath}. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. This allows making an attempt to set + * executable permissions, but ignoring if it fails. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error validateDirectoryFileExistenceAndPermissions(String label, final String filePath, final boolean createDirectoryIfMissing, + final boolean setPermissions, final boolean setMissingPermissionsOnly, + final boolean ignoreErrorsIfPathIsInParentDirPath, final boolean ignoreIfNotExecutable) { + return FileUtils.validateDirectoryFileExistenceAndPermissions(label, filePath, + TermuxFileUtils.getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(filePath), createDirectoryIfMissing, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setPermissions, setMissingPermissionsOnly, + ignoreErrorsIfPathIsInParentDirPath, ignoreIfNotExecutable); + } + + /** + * Validate if {@link TermuxConstants#TERMUX_FILES_DIR_PATH} exists and has + * {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions. + * + * This is required because binaries compiled for termux are hard coded with + * {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} and the path must be accessible. + * + * The permissions set to directory will be {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS}. + * + * This function does not create the directory manually but by calling {@link Context#getFilesDir()} + * so that android itself creates it. However, the call will not create its parent package + * data directory `/data/user/0/[package_name]` if it does not already exist and a `logcat` + * error will be logged by android. + * {@code Failed to ensure /data/user/0//files: mkdir failed: ENOENT (No such file or directory)} + * An android app normally can't create the package data directory since its parent `/data/user/0` + * is owned by `system` user and is normally created at app install or update time and not at app startup. + * + * Note that the path returned by {@link Context#getFilesDir()} may + * be under `/data/user/[id]/[package_name]` instead of `/data/data/[package_name]` + * defined by default by {@link TermuxConstants#TERMUX_FILES_DIR_PATH} where id will be 0 for + * primary user and a higher number for other users/profiles. If app is running under work profile + * or secondary user, then {@link TermuxConstants#TERMUX_FILES_DIR_PATH} will not be accessible + * and will not be automatically created, unless there is a bind mount from `/data/data` to + * `/data/user/[id]`, ideally in the right namespace. + * https://source.android.com/devices/tech/admin/multi-user + * + * + * On Android version `<=10`, the `/data/user/0` is a symlink to `/data/data` directory. + * https://cs.android.com/android/platform/superproject/+/android-10.0.0_r47:system/core/rootdir/init.rc;l=589 + * {@code + * symlink /data/data /data/user/0 + * } + * + * {@code + * /system/bin/ls -lhd /data/data /data/user/0 + * drwxrwx--x 179 system system 8.0K 2021-xx-xx xx:xx /data/data + * lrwxrwxrwx 1 root root 10 2021-xx-xx xx:xx /data/user/0 -> /data/data + * } + * + * On Android version `>=11`, the `/data/data` directory is bind mounted at `/data/user/0`. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:system/core/rootdir/init.rc;l=705 + * https://cs.android.com/android/_/android/platform/system/core/+/3cca270e95ca8d8bc8b800e2b5d7da1825fd7100 + * {@code + * # Unlink /data/user/0 if we previously symlink it to /data/data + * rm /data/user/0 + * + * # Bind mount /data/user/0 to /data/data + * mkdir /data/user/0 0700 system system encryption=None + * mount none /data/data /data/user/0 bind rec + * } + * + * {@code + * /system/bin/grep -E '( /data )|( /data/data )|( /data/user/[0-9]+ )' /proc/self/mountinfo 2>&1 | /system/bin/grep -v '/data_mirror' 2>&1 + * 87 32 253:5 / /data rw,nosuid,nodev,noatime shared:27 - ext4 /dev/block/dm-5 rw,seclabel,resgid=1065,errors=panic + * 91 87 253:5 /data /data/user/0 rw,nosuid,nodev,noatime shared:27 - ext4 /dev/block/dm-5 rw,seclabel,resgid=1065,errors=panic + * } + * + * The column 4 defines the root of the mount within the filesystem. + * Basically, `/dev/block/dm-5/` is mounted at `/data` and `/dev/block/dm-5/data` is mounted at + * `/data/user/0`. + * https://www.kernel.org/doc/Documentation/filesystems/proc.txt (section 3.5) + * https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt + * https://unix.stackexchange.com/a/571959 + * + * + * Also note that running `/system/bin/ls -lhd /data/user/0/com.termux` as secondary user will result + * in `ls: /data/user/0/com.termux: Permission denied` where `0` is primary user id but running + * `/system/bin/ls -lhd /data/user/10/com.termux` will result in + * `drwx------ 6 u10_a149 u10_a149 4.0K 2021-xx-xx xx:xx /data/user/10/com.termux` where `10` is + * secondary user id. So can't stat directory (not contents) of primary user from secondary user + * but can the other way around. However, this is happening on android 10 avd, but not on android + * 11 avd. + * + * @param context The {@link Context} for operations. + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param setMissingPermissions The {@code boolean} that decides if permissions are to be + * automatically set. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error isTermuxFilesDirectoryAccessible(@NonNull final Context context, boolean createDirectoryIfMissing, boolean setMissingPermissions) { + if (createDirectoryIfMissing) + context.getFilesDir(); + + if (!FileUtils.directoryFileExists(TermuxConstants.TERMUX_FILES_DIR_PATH, true)) + return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError("termux files directory", TermuxConstants.TERMUX_FILES_DIR_PATH); + + if (setMissingPermissions) + FileUtils.setMissingFilePermissions("termux files directory", TermuxConstants.TERMUX_FILES_DIR_PATH, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS); + + return FileUtils.checkMissingFilePermissions("termux files directory", TermuxConstants.TERMUX_FILES_DIR_PATH, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, false); + } + + /** + * Validate if {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} exists and has + * {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions. + * . + * + * The {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} directory would not exist if termux has + * not been installed or the bootstrap setup has not been run or if it was deleted by the user. + * + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param setMissingPermissions The {@code boolean} that decides if permissions are to be + * automatically set. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error isTermuxPrefixDirectoryAccessible(boolean createDirectoryIfMissing, boolean setMissingPermissions) { + return FileUtils.validateDirectoryFileExistenceAndPermissions("termux prefix directory", TermuxConstants.TERMUX_PREFIX_DIR_PATH, + null, createDirectoryIfMissing, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setMissingPermissions, true, + false, false); + } + + /** + * Validate if {@link TermuxConstants#TERMUX_STAGING_PREFIX_DIR_PATH} exists and has + * {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions. + * + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param setMissingPermissions The {@code boolean} that decides if permissions are to be + * automatically set. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error isTermuxPrefixStagingDirectoryAccessible(boolean createDirectoryIfMissing, boolean setMissingPermissions) { + return FileUtils.validateDirectoryFileExistenceAndPermissions("termux prefix staging directory", TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH, + null, createDirectoryIfMissing, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setMissingPermissions, true, + false, false); + } + + /** + * Validate if {@link TermuxConstants.TERMUX_APP#APPS_DIR_PATH} exists and has + * {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions. + * + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param setMissingPermissions The {@code boolean} that decides if permissions are to be + * automatically set. + * @return Returns the {@code error} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static Error isAppsTermuxAppDirectoryAccessible(boolean createDirectoryIfMissing, boolean setMissingPermissions) { + return FileUtils.validateDirectoryFileExistenceAndPermissions("apps/termux-app directory", TermuxConstants.TERMUX_APP.APPS_DIR_PATH, + null, createDirectoryIfMissing, + FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setMissingPermissions, true, + false, false); + } + + /** + * If {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} doesn't exist, is empty or only contains + * files in {@link TermuxConstants#TERMUX_PREFIX_DIR_IGNORED_SUB_FILES_PATHS_TO_CONSIDER_AS_EMPTY}. + */ + public static boolean isTermuxPrefixDirectoryEmpty() { + Error error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles("termux prefix", + TERMUX_PREFIX_DIR_PATH, TermuxConstants.TERMUX_PREFIX_DIR_IGNORED_SUB_FILES_PATHS_TO_CONSIDER_AS_EMPTY, true); + if (error == null) + return true; + + if (!FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE.equalsErrorTypeAndCode(error)) + Logger.logErrorExtended(LOG_TAG, "Failed to check if termux prefix directory is empty:\n" + error.getErrorLogString()); + return false; + } + + /** + * Get a markdown {@link String} for stat output for various Termux app files paths. + * + * @param context The context for operations. + * @return Returns the markdown {@link String}. + */ + public static String getTermuxFilesStatMarkdownString(@NonNull final Context context) { + Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(context); + if (termuxPackageContext == null) return null; + + // Also ensures that termux files directory is created if it does not already exist + String filesDir = termuxPackageContext.getFilesDir().getAbsolutePath(); + + // Build script + StringBuilder statScript = new StringBuilder(); + statScript + .append("echo 'ls info:'\n") + .append("/system/bin/ls -lhdZ") + .append(" '/data/data'") + .append(" '/data/user/0'") + .append(" '" + TermuxConstants.TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH + "'") + .append(" '/data/user/0/" + TermuxConstants.TERMUX_PACKAGE_NAME + "'") + .append(" '" + TermuxConstants.TERMUX_FILES_DIR_PATH + "'") + .append(" '" + filesDir + "'") + .append(" '/data/user/0/" + TermuxConstants.TERMUX_PACKAGE_NAME + "/files'") + .append(" '/data/user/" + TermuxConstants.TERMUX_PACKAGE_NAME + "/files'") + .append(" '" + TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH + "'") + .append(" '" + TermuxConstants.TERMUX_PREFIX_DIR_PATH + "'") + .append(" '" + TermuxConstants.TERMUX_HOME_DIR_PATH + "'") + .append(" '" + TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/login'") + .append(" 2>&1") + .append("\necho; echo 'mount info:'\n") + .append("/system/bin/grep -E '( /data )|( /data/data )|( /data/user/[0-9]+ )' /proc/self/mountinfo 2>&1 | /system/bin/grep -v '/data_mirror' 2>&1"); + + // Run script + ExecutionCommand executionCommand = new ExecutionCommand(-1, "/system/bin/sh", null, + statScript.toString() + "\n", "/", ExecutionCommand.Runner.APP_SHELL.getName(), true); + executionCommand.commandLabel = TermuxConstants.TERMUX_APP_NAME + " Files Stat Command"; + executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF; + AppShell appShell = AppShell.execute(context, executionCommand, null, new TermuxShellEnvironment(), null, true); + if (appShell == null || !executionCommand.isSuccessful()) { + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + return null; + } + + // Build script output + StringBuilder statOutput = new StringBuilder(); + statOutput.append("$ ").append(statScript.toString()); + statOutput.append("\n\n").append(executionCommand.resultData.stdout.toString()); + + boolean stderrSet = !executionCommand.resultData.stderr.toString().isEmpty(); + if (executionCommand.resultData.exitCode != 0 || stderrSet) { + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + if (stderrSet) + statOutput.append("\n").append(executionCommand.resultData.stderr.toString()); + statOutput.append("\n").append("exit code: ").append(executionCommand.resultData.exitCode.toString()); + } + + // Build markdown output + StringBuilder markdownString = new StringBuilder(); + markdownString.append("## ").append(TermuxConstants.TERMUX_APP_NAME).append(" Files Info\n\n"); + AndroidUtils.appendPropertyToMarkdown(markdownString,"TERMUX_REQUIRED_FILES_DIR_PATH ($PREFIX)", TermuxConstants.TERMUX_FILES_DIR_PATH); + AndroidUtils.appendPropertyToMarkdown(markdownString,"ANDROID_ASSIGNED_FILES_DIR_PATH", filesDir); + markdownString.append("\n\n").append(MarkdownUtils.getMarkdownCodeForString(statOutput.toString(), true)); + markdownString.append("\n##\n"); + + return markdownString.toString(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/interact/TextInputDialogUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/interact/TextInputDialogUtils.java new file mode 100644 index 000000000..d644af726 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/interact/TextInputDialogUtils.java @@ -0,0 +1,72 @@ +package com.termux.shared.termux.interact; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.text.Selection; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.ViewGroup.LayoutParams; +import android.widget.EditText; +import android.widget.LinearLayout; + +public final class TextInputDialogUtils { + + public interface TextSetListener { + void onTextSet(String text); + } + + public static void textInput(Activity activity, int titleText, String initialText, + int positiveButtonText, final TextSetListener onPositive, + int neutralButtonText, final TextSetListener onNeutral, + int negativeButtonText, final TextSetListener onNegative, + final DialogInterface.OnDismissListener onDismiss) { + final EditText input = new EditText(activity); + input.setSingleLine(); + if (initialText != null) { + input.setText(initialText); + Selection.setSelection(input.getText(), initialText.length()); + } + + final AlertDialog[] dialogHolder = new AlertDialog[1]; + input.setImeActionLabel(activity.getResources().getString(positiveButtonText), KeyEvent.KEYCODE_ENTER); + input.setOnEditorActionListener((v, actionId, event) -> { + onPositive.onTextSet(input.getText().toString()); + dialogHolder[0].dismiss(); + return true; + }); + + float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, activity.getResources().getDisplayMetrics()); + // https://www.google.com/design/spec/components/dialogs.html#dialogs-specs + int paddingTopAndSides = Math.round(16 * dipInPixels); + int paddingBottom = Math.round(24 * dipInPixels); + + LinearLayout layout = new LinearLayout(activity); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + layout.setPadding(paddingTopAndSides, paddingTopAndSides, paddingTopAndSides, paddingBottom); + layout.addView(input); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity) + .setTitle(titleText).setView(layout) + .setPositiveButton(positiveButtonText, (d, whichButton) -> onPositive.onTextSet(input.getText().toString())); + + if (onNeutral != null) { + builder.setNeutralButton(neutralButtonText, (dialog, which) -> onNeutral.onTextSet(input.getText().toString())); + } + + if (onNegative == null) { + builder.setNegativeButton(android.R.string.cancel, null); + } else { + builder.setNegativeButton(negativeButtonText, (dialog, which) -> onNegative.onTextSet(input.getText().toString())); + } + + if (onDismiss != null) + builder.setOnDismissListener(onDismiss); + + dialogHolder[0] = builder.create(); + dialogHolder[0].setCanceledOnTouchOutside(false); + dialogHolder[0].show(); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/models/UserAction.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/models/UserAction.java new file mode 100644 index 000000000..b7e1c5695 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/models/UserAction.java @@ -0,0 +1,18 @@ +package com.termux.shared.termux.models; + +public enum UserAction { + + CRASH_REPORT("crash report"), + PLUGIN_EXECUTION_COMMAND("plugin execution command"); + + private final String name; + + UserAction(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/notification/TermuxNotificationUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/notification/TermuxNotificationUtils.java new file mode 100644 index 000000000..27b5c2e78 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/notification/TermuxNotificationUtils.java @@ -0,0 +1,109 @@ +package com.termux.shared.termux.notification; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.graphics.drawable.Icon; +import android.os.Build; + +import androidx.annotation.Nullable; + +import com.termux.shared.R; +import com.termux.shared.android.resource.ResourceUtils; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants; + +public class TermuxNotificationUtils { + /** + * Try to get the next unique notification id that isn't already being used by the app. + * + * Termux app and its plugin must use unique notification ids from the same pool due to usage of android:sharedUserId. + * https://commonsware.com/blog/2017/06/07/jobscheduler-job-ids-libraries.html + * + * @param context The {@link Context} for operations. + * @return Returns the notification id that should be safe to use. + */ + public synchronized static int getNextNotificationId(final Context context) { + if (context == null) return TermuxPreferenceConstants.TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID; + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return TermuxPreferenceConstants.TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID; + + int lastNotificationId = preferences.getLastNotificationId(); + + int nextNotificationId = lastNotificationId + 1; + while(nextNotificationId == TermuxConstants.TERMUX_APP_NOTIFICATION_ID || nextNotificationId == TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_ID) { + nextNotificationId++; + } + + if (nextNotificationId == Integer.MAX_VALUE || nextNotificationId < 0) + nextNotificationId = TermuxPreferenceConstants.TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID; + + preferences.setLastNotificationId(nextNotificationId); + return nextNotificationId; + } + + /** + * Get {@link Notification.Builder} for termux app or its plugin. + * + * @param currentPackageContext The {@link Context} of current package. + * @param termuxPackageContext The {@link Context} of termux package. + * @param channelId The channel id for the notification. + * @param priority The priority for the notification. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getTermuxOrPluginAppNotificationBuilder(final Context currentPackageContext, + final Context termuxPackageContext, + final String channelId, + final int priority, + final CharSequence title, + final CharSequence notificationText, + final CharSequence notificationBigText, + final PendingIntent contentIntent, + final PendingIntent deleteIntent, + final int notificationMode) { + Notification.Builder builder = NotificationUtils.geNotificationBuilder(termuxPackageContext, + channelId, priority, + title, notificationText, notificationBigText, contentIntent, deleteIntent, notificationMode); + + if (builder == null) return null; + + // Enable timestamp + builder.setShowWhen(true); + + // Set notification icon + // If a notification is to be shown by a termux plugin app, then we can't use the drawable + // resource id for the plugin app with setSmallIcon(@DrawableRes int icon) since notification + // is shown with termuxPackageContext and termux-app package would have a different id and + // when android tries to load the drawable an exception would be thrown and notification will + // not be thrown. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Set Icon instead of drawable resource id + builder.setSmallIcon(Icon.createWithResource(currentPackageContext, R.drawable.ic_error_notification)); + } else { + // Set drawable resource id used by termux-app package + Integer iconResId = ResourceUtils.getDrawableResourceId(termuxPackageContext, "ic_error_notification", + termuxPackageContext.getPackageName(), true); + if (iconResId != null) + builder.setSmallIcon(iconResId); + } + + // Set background color for small notification icon + builder.setColor(0xFF607D8B); + + // Dismiss on click + builder.setAutoCancel(true); + + return builder; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/plugins/TermuxPluginUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/plugins/TermuxPluginUtils.java new file mode 100644 index 000000000..fb01f427e --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/plugins/TermuxPluginUtils.java @@ -0,0 +1,467 @@ +package com.termux.shared.termux.plugins; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Build; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.R; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.file.FileUtils; +import com.termux.shared.termux.file.TermuxFileUtils; +import com.termux.shared.shell.command.result.ResultConfig; +import com.termux.shared.shell.command.result.ResultData; +import com.termux.shared.errors.Errno; +import com.termux.shared.errors.Error; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.termux.models.UserAction; +import com.termux.shared.termux.notification.TermuxNotificationUtils; +import com.termux.shared.shell.command.result.ResultSender; +import com.termux.shared.shell.ShellUtils; +import com.termux.shared.android.AndroidUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.models.ReportInfo; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.data.DataUtils; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxUtils; + +public class TermuxPluginUtils { + + private static final String LOG_TAG = "TermuxPluginUtils"; + + /** + * Process {@link ExecutionCommand} result. + * + * The ExecutionCommand currentState must be greater or equal to + * {@link ExecutionCommand.ExecutionState#EXECUTED}. + * If the {@link ExecutionCommand#isPluginExecutionCommand} is {@code true} and + * {@link ResultConfig#resultPendingIntent} or {@link ResultConfig#resultDirectoryPath} + * is not {@code null}, then the result of commands are sent back to the command caller. + * + * @param context The {@link Context} that will be used to send result intent to the {@link PendingIntent} creator. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} to process. + */ + public static void processPluginExecutionCommandResult(final Context context, String logTag, final ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + Error error = null; + ResultData resultData = executionCommand.resultData; + + if (!executionCommand.hasExecuted()) { + Logger.logWarn(logTag, executionCommand.getCommandIdAndLabelLogString() + ": Ignoring call to processPluginExecutionCommandResult() since the execution command state is not higher than the ExecutionState.EXECUTED"); + return; + } + + boolean isPluginExecutionCommandWithPendingResult = executionCommand.isPluginExecutionCommandWithPendingResult(); + boolean isExecutionCommandLoggingEnabled = Logger.shouldEnableLoggingForCustomLogLevel(executionCommand.backgroundCustomLogLevel); + + // Log the output. ResultData should not be logged if pending result since ResultSender will do it + // or if logging is disabled + Logger.logDebugExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, + !isPluginExecutionCommandWithPendingResult, isExecutionCommandLoggingEnabled)); + + // If execution command was started by a plugin which expects the result back + if (isPluginExecutionCommandWithPendingResult) { + // Set variables which will be used by sendCommandResultData to send back the result + if (executionCommand.resultConfig.resultPendingIntent != null) + setPluginResultPendingIntentVariables(executionCommand); + if (executionCommand.resultConfig.resultDirectoryPath != null) + setPluginResultDirectoryVariables(executionCommand); + + // Send result to caller + error = ResultSender.sendCommandResultData(context, logTag, executionCommand.getCommandIdAndLabelLogString(), + executionCommand.resultConfig, executionCommand.resultData, isExecutionCommandLoggingEnabled); + if (error != null) { + // error will be added to existing Errors + resultData.setStateFailed(error); + Logger.logDebugExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, true, isExecutionCommandLoggingEnabled)); + + // Flash and send notification for the error + sendPluginCommandErrorNotification(context, logTag, null, + ResultData.getErrorsListMinimalString(resultData), + ExecutionCommand.getExecutionCommandMarkdownString(executionCommand), + false, true, TermuxUtils.AppInfoMode.TERMUX_AND_CALLING_PACKAGE,true, + executionCommand.resultConfig.resultPendingIntent != null ? executionCommand.resultConfig.resultPendingIntent.getCreatorPackage(): null); + } + + } + + if (!executionCommand.isStateFailed() && error == null) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + + /** + * Set {@link ExecutionCommand} state to {@link Errno#ERRNO_FAILED} with {@code errmsg} and + * process error with {@link #processPluginExecutionCommandError(Context, String, ExecutionCommand, boolean)}. + * + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} that failed. + * @param forceNotification If set to {@code true}, then a flash and notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} + * is {@code false}. + * @param errmsg The error message to set. + */ + public static void setAndProcessPluginExecutionCommandError(final Context context, String logTag, + final ExecutionCommand executionCommand, + boolean forceNotification, + @NonNull String errmsg) { + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + processPluginExecutionCommandError(context, logTag, executionCommand, forceNotification); + } + + /** + * Process {@link ExecutionCommand} error. + * + * The ExecutionCommand currentState must be equal to {@link ExecutionCommand.ExecutionState#FAILED}. + * The {@link ResultData#getErrCode()} must have been set to a value greater than + * {@link Errno#ERRNO_SUCCESS}. + * The {@link ResultData#errorsList} must also be set with appropriate error info. + * + * If the {@link ExecutionCommand#isPluginExecutionCommand} is {@code true} and + * {@link ResultConfig#resultPendingIntent} or {@link ResultConfig#resultDirectoryPath} + * is not {@code null}, then the errors of commands are sent back to the command caller. + * + * Otherwise if the {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} is + * enabled, then a flash and a notification will be shown for the error as well + * on the {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME} channel instead of just logging + * the error. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} that failed. + * @param forceNotification If set to {@code true}, then a flash and notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} + * is {@code false}. + */ + public static void processPluginExecutionCommandError(final Context context, String logTag, + final ExecutionCommand executionCommand, + boolean forceNotification) { + if (context == null || executionCommand == null) return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + Error error; + ResultData resultData = executionCommand.resultData; + + if (!executionCommand.isStateFailed()) { + Logger.logWarn(logTag, executionCommand.getCommandIdAndLabelLogString() + ": Ignoring call to processPluginExecutionCommandError() since the execution command is not in ExecutionState.FAILED"); + return; + } + + boolean isPluginExecutionCommandWithPendingResult = executionCommand.isPluginExecutionCommandWithPendingResult(); + boolean isExecutionCommandLoggingEnabled = Logger.shouldEnableLoggingForCustomLogLevel(executionCommand.backgroundCustomLogLevel); + + // Log the error and any exception. ResultData should not be logged if pending result since ResultSender will do it + Logger.logError(logTag, "Processing plugin execution error for:\n" + executionCommand.getCommandIdAndLabelLogString()); + Logger.logError(logTag, "Set log level to debug or higher to see error in logs"); + Logger.logErrorPrivateExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, + !isPluginExecutionCommandWithPendingResult, isExecutionCommandLoggingEnabled)); + + // If execution command was started by a plugin which expects the result back + if (isPluginExecutionCommandWithPendingResult) { + // Set variables which will be used by sendCommandResultData to send back the result + if (executionCommand.resultConfig.resultPendingIntent != null) + setPluginResultPendingIntentVariables(executionCommand); + if (executionCommand.resultConfig.resultDirectoryPath != null) + setPluginResultDirectoryVariables(executionCommand); + + // Send result to caller + error = ResultSender.sendCommandResultData(context, logTag, executionCommand.getCommandIdAndLabelLogString(), + executionCommand.resultConfig, executionCommand.resultData, isExecutionCommandLoggingEnabled); + if (error != null) { + // error will be added to existing Errors + resultData.setStateFailed(error); + Logger.logErrorPrivateExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, true, isExecutionCommandLoggingEnabled)); + forceNotification = true; + } + + // No need to show notifications if a pending intent was sent, let the caller handle the result himself + if (!forceNotification) return; + } + + // Flash and send notification for the error + sendPluginCommandErrorNotification(context, logTag, null, + ResultData.getErrorsListMinimalString(resultData), + ExecutionCommand.getExecutionCommandMarkdownString(executionCommand), + forceNotification, true, TermuxUtils.AppInfoMode.TERMUX_AND_CALLING_PACKAGE, true, + executionCommand.resultConfig.resultPendingIntent != null ? executionCommand.resultConfig.resultPendingIntent.getCreatorPackage(): null); + } + + /** Set variables which will be used by {@link ResultSender#sendCommandResultData(Context, String, String, ResultConfig, ResultData, boolean)} + * to send back the result via {@link ResultConfig#resultPendingIntent}. */ + public static void setPluginResultPendingIntentVariables(ExecutionCommand executionCommand) { + ResultConfig resultConfig = executionCommand.resultConfig; + + resultConfig.resultBundleKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE; + resultConfig.resultStdoutKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT; + resultConfig.resultStdoutOriginalLengthKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH; + resultConfig.resultStderrKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDERR; + resultConfig.resultStderrOriginalLengthKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH; + resultConfig.resultExitCodeKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE; + resultConfig.resultErrCodeKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_ERR; + resultConfig.resultErrmsgKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_ERRMSG; + } + + /** Set variables which will be used by {@link ResultSender#sendCommandResultData(Context, String, String, ResultConfig, ResultData, boolean)} + * to send back the result by writing it to files in {@link ResultConfig#resultDirectoryPath}. */ + public static void setPluginResultDirectoryVariables(ExecutionCommand executionCommand) { + ResultConfig resultConfig = executionCommand.resultConfig; + + resultConfig.resultDirectoryPath = TermuxFileUtils.getCanonicalPath(resultConfig.resultDirectoryPath, null, true); + resultConfig.resultDirectoryAllowedParentPath = TermuxFileUtils.getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(resultConfig.resultDirectoryPath); + + // Set default resultFileBasename if resultSingleFile is true to `-.log` + if (resultConfig.resultSingleFile && resultConfig.resultFileBasename == null) + resultConfig.resultFileBasename = ShellUtils.getExecutableBasename(executionCommand.executable) + "-" + AndroidUtils.getCurrentMilliSecondLocalTimeStamp() + ".log"; + } + + + + + /** + * Send a plugin error report notification for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the error report and notification. + * @param message The message for the error report. + * @param throwable The {@link Throwable} for the error report. + */ + public static void sendPluginCommandErrorNotification(final Context currentPackageContext, String logTag, + CharSequence title, String message, Throwable throwable) { + sendPluginCommandErrorNotification(currentPackageContext, logTag, + title, message, + MarkdownUtils.getMarkdownCodeForString(Logger.getMessageAndStackTraceString(message, throwable), true), + false, false, true); + } + + /** + * Send a plugin error report notification for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the error report and notification. + * @param notificationTextString The text of the notification. + * @param message The message for the error report. + */ + public static void sendPluginCommandErrorNotification(final Context currentPackageContext, String logTag, + CharSequence title, String notificationTextString, + String message) { + sendPluginCommandErrorNotification(currentPackageContext, logTag, + title, notificationTextString, message, + false, false, true); + } + + /** + * Send a plugin error report notification for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the error report and notification. + * @param notificationTextString The text of the notification. + * @param message The message for the error report. + * @param forceNotification If set to {@code true}, then a notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} + * is {@code false}. + * @param showToast If set to {@code true}, then a toast will be shown for {@code notificationTextString}. + * @param addDeviceInfo If set to {@code true}, then device info should be appended to the message. + */ + public static void sendPluginCommandErrorNotification(final Context currentPackageContext, String logTag, + CharSequence title, String notificationTextString, + String message, boolean forceNotification, + boolean showToast, + boolean addDeviceInfo) { + sendPluginCommandErrorNotification(currentPackageContext, logTag, + title, notificationTextString, "## " + title + "\n\n" + message + "\n\n", + forceNotification, showToast, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGE, addDeviceInfo, null); + } + + /** + * Send a plugin error notification for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param logTag The log tag to use for logging. + * @param title The title for the error report and notification. + * @param notificationTextString The text of the notification. + * @param message The message for the error report. + * @param forceNotification If set to {@code true}, then a notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} + * is {@code false}. + * @param showToast If set to {@code true}, then a toast will be shown for {@code notificationTextString}. + * @param appInfoMode The {@link TermuxUtils.AppInfoMode} to use to add app info to the message. + * Set to {@code null} if app info should not be appended to the message. + * @param addDeviceInfo If set to {@code true}, then device info should be appended to the message. + * @param callingPackageName The optional package name of the app for which the plugin command + * was run. + */ + public static void sendPluginCommandErrorNotification(Context currentPackageContext, String logTag, + CharSequence title, + String notificationTextString, + String message, boolean forceNotification, + boolean showToast, + TermuxUtils.AppInfoMode appInfoMode, + boolean addDeviceInfo, + String callingPackageName) { + // Note: Do not change currentPackageContext or termuxPackageContext passed to functions or things will break + + if (currentPackageContext == null) return; + String currentPackageName = currentPackageContext.getPackageName(); + + final Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext); + if (termuxPackageContext == null) { + Logger.logWarn(LOG_TAG, "Ignoring call to sendPluginCommandErrorNotification() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context"); + return; + } + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(termuxPackageContext); + if (preferences == null) return; + + // If user has disabled notifications for plugin commands, then just return + if (!preferences.arePluginErrorNotificationsEnabled(true) && !forceNotification) + return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + if (showToast) + Logger.showToast(currentPackageContext, notificationTextString, true); + + // Send a notification to show the error which when clicked will open the ReportActivity + // to show the details of the error + if (title == null || title.toString().isEmpty()) + title = TermuxConstants.TERMUX_APP_NAME + " Plugin Execution Command Error"; + + Logger.logDebug(logTag, "Sending \"" + title + "\" notification."); + + StringBuilder reportString = new StringBuilder(message); + + if (appInfoMode != null) + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(currentPackageContext, appInfoMode, + callingPackageName != null ? callingPackageName : currentPackageName)); + + if (addDeviceInfo) + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(currentPackageContext, true)); + + String userActionName = UserAction.PLUGIN_EXECUTION_COMMAND.getName(); + + ReportInfo reportInfo = new ReportInfo(userActionName, logTag, title.toString()); + reportInfo.setReportString(reportString.toString()); + reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(currentPackageContext)); + reportInfo.setAddReportInfoHeaderToMarkdown(true); + reportInfo.setReportSaveFileLabelAndPath(userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)); + + ReportActivity.NewInstanceResult result = ReportActivity.newInstance(termuxPackageContext, reportInfo); + if (result.contentIntent == null) return; + + // Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous + int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(termuxPackageContext); + + PendingIntent contentIntent = PendingIntent.getActivity(termuxPackageContext, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + PendingIntent deleteIntent = null; + if (result.deleteIntent != null) + deleteIntent = PendingIntent.getBroadcast(termuxPackageContext, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Setup the notification channel if not already set up + setupPluginCommandErrorsNotificationChannel(termuxPackageContext); + + // Use markdown in notification + CharSequence notificationTextCharSequence = MarkdownUtils.getSpannedMarkdownText(termuxPackageContext, notificationTextString); + //CharSequence notificationTextCharSequence = notificationTextString; + + // Build the notification + Notification.Builder builder = getPluginCommandErrorsNotificationBuilder(currentPackageContext, termuxPackageContext, + title, notificationTextCharSequence, notificationTextCharSequence, contentIntent, deleteIntent, + NotificationUtils.NOTIFICATION_MODE_VIBRATE); + if (builder == null) return; + + // Send the notification + NotificationManager notificationManager = NotificationUtils.getNotificationManager(termuxPackageContext); + if (notificationManager != null) + notificationManager.notify(nextNotificationId, builder.build()); + } + + /** + * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param currentPackageContext The {@link Context} of current package. + * @param termuxPackageContext The {@link Context} of termux package. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getPluginCommandErrorsNotificationBuilder(final Context currentPackageContext, + final Context termuxPackageContext, + final CharSequence title, + final CharSequence notificationText, + final CharSequence notificationBigText, + final PendingIntent contentIntent, + final PendingIntent deleteIntent, + final int notificationMode) { + return TermuxNotificationUtils.getTermuxOrPluginAppNotificationBuilder( + currentPackageContext, termuxPackageContext, + TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH, + title, notificationText, notificationBigText, contentIntent, deleteIntent, notificationMode); + } + + /** + * Setup the notification channel for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} and + * {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + */ + public static void setupPluginCommandErrorsNotificationChannel(final Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + } + + + + /** + * Check if {@link TermuxConstants#PROP_ALLOW_EXTERNAL_APPS} property is not set to "true". + * + * @param context The {@link Context} to get error string. + * @return Returns the {@code error} if policy is violated, otherwise {@code null}. + */ + public static String checkIfAllowExternalAppsPolicyIsViolated(final Context context, String apiName) { + String errmsg = null; + + TermuxAppSharedProperties mProperties = TermuxAppSharedProperties.getProperties(); + if (mProperties == null || !mProperties.shouldAllowExternalApps()) { + errmsg = context.getString(R.string.error_allow_external_apps_ungranted, apiName, + TermuxFileUtils.getUnExpandedTermuxPath(TermuxConstants.TERMUX_PROPERTIES_PRIMARY_FILE_PATH)); + } + + return errmsg; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAppSharedPreferences.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAppSharedPreferences.java new file mode 100644 index 000000000..cd3812fe4 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAppSharedPreferences.java @@ -0,0 +1,261 @@ +package com.termux.shared.termux.settings.preferences; + +import android.content.Context; +import android.util.TypedValue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.android.PackageUtils; +import com.termux.shared.settings.preferences.AppSharedPreferences; +import com.termux.shared.settings.preferences.SharedPreferenceUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.logger.Logger; +import com.termux.shared.data.DataUtils; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_APP; + +public class TermuxAppSharedPreferences extends AppSharedPreferences { + + private int MIN_FONTSIZE; + private int MAX_FONTSIZE; + private int DEFAULT_FONTSIZE; + + private static final String LOG_TAG = "TermuxAppSharedPreferences"; + + private TermuxAppSharedPreferences(@NonNull Context context) { + super(context, + SharedPreferenceUtils.getPrivateSharedPreferences(context, + TermuxConstants.TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), + SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, + TermuxConstants.TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); + + setFontVariables(context); + } + + /** + * Get {@link TermuxAppSharedPreferences}. + * + * @param context The {@link Context} to use to get the {@link Context} of the + * {@link TermuxConstants#TERMUX_PACKAGE_NAME}. + * @return Returns the {@link TermuxAppSharedPreferences}. This will {@code null} if an exception is raised. + */ + @Nullable + public static TermuxAppSharedPreferences build(@NonNull final Context context) { + Context termuxPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_PACKAGE_NAME); + if (termuxPackageContext == null) + return null; + else + return new TermuxAppSharedPreferences(termuxPackageContext); + } + + /** + * Get {@link TermuxAppSharedPreferences}. + * + * @param context The {@link Context} to use to get the {@link Context} of the + * {@link TermuxConstants#TERMUX_PACKAGE_NAME}. + * @param exitAppOnError If {@code true} and failed to get package context, then a dialog will + * be shown which when dismissed will exit the app. + * @return Returns the {@link TermuxAppSharedPreferences}. This will {@code null} if an exception is raised. + */ + public static TermuxAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { + Context termuxPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_PACKAGE_NAME, exitAppOnError); + if (termuxPackageContext == null) + return null; + else + return new TermuxAppSharedPreferences(termuxPackageContext); + } + + + + public boolean shouldShowTerminalToolbar() { + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_SHOW_TERMINAL_TOOLBAR, TERMUX_APP.DEFAULT_VALUE_SHOW_TERMINAL_TOOLBAR); + } + + public void setShowTerminalToolbar(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_SHOW_TERMINAL_TOOLBAR, value, false); + } + + public boolean toogleShowTerminalToolbar() { + boolean currentValue = shouldShowTerminalToolbar(); + setShowTerminalToolbar(!currentValue); + return !currentValue; + } + + + + public boolean isTerminalMarginAdjustmentEnabled() { + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_MARGIN_ADJUSTMENT, TERMUX_APP.DEFAULT_TERMINAL_MARGIN_ADJUSTMENT); + } + + public void setTerminalMarginAdjustment(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_MARGIN_ADJUSTMENT, value, false); + } + + + + public boolean isSoftKeyboardEnabled() { + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED, TERMUX_APP.DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED); + } + + public void setSoftKeyboardEnabled(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED, value, false); + } + + public boolean isSoftKeyboardEnabledOnlyIfNoHardware() { + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE, TERMUX_APP.DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE); + } + + public void setSoftKeyboardEnabledOnlyIfNoHardware(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE, value, false); + } + + + + public boolean shouldKeepScreenOn() { + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_KEEP_SCREEN_ON, TERMUX_APP.DEFAULT_VALUE_KEEP_SCREEN_ON); + } + + public void setKeepScreenOn(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_KEEP_SCREEN_ON, value, false); + } + + + + public static int[] getDefaultFontSizes(Context context) { + float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, context.getResources().getDisplayMetrics()); + + int[] sizes = new int[3]; + + // This is a bit arbitrary and sub-optimal. We want to give a sensible default for minimum font size + // to prevent invisible text due to zoom be mistake: + sizes[1] = (int) (4f * dipInPixels); // min + + // http://www.google.com/design/spec/style/typography.html#typography-line-height + int defaultFontSize = Math.round(12 * dipInPixels); + // Make it divisible by 2 since that is the minimal adjustment step: + if (defaultFontSize % 2 == 1) defaultFontSize--; + + sizes[0] = defaultFontSize; // default + + sizes[2] = 256; // max + + return sizes; + } + + public void setFontVariables(Context context) { + int[] sizes = getDefaultFontSizes(context); + + DEFAULT_FONTSIZE = sizes[0]; + MIN_FONTSIZE = sizes[1]; + MAX_FONTSIZE = sizes[2]; + } + + public int getFontSize() { + int fontSize = SharedPreferenceUtils.getIntStoredAsString(mSharedPreferences, TERMUX_APP.KEY_FONTSIZE, DEFAULT_FONTSIZE); + return DataUtils.clamp(fontSize, MIN_FONTSIZE, MAX_FONTSIZE); + } + + public void setFontSize(int value) { + SharedPreferenceUtils.setIntStoredAsString(mSharedPreferences, TERMUX_APP.KEY_FONTSIZE, value, false); + } + + public void changeFontSize(boolean increase) { + int fontSize = getFontSize(); + + fontSize += (increase ? 1 : -1) * 2; + fontSize = Math.max(MIN_FONTSIZE, Math.min(fontSize, MAX_FONTSIZE)); + + setFontSize(fontSize); + } + + + + public String getCurrentSession() { + return SharedPreferenceUtils.getString(mSharedPreferences, TERMUX_APP.KEY_CURRENT_SESSION, null, true); + } + + public void setCurrentSession(String value) { + SharedPreferenceUtils.setString(mSharedPreferences, TERMUX_APP.KEY_CURRENT_SESSION, value, false); + } + + + + public int getLogLevel() { + return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); + } + + public void setLogLevel(Context context, int logLevel) { + logLevel = Logger.setLogLevel(context, logLevel); + SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_APP.KEY_LOG_LEVEL, logLevel, false); + } + + + + public int getLastNotificationId() { + return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_APP.KEY_LAST_NOTIFICATION_ID, TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID); + } + + public void setLastNotificationId(int notificationId) { + SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_APP.KEY_LAST_NOTIFICATION_ID, notificationId, false); + } + + + public synchronized int getAndIncrementAppShellNumberSinceBoot() { + // Keep value at MAX_VALUE on integer overflow and not 0, since not first shell + return SharedPreferenceUtils.getAndIncrementInt(mSharedPreferences, TERMUX_APP.KEY_APP_SHELL_NUMBER_SINCE_BOOT, + TERMUX_APP.DEFAULT_VALUE_APP_SHELL_NUMBER_SINCE_BOOT, true, Integer.MAX_VALUE); + } + + public synchronized void resetAppShellNumberSinceBoot() { + SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_APP.KEY_APP_SHELL_NUMBER_SINCE_BOOT, + TERMUX_APP.DEFAULT_VALUE_APP_SHELL_NUMBER_SINCE_BOOT, true); + } + + public synchronized int getAndIncrementTerminalSessionNumberSinceBoot() { + // Keep value at MAX_VALUE on integer overflow and not 0, since not first shell + return SharedPreferenceUtils.getAndIncrementInt(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_SESSION_NUMBER_SINCE_BOOT, + TERMUX_APP.DEFAULT_VALUE_TERMINAL_SESSION_NUMBER_SINCE_BOOT, true, Integer.MAX_VALUE); + } + + public synchronized void resetTerminalSessionNumberSinceBoot() { + SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_SESSION_NUMBER_SINCE_BOOT, + TERMUX_APP.DEFAULT_VALUE_TERMINAL_SESSION_NUMBER_SINCE_BOOT, true); + } + + + public boolean isTerminalViewKeyLoggingEnabled() { + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, TERMUX_APP.DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED); + } + + public void setTerminalViewKeyLoggingEnabled(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, value, false); + } + + + + public boolean arePluginErrorNotificationsEnabled(boolean readFromFile) { + if (readFromFile) + return SharedPreferenceUtils.getBoolean(mMultiProcessSharedPreferences, TERMUX_APP.KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED, TERMUX_APP.DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED); + else + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED, TERMUX_APP.DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED); + } + + public void setPluginErrorNotificationsEnabled(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED, value, false); + } + + + + public boolean areCrashReportNotificationsEnabled(boolean readFromFile) { + if (readFromFile) + return SharedPreferenceUtils.getBoolean(mMultiProcessSharedPreferences, TERMUX_APP.KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED, TERMUX_APP.DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED); + else + return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED, TERMUX_APP.DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED); + } + + public void setCrashReportNotificationsEnabled(boolean value) { + SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED, value, false); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxPreferenceConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxPreferenceConstants.java new file mode 100644 index 000000000..d44a69521 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxPreferenceConstants.java @@ -0,0 +1,184 @@ +package com.termux.shared.termux.settings.preferences; + +/* + * Version: v0.16.0 + * + * Changelog + * + * - 0.1.0 (2021-03-12) + * - Initial Release. + * + * - 0.2.0 (2021-03-13) + * - Added `KEY_LOG_LEVEL` and `KEY_TERMINAL_VIEW_LOGGING_ENABLED`. + * + * - 0.3.0 (2021-03-16) + * - Changed to per app scoping of variables so that the same file can store all constants of + * Termux app and its plugins. This will allow {@link com.termux.app.TermuxSettings} to + * manage preferences of plugins as well if they don't have launcher activity themselves + * and also allow plugin apps to make changes to preferences from background. + * - Added following to `TERMUX_TASKER_APP`: + * `KEY_LOG_LEVEL`. + * + * - 0.4.0 (2021-03-13) + * - Added following to `TERMUX_APP`: + * `KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED` and `DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED`. + * + * - 0.5.0 (2021-03-24) + * - Added following to `TERMUX_APP`: + * `KEY_LAST_NOTIFICATION_ID` and `DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID`. + * + * - 0.6.0 (2021-03-24) + * - Change `DEFAULT_VALUE_KEEP_SCREEN_ON` value to `false` in `TERMUX_APP`. + * + * - 0.7.0 (2021-03-27) + * - Added following to `TERMUX_APP`: + * `KEY_SOFT_KEYBOARD_ENABLED` and `DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED`. + * + * - 0.8.0 (2021-04-06) + * - Added following to `TERMUX_APP`: + * `KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED` and `DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED`. + * + * - 0.9.0 (2021-04-07) + * - Updated javadocs. + * + * - 0.10.0 (2021-05-12) + * - Added following to `TERMUX_APP`: + * `KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE` and `DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE`. + * + * - 0.11.0 (2021-07-08) + * - Added following to `TERMUX_APP`: + * `KEY_DISABLE_TERMINAL_MARGIN_ADJUSTMENT`. + * + * - 0.12.0 (2021-08-27) + * - Added `TERMUX_API_APP.KEY_LOG_LEVEL`, `TERMUX_BOOT_APP.KEY_LOG_LEVEL`, + * `TERMUX_FLOAT_APP.KEY_LOG_LEVEL`, `TERMUX_STYLING_APP.KEY_LOG_LEVEL`, + * `TERMUX_Widget_APP.KEY_LOG_LEVEL`. + * + * - 0.13.0 (2021-09-02) + * - Added following to `TERMUX_FLOAT_APP`: + * `KEY_WINDOW_X`, `KEY_WINDOW_Y`, `KEY_WINDOW_WIDTH`, `KEY_WINDOW_HEIGHT`, `KEY_FONTSIZE`, + * `KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED`. + * + * - 0.14.0 (2021-09-04) + * - Added `TERMUX_WIDGET_APP.KEY_TOKEN`. + * + * - 0.15.0 (2021-09-05) + * - Added following to `TERMUX_TASKER_APP`: + * `KEY_LAST_PENDING_INTENT_REQUEST_CODE` and `DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE`. + * + * - 0.16.0 (2022-06-11) + * - Added following to `TERMUX_APP`: + * `KEY_APP_SHELL_NUMBER_SINCE_BOOT` and `KEY_TERMINAL_SESSION_NUMBER_SINCE_BOOT`. + */ + +import com.termux.shared.shell.command.ExecutionCommand; + +/** + * A class that defines shared constants of the SharedPreferences used by Termux app and its plugins. + * This class will be hosted by termux-shared lib and should be imported by other termux plugin + * apps as is instead of copying constants to random classes. The 3rd party apps can also import + * it for interacting with termux apps. If changes are made to this file, increment the version number + * and add an entry in the Changelog section above. + */ +public final class TermuxPreferenceConstants { + + /** + * Termux app constants. + */ + public static final class TERMUX_APP { + + /** + * Defines the key for whether terminal view margin adjustment that is done to prevent soft + * keyboard from covering bottom part of terminal view on some devices is enabled or not. + * Margin adjustment may cause screen flickering on some devices and so should be disabled. + */ + public static final String KEY_TERMINAL_MARGIN_ADJUSTMENT = "terminal_margin_adjustment"; + public static final boolean DEFAULT_TERMINAL_MARGIN_ADJUSTMENT = 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; + + + /** + * Defines the key for whether the soft keyboard will be enabled, for cases where users want + * to use a hardware keyboard instead. + */ + public static final String KEY_SOFT_KEYBOARD_ENABLED = "soft_keyboard_enabled"; + public static final boolean DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED = true; + + /** + * Defines the key for whether the soft keyboard will be enabled only if no hardware keyboard + * attached, for cases where users want to use a hardware keyboard instead. + */ + public static final String KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE = "soft_keyboard_enabled_only_if_no_hardware"; + public static final boolean DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE = false; + + + /** + * Defines the key for whether to always keep screen on. + */ + public static final String KEY_KEEP_SCREEN_ON = "screen_always_on"; + public static final boolean DEFAULT_VALUE_KEEP_SCREEN_ON = false; + + + /** + * Defines the key for font size of termux terminal view. + */ + public static final String KEY_FONTSIZE = "fontsize"; + + + /** + * Defines the key for current termux terminal session. + */ + public static final String KEY_CURRENT_SESSION = "current_session"; + + + /** + * Defines the key for current log level. + */ + public static final String KEY_LOG_LEVEL = "log_level"; + + + /** + * Defines the key for last used notification id. + */ + public static final String KEY_LAST_NOTIFICATION_ID = "last_notification_id"; + public static final int DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID = 0; + + /** + * The {@link ExecutionCommand.Runner#APP_SHELL} number after termux app process since boot. + */ + public static final String KEY_APP_SHELL_NUMBER_SINCE_BOOT = "app_shell_number_since_boot"; + public static final int DEFAULT_VALUE_APP_SHELL_NUMBER_SINCE_BOOT = 0; + + /** + * The {@link ExecutionCommand.Runner#TERMINAL_SESSION} number after termux app process since boot. + */ + public static final String KEY_TERMINAL_SESSION_NUMBER_SINCE_BOOT = "terminal_session_number_since_boot"; + public static final int DEFAULT_VALUE_TERMINAL_SESSION_NUMBER_SINCE_BOOT = 0; + + + /** + * Defines the key for whether termux terminal view key logging is enabled or not + */ + public static final String KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED = "terminal_view_key_logging_enabled"; + public static final boolean DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED = false; + + /** + * Defines the key for whether flashes and notifications for plugin errors are enabled or not. + */ + public static final String KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED = "plugin_error_notifications_enabled"; + public static final boolean DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED = true; + + /** + * Defines the key for whether notifications for crash reports are enabled or not. + */ + public static final String KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED = "crash_report_notifications_enabled"; + public static final boolean DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED = true; + + } +} \ No newline at end of file diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxAppSharedProperties.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxAppSharedProperties.java new file mode 100644 index 000000000..7faf5f8b1 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxAppSharedProperties.java @@ -0,0 +1,42 @@ +package com.termux.shared.termux.settings.properties; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.termux.TermuxConstants; + +public class TermuxAppSharedProperties extends TermuxSharedProperties { + + private static TermuxAppSharedProperties properties; + + + private TermuxAppSharedProperties(@NonNull Context context) { + super(context, TermuxConstants.TERMUX_APP_NAME, + TermuxConstants.TERMUX_PROPERTIES_FILE_PATHS_LIST, TermuxPropertyConstants.TERMUX_APP_PROPERTIES_LIST, + new TermuxSharedProperties.SharedPropertiesParserClient()); + } + + /** + * Initialize the {@link #properties} and load properties from disk. + * + * @param context The {@link Context} for operations. + * @return Returns the {@link TermuxAppSharedProperties}. + */ + public static TermuxAppSharedProperties init(@NonNull Context context) { + if (properties == null) + properties = new TermuxAppSharedProperties(context); + + return properties; + } + + /** + * Get the {@link #properties}. + * + * @return Returns the {@link TermuxAppSharedProperties}. + */ + public static TermuxAppSharedProperties getProperties() { + return properties; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java new file mode 100644 index 000000000..dd935f3ab --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java @@ -0,0 +1,481 @@ +package com.termux.shared.termux.settings.properties; + +import com.google.common.collect.ImmutableBiMap; +import com.termux.shared.termux.shell.am.TermuxAmSocketServer; +import com.termux.shared.theme.NightMode; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.settings.properties.SharedProperties; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalEmulator; +import com.termux.view.TerminalView; + +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/* + * Version: v0.18.0 + * SPDX-License-Identifier: MIT + * + * Changelog + * + * - 0.1.0 (2021-03-11) + * - Initial Release. + * + * - 0.2.0 (2021-03-11) + * - Renamed `HOME_PATH` to `TERMUX_HOME_DIR_PATH`. + * - Renamed `TERMUX_PROPERTIES_PRIMARY_PATH` to `TERMUX_PROPERTIES_PRIMARY_FILE_PATH`. + * - Renamed `TERMUX_PROPERTIES_SECONDARY_FILE_PATH` to `TERMUX_PROPERTIES_SECONDARY_FILE_PATH`. + * + * - 0.3.0 (2021-03-16) + * - Add `*TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR*`. + * + * - 0.4.0 (2021-03-16) + * - Removed `MAP_GENERIC_BOOLEAN` and `MAP_GENERIC_INVERTED_BOOLEAN`. + * + * - 0.5.0 (2021-03-25) + * - Add `KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP`. + * + * - 0.6.0 (2021-04-07) + * - Updated javadocs. + * + * - 0.7.0 (2021-05-09) + * - Add `*SOFT_KEYBOARD_TOGGLE_BEHAVIOUR*`. + * + * - 0.8.0 (2021-05-10) + * - Change the `KEY_USE_BACK_KEY_AS_ESCAPE_KEY` and `KEY_VIRTUAL_VOLUME_KEYS_DISABLED` booleans + * to `KEY_BACK_KEY_BEHAVIOUR` and `KEY_VOLUME_KEYS_BEHAVIOUR` String internal values. + * - Renamed `SOFT_KEYBOARD_TOGGLE_BEHAVIOUR` to `KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR`. + * + * - 0.9.0 (2021-05-14) + * - Add `*KEY_TERMINAL_CURSOR_BLINK_RATE*`. + * + * - 0.10.0 (2021-05-15) + * - Add `MAP_BACK_KEY_BEHAVIOUR`, `MAP_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR`, `MAP_VOLUME_KEYS_BEHAVIOUR`. + * + * - 0.11.0 (2021-06-10) + * - Add `*KEY_TERMINAL_TRANSCRIPT_ROWS*`. + * + * - 0.12.0 (2021-06-10) + * - Add `*KEY_TERMINAL_CURSOR_STYLE*`. + * + * - 0.13.0 (2021-08-25) + * - Add `*KEY_TERMINAL_MARGIN_HORIZONTAL*` and `*KEY_TERMINAL_MARGIN_VERTICAL*`. + * + * - 0.14.0 (2021-09-02) + * - Add `getTermuxFloatPropertiesFile()`. + * + * - 0.15.0 (2021-09-05) + * - Add `KEY_EXTRA_KEYS_TEXT_ALL_CAPS`. + * + * - 0.16.0 (2021-10-21) + * - Add `KEY_NIGHT_MODE`. + * + * - 0.17.0 (2022-03-17) + * - Add `KEY_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT`. + * + * - 0.18.0 (2022-06-13) + * - Add `KEY_DISABLE_FILE_SHARE_RECEIVER` and `KEY_DISABLE_FILE_VIEW_RECEIVER`. + */ + +/** + * A class that defines shared constants of the SharedProperties used by Termux app and its plugins. + * This class will be hosted by termux-shared lib and should be imported by other termux plugin + * apps as is instead of copying constants to random classes. The 3rd party apps can also import + * it for interacting with termux apps. If changes are made to this file, increment the version number + * and add an entry in the Changelog section above. + * + * The properties are loaded from the first file found at + * {@link TermuxConstants#TERMUX_PROPERTIES_PRIMARY_FILE_PATH} or + * {@link TermuxConstants#TERMUX_PROPERTIES_SECONDARY_FILE_PATH} + */ +public final class TermuxPropertyConstants { + + private static final String LOG_TAG = "TermuxPropertyConstants"; + + /* boolean */ + + /** Defines the key for whether file share receiver of the app is enabled. */ + public static final String KEY_DISABLE_FILE_SHARE_RECEIVER = "disable-file-share-receiver"; // Default: "disable-file-share-receiver" + + /** Defines the key for whether file view receiver of the app is enabled. */ + public static final String KEY_DISABLE_FILE_VIEW_RECEIVER = "disable-file-view-receiver"; // Default: "disable-file-view-receiver" + + + + /** Defines the key for whether hardware keyboard shortcuts are enabled. */ + public static final String KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS = "disable-hardware-keyboard-shortcuts"; // Default: "disable-hardware-keyboard-shortcuts" + + + + /** Defines the key for whether a toast will be shown when user changes the terminal session */ + public static final String KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST = "disable-terminal-session-change-toast"; // Default: "disable-terminal-session-change-toast" + + + + /** Defines the key for whether to enforce character based input to fix the issue where for some devices like Samsung, the letters might not appear until enter is pressed */ + public static final String KEY_ENFORCE_CHAR_BASED_INPUT = "enforce-char-based-input"; // Default: "enforce-char-based-input" + + + + /** Defines the key for whether text for the extra keys buttons should be all capitalized automatically */ + public static final String KEY_EXTRA_KEYS_TEXT_ALL_CAPS = "extra-keys-text-all-caps"; // Default: "extra-keys-text-all-caps" + + + + /** Defines the key for whether to hide soft keyboard when termux app is started */ + public static final String KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP = "hide-soft-keyboard-on-startup"; // Default: "hide-soft-keyboard-on-startup" + + + + /** Defines the key for whether the {@link TermuxAmSocketServer} should be run at app startup */ + public static final String KEY_RUN_TERMUX_AM_SOCKET_SERVER = "run-termux-am-socket-server"; // Default: "run-termux-am-socket-server" + + + + /** Defines the key for whether url links in terminal transcript will automatically open on click or on tap */ + public static final String KEY_TERMINAL_ONCLICK_URL_OPEN = "terminal-onclick-url-open"; // Default: "terminal-onclick-url-open" + + + + /** Defines the key for whether to use black UI */ + @Deprecated + public static final String KEY_USE_BLACK_UI = "use-black-ui"; // Default: "use-black-ui" + + + + /** Defines the key for whether to use ctrl space workaround to fix the issue where ctrl+space does not work on some ROMs */ + public static final String KEY_USE_CTRL_SPACE_WORKAROUND = "ctrl-space-workaround"; // Default: "ctrl-space-workaround" + + + + /** Defines the key for whether to use fullscreen */ + public static final String KEY_USE_FULLSCREEN = "fullscreen"; // Default: "fullscreen" + + + + /** Defines the key for whether to use fullscreen workaround */ + public static final String KEY_USE_FULLSCREEN_WORKAROUND = "use-fullscreen-workaround"; // Default: "use-fullscreen-workaround" + + + + + + /* int */ + + /** Defines the key for the bell behaviour */ + public static final String KEY_BELL_BEHAVIOUR = "bell-character"; // Default: "bell-character" + + public static final String VALUE_BELL_BEHAVIOUR_VIBRATE = "vibrate"; + public static final String VALUE_BELL_BEHAVIOUR_BEEP = "beep"; + public static final String VALUE_BELL_BEHAVIOUR_IGNORE = "ignore"; + public static final String DEFAULT_VALUE_BELL_BEHAVIOUR = VALUE_BELL_BEHAVIOUR_VIBRATE; + + public static final int IVALUE_BELL_BEHAVIOUR_VIBRATE = 1; + public static final int IVALUE_BELL_BEHAVIOUR_BEEP = 2; + public static final int IVALUE_BELL_BEHAVIOUR_IGNORE = 3; + public static final int DEFAULT_IVALUE_BELL_BEHAVIOUR = IVALUE_BELL_BEHAVIOUR_VIBRATE; + + /** Defines the bidirectional map for bell behaviour values and their internal values */ + public static final ImmutableBiMap MAP_BELL_BEHAVIOUR = + new ImmutableBiMap.Builder() + .put(VALUE_BELL_BEHAVIOUR_VIBRATE, IVALUE_BELL_BEHAVIOUR_VIBRATE) + .put(VALUE_BELL_BEHAVIOUR_BEEP, IVALUE_BELL_BEHAVIOUR_BEEP) + .put(VALUE_BELL_BEHAVIOUR_IGNORE, IVALUE_BELL_BEHAVIOUR_IGNORE) + .build(); + + + + /** Defines the key for the terminal cursor blink rate */ + public static final String KEY_TERMINAL_CURSOR_BLINK_RATE = "terminal-cursor-blink-rate"; // Default: "terminal-cursor-blink-rate" + public static final int IVALUE_TERMINAL_CURSOR_BLINK_RATE_MIN = TerminalView.TERMINAL_CURSOR_BLINK_RATE_MIN; + public static final int IVALUE_TERMINAL_CURSOR_BLINK_RATE_MAX = TerminalView.TERMINAL_CURSOR_BLINK_RATE_MAX; + public static final int DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE = 0; + + + + /** Defines the key for the terminal cursor style */ + public static final String KEY_TERMINAL_CURSOR_STYLE = "terminal-cursor-style"; // Default: "terminal-cursor-style" + + public static final String VALUE_TERMINAL_CURSOR_STYLE_BLOCK = "block"; + public static final String VALUE_TERMINAL_CURSOR_STYLE_UNDERLINE = "underline"; + public static final String VALUE_TERMINAL_CURSOR_STYLE_BAR = "bar"; + + public static final int IVALUE_TERMINAL_CURSOR_STYLE_BLOCK = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK; + public static final int IVALUE_TERMINAL_CURSOR_STYLE_UNDERLINE = TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE; + public static final int IVALUE_TERMINAL_CURSOR_STYLE_BAR = TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR; + public static final int DEFAULT_IVALUE_TERMINAL_CURSOR_STYLE = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE; + + /** Defines the bidirectional map for terminal cursor styles and their internal values */ + public static final ImmutableBiMap MAP_TERMINAL_CURSOR_STYLE = + new ImmutableBiMap.Builder() + .put(VALUE_TERMINAL_CURSOR_STYLE_BLOCK, IVALUE_TERMINAL_CURSOR_STYLE_BLOCK) + .put(VALUE_TERMINAL_CURSOR_STYLE_UNDERLINE, IVALUE_TERMINAL_CURSOR_STYLE_UNDERLINE) + .put(VALUE_TERMINAL_CURSOR_STYLE_BAR, IVALUE_TERMINAL_CURSOR_STYLE_BAR) + .build(); + + + + + /** + * Defines the key for how many days old the access time should be of files that should be + * deleted from $TMPDIR on termux exit. + * `-1` for none, `0` for all and `> 0` for x days. + */ + public static final String KEY_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT = "delete-tmpdir-files-older-than-x-days-on-exit"; // Default: "delete-tmpdir-files-older-than-x-days-on-exit" + public static final int IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT_MIN = -1; + public static final int IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT_MAX = 100000; + public static final int DEFAULT_IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT = 3; + + + + /** Defines the key for the terminal margin on left and right in dp units */ + public static final String KEY_TERMINAL_MARGIN_HORIZONTAL = "terminal-margin-horizontal"; // Default: "terminal-margin-horizontal" + public static final int IVALUE_TERMINAL_MARGIN_HORIZONTAL_MIN = 0; + public static final int IVALUE_TERMINAL_MARGIN_HORIZONTAL_MAX = 100; + public static final int DEFAULT_IVALUE_TERMINAL_MARGIN_HORIZONTAL = 3; + + /** Defines the key for the terminal margin on top and bottom in dp units */ + public static final String KEY_TERMINAL_MARGIN_VERTICAL = "terminal-margin-vertical"; // Default: "terminal-margin-vertical" + public static final int IVALUE_TERMINAL_MARGIN_VERTICAL_MIN = 0; + public static final int IVALUE_TERMINAL_MARGIN_VERTICAL_MAX = 100; + public static final int DEFAULT_IVALUE_TERMINAL_MARGIN_VERTICAL = 0; + + + + /** Defines the key for the terminal transcript rows */ + public static final String KEY_TERMINAL_TRANSCRIPT_ROWS = "terminal-transcript-rows"; // Default: "terminal-transcript-rows" + public static final int IVALUE_TERMINAL_TRANSCRIPT_ROWS_MIN = TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN; + public static final int IVALUE_TERMINAL_TRANSCRIPT_ROWS_MAX = TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MAX; + public static final int DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS = TerminalEmulator.DEFAULT_TERMINAL_TRANSCRIPT_ROWS; + + + + + + /* float */ + + /** Defines the key for the terminal toolbar height */ + public static final String KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR = "terminal-toolbar-height"; // Default: "terminal-toolbar-height" + public static final float IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MIN = 0.4f; + public static final float IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MAX = 3; + public static final float DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR = 1; + + + + + + /* Integer */ + + /** Defines the key for create session shortcut */ + public static final String KEY_SHORTCUT_CREATE_SESSION = "shortcut.create-session"; // Default: "shortcut.create-session" + /** Defines the key for next session shortcut */ + public static final String KEY_SHORTCUT_NEXT_SESSION = "shortcut.next-session"; // Default: "shortcut.next-session" + /** Defines the key for previous session shortcut */ + public static final String KEY_SHORTCUT_PREVIOUS_SESSION = "shortcut.previous-session"; // Default: "shortcut.previous-session" + /** Defines the key for rename session shortcut */ + public static final String KEY_SHORTCUT_RENAME_SESSION = "shortcut.rename-session"; // Default: "shortcut.rename-session" + + public static final int ACTION_SHORTCUT_CREATE_SESSION = 1; + public static final int ACTION_SHORTCUT_NEXT_SESSION = 2; + public static final int ACTION_SHORTCUT_PREVIOUS_SESSION = 3; + public static final int ACTION_SHORTCUT_RENAME_SESSION = 4; + + /** Defines the bidirectional map for session shortcut values and their internal actions */ + public static final ImmutableBiMap MAP_SESSION_SHORTCUTS = + new ImmutableBiMap.Builder() + .put(KEY_SHORTCUT_CREATE_SESSION, ACTION_SHORTCUT_CREATE_SESSION) + .put(KEY_SHORTCUT_NEXT_SESSION, ACTION_SHORTCUT_NEXT_SESSION) + .put(KEY_SHORTCUT_PREVIOUS_SESSION, ACTION_SHORTCUT_PREVIOUS_SESSION) + .put(KEY_SHORTCUT_RENAME_SESSION, ACTION_SHORTCUT_RENAME_SESSION) + .build(); + + + + + + /* String */ + + /** Defines the key for whether back key will behave as escape key or literal back key */ + public static final String KEY_BACK_KEY_BEHAVIOUR = "back-key"; // Default: "back-key" + + public static final String IVALUE_BACK_KEY_BEHAVIOUR_BACK = "back"; + public static final String IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE = "escape"; + public static final String DEFAULT_IVALUE_BACK_KEY_BEHAVIOUR = IVALUE_BACK_KEY_BEHAVIOUR_BACK; + + /** Defines the bidirectional map for back key behaviour values and their internal values */ + public static final ImmutableBiMap MAP_BACK_KEY_BEHAVIOUR = + new ImmutableBiMap.Builder() + .put(IVALUE_BACK_KEY_BEHAVIOUR_BACK, IVALUE_BACK_KEY_BEHAVIOUR_BACK) + .put(IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE, IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE) + .build(); + + + + /** Defines the key for the default working directory */ + public static final String KEY_DEFAULT_WORKING_DIRECTORY = "default-working-directory"; // Default: "default-working-directory" + /** Defines the default working directory */ + public static final String DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY = TermuxConstants.TERMUX_HOME_DIR_PATH; + + + + /** Defines the key for extra keys */ + public static final String KEY_EXTRA_KEYS = "extra-keys"; // Default: "extra-keys" + //public static final String DEFAULT_IVALUE_EXTRA_KEYS = "[[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]]"; // Single row + public static final String DEFAULT_IVALUE_EXTRA_KEYS = "[['ESC','/',{key: '-', popup: '|'},'HOME','UP','END','PGUP'], ['TAB','CTRL','ALT','LEFT','DOWN','RIGHT','PGDN']]"; // Double row + + /** Defines the key for extra keys style */ + public static final String KEY_EXTRA_KEYS_STYLE = "extra-keys-style"; // Default: "extra-keys-style" + public static final String DEFAULT_IVALUE_EXTRA_KEYS_STYLE = "default"; + + + + /** Defines the key for {@link NightMode}. */ + public static final String KEY_NIGHT_MODE = "night-mode"; // Default: "night-mode" + + public static final String IVALUE_NIGHT_MODE_TRUE = NightMode.TRUE.getName(); + public static final String IVALUE_NIGHT_MODE_FALSE = NightMode.FALSE.getName(); + public static final String IVALUE_NIGHT_MODE_SYSTEM = NightMode.SYSTEM.getName(); + public static final String DEFAULT_IVALUE_NIGHT_MODE = IVALUE_NIGHT_MODE_SYSTEM; + + /** Defines the bidirectional map for {@link NightMode} values and their internal values */ + public static final ImmutableBiMap MAP_NIGHT_MODE = + new ImmutableBiMap.Builder() + .put(IVALUE_NIGHT_MODE_TRUE, IVALUE_NIGHT_MODE_TRUE) + .put(IVALUE_NIGHT_MODE_FALSE, IVALUE_NIGHT_MODE_FALSE) + .put(IVALUE_NIGHT_MODE_SYSTEM, IVALUE_NIGHT_MODE_SYSTEM) + .build(); + + + + /** Defines the key for whether toggle soft keyboard request will show/hide or enable/disable keyboard */ + public static final String KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR = "soft-keyboard-toggle-behaviour"; // Default: "soft-keyboard-toggle-behaviour" + + public static final String IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE = "show/hide"; + public static final String IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE = "enable/disable"; + public static final String DEFAULT_IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR = IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE; + + /** Defines the bidirectional map for toggle soft keyboard behaviour values and their internal values */ + public static final ImmutableBiMap MAP_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR = + new ImmutableBiMap.Builder() + .put(IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE, IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE) + .put(IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE, IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE) + .build(); + + + + /** Defines the key for whether volume keys will behave as virtual or literal volume keys */ + public static final String KEY_VOLUME_KEYS_BEHAVIOUR = "volume-keys"; // Default: "volume-keys" + + public static final String IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL = "virtual"; + public static final String IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME = "volume"; + public static final String DEFAULT_IVALUE_VOLUME_KEYS_BEHAVIOUR = IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL; + + /** Defines the bidirectional map for volume keys behaviour values and their internal values */ + public static final ImmutableBiMap MAP_VOLUME_KEYS_BEHAVIOUR = + new ImmutableBiMap.Builder() + .put(IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL, IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL) + .put(IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME, IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME) + .build(); + + + + + + /** Defines the set for keys loaded by termux + * Setting this to {@code null} will make {@link SharedProperties} throw an exception. + * */ + public static final Set TERMUX_APP_PROPERTIES_LIST = new HashSet<>(Arrays.asList( + /* boolean */ + KEY_DISABLE_FILE_SHARE_RECEIVER, + KEY_DISABLE_FILE_VIEW_RECEIVER, + KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS, + KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST, + KEY_ENFORCE_CHAR_BASED_INPUT, + KEY_EXTRA_KEYS_TEXT_ALL_CAPS, + KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP, + KEY_RUN_TERMUX_AM_SOCKET_SERVER, + KEY_TERMINAL_ONCLICK_URL_OPEN, + KEY_USE_CTRL_SPACE_WORKAROUND, + KEY_USE_FULLSCREEN, + KEY_USE_FULLSCREEN_WORKAROUND, + TermuxConstants.PROP_ALLOW_EXTERNAL_APPS, + + /* int */ + KEY_BELL_BEHAVIOUR, + KEY_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT, + KEY_TERMINAL_CURSOR_BLINK_RATE, + KEY_TERMINAL_CURSOR_STYLE, + KEY_TERMINAL_MARGIN_HORIZONTAL, + KEY_TERMINAL_MARGIN_VERTICAL, + KEY_TERMINAL_TRANSCRIPT_ROWS, + + /* float */ + KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR, + + /* Integer */ + KEY_SHORTCUT_CREATE_SESSION, + KEY_SHORTCUT_NEXT_SESSION, + KEY_SHORTCUT_PREVIOUS_SESSION, + KEY_SHORTCUT_RENAME_SESSION, + + /* String */ + KEY_BACK_KEY_BEHAVIOUR, + KEY_DEFAULT_WORKING_DIRECTORY, + KEY_EXTRA_KEYS, + KEY_EXTRA_KEYS_STYLE, + KEY_NIGHT_MODE, + KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, + KEY_VOLUME_KEYS_BEHAVIOUR + )); + + /** Defines the set for keys loaded by termux that have default boolean behaviour with false as default. + * "true" -> true + * "false" -> false + * default: false + */ + public static final Set TERMUX_DEFAULT_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList( + KEY_DISABLE_FILE_SHARE_RECEIVER, + KEY_DISABLE_FILE_VIEW_RECEIVER, + KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS, + KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST, + KEY_ENFORCE_CHAR_BASED_INPUT, + KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP, + KEY_TERMINAL_ONCLICK_URL_OPEN, + KEY_USE_CTRL_SPACE_WORKAROUND, + KEY_USE_FULLSCREEN, + KEY_USE_FULLSCREEN_WORKAROUND, + TermuxConstants.PROP_ALLOW_EXTERNAL_APPS + )); + + /** Defines the set for keys loaded by termux that have default boolean behaviour with true as default. + * "true" -> true + * "false" -> false + * default: true + */ + public static final Set TERMUX_DEFAULT_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList( + KEY_EXTRA_KEYS_TEXT_ALL_CAPS, + KEY_RUN_TERMUX_AM_SOCKET_SERVER + )); + + /** Defines the set for keys loaded by termux that have default inverted boolean behaviour with false as default. + * "false" -> true + * "true" -> false + * default: false + */ + public static final Set TERMUX_DEFAULT_INVERETED_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList( + )); + + /** Defines the set for keys loaded by termux that have default inverted boolean behaviour with true as default. + * "false" -> true + * "true" -> false + * default: true + */ + public static final Set TERMUX_DEFAULT_INVERETED_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList( + )); + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxSharedProperties.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxSharedProperties.java new file mode 100644 index 000000000..e277f4506 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxSharedProperties.java @@ -0,0 +1,721 @@ +package com.termux.shared.termux.settings.properties; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.logger.Logger; +import com.termux.shared.data.DataUtils; +import com.termux.shared.settings.properties.SharedProperties; +import com.termux.shared.settings.properties.SharedPropertiesParser; +import com.termux.shared.termux.TermuxConstants; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +public abstract class TermuxSharedProperties { + + protected final Context mContext; + protected final String mLabel; + protected final List mPropertiesFilePaths; + protected final Set mPropertiesList; + protected final SharedPropertiesParser mSharedPropertiesParser; + protected File mPropertiesFile; + protected SharedProperties mSharedProperties; + + public static final String LOG_TAG = "TermuxSharedProperties"; + + public TermuxSharedProperties(@NonNull Context context, @NonNull String label, List propertiesFilePaths, + @NonNull Set propertiesList, @NonNull SharedPropertiesParser sharedPropertiesParser) { + mContext = context.getApplicationContext(); + mLabel = label; + mPropertiesFilePaths = propertiesFilePaths; + mPropertiesList = propertiesList; + mSharedPropertiesParser = sharedPropertiesParser; + loadTermuxPropertiesFromDisk(); + } + + /** + * Reload the termux properties from disk into an in-memory cache. + */ + public synchronized void loadTermuxPropertiesFromDisk() { + // Properties files must be searched everytime since no file may exist when constructor is + // called or a higher priority file may have been created afterward. Otherwise, if no file + // was found, then default props would keep loading, since mSharedProperties would be null. #2836 + mPropertiesFile = SharedProperties.getPropertiesFileFromList(mPropertiesFilePaths, LOG_TAG); + mSharedProperties = null; + mSharedProperties = new SharedProperties(mContext, mPropertiesFile, mPropertiesList, mSharedPropertiesParser); + + mSharedProperties.loadPropertiesFromDisk(); + dumpPropertiesToLog(); + dumpInternalPropertiesToLog(); + } + + + + + + /** + * Get the {@link Properties} from the {@link #mPropertiesFile} file. + * + * @param cached If {@code true}, then the {@link Properties} in-memory cache is returned. + * Otherwise the {@link Properties} object is read directly from the + * {@link #mPropertiesFile} file. + * @return Returns the {@link Properties} object. It will be {@code null} if an exception is + * raised while reading the file. + */ + public Properties getProperties(boolean cached) { + return mSharedProperties.getProperties(cached); + } + + /** + * Get the {@link String} value for the key passed from the {@link #mPropertiesFile} file. + * + * @param key The key to read. + * @param def The default value. + * @param cached If {@code true}, then the value is returned from the the {@link Properties} in-memory cache. + * Otherwise the {@link Properties} object is read directly from the file + * and value is returned from it against the key. + * @return Returns the {@link String} object. This will be {@code null} if key is not found. + */ + public String getPropertyValue(String key, String def, boolean cached) { + return SharedProperties.getDefaultIfNull(mSharedProperties.getProperty(key, cached), def); + } + + /** + * A function to check if the value is {@code true} for {@link Properties} key read from + * the {@link #mPropertiesFile} file. + * + * @param key The key to read. + * @param cached If {@code true}, then the value is checked from the the {@link Properties} in-memory cache. + * Otherwise the {@link Properties} object is read directly from the file + * and value is checked from it. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value + * was found in {@link Properties} but was invalid. + * @return Returns the {@code true} if the {@link Properties} key {@link String} value equals "true", + * regardless of case. If the key does not exist in the file or does not equal "true", then + * {@code false} will be returned. + */ + public boolean isPropertyValueTrue(String key, boolean cached, boolean logErrorOnInvalidValue) { + return (boolean) SharedProperties.getBooleanValueForStringValue(key, (String) getPropertyValue(key, null, cached), false, logErrorOnInvalidValue, LOG_TAG); + } + + /** + * A function to check if the value is {@code false} for {@link Properties} key read from + * the {@link #mPropertiesFile} file. + * + * @param key The key to read. + * @param cached If {@code true}, then the value is checked from the the {@link Properties} in-memory cache. + * Otherwise the {@link Properties} object is read directly from the file + * and value is checked from it. + * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value + * was found in {@link Properties} but was invalid. + * @return Returns {@code true} if the {@link Properties} key {@link String} value equals "false", + * regardless of case. If the key does not exist in the file or does not equal "false", then + * {@code true} will be returned. + */ + public boolean isPropertyValueFalse(String key, boolean cached, boolean logErrorOnInvalidValue) { + return (boolean) SharedProperties.getInvertedBooleanValueForStringValue(key, (String) getPropertyValue(key, null, cached), true, logErrorOnInvalidValue, LOG_TAG); + } + + + + + + /** + * Get the internal value {@link Object} {@link HashMap <>} in-memory cache for the + * {@link #mPropertiesFile} file. A call to {@link #loadTermuxPropertiesFromDisk()} must be made + * before this. + * + * @return Returns a copy of {@link Map} object. + */ + public Map getInternalProperties() { + return mSharedProperties.getInternalProperties(); + } + + /** + * Get the internal {@link Object} value for the key passed from the {@link #mPropertiesFile} file. + * If cache is {@code true}, then value is returned from the {@link HashMap <>} in-memory cache, + * so a call to {@link #loadTermuxPropertiesFromDisk()} must be made before this. + * + * @param key The key to read from the {@link HashMap<>} in-memory cache. + * @param cached If {@code true}, then the value is returned from the the {@link HashMap <>} in-memory cache, + * but if the value is null, then an attempt is made to return the default value. + * If {@code false}, then the {@link Properties} object is read directly from the file + * and internal value is returned for the property value against the key. + * @return Returns the {@link Object} object. This will be {@code null} if key is not found or + * the object stored against the key is {@code null}. + */ + public Object getInternalPropertyValue(String key, boolean cached) { + Object value; + if (cached) { + value = mSharedProperties.getInternalProperty(key); + // If the value is not null since key was found or if the value was null since the + // object stored for the key was itself null, we detect the later by checking if the key + // exists in the map. + if (value != null || mSharedProperties.getInternalProperties().containsKey(key)) { + return value; + } else { + // This should not happen normally unless mMap was modified after the + // {@link #loadTermuxPropertiesFromDisk()} call + // A null value can still be returned by + // {@link #getInternalPropertyValueFromValue(Context,String,String)} for some keys + value = getInternalTermuxPropertyValueFromValue(mContext, key, null); + Logger.logWarn(LOG_TAG, "The value for \"" + key + "\" not found in SharedProperties cache, force returning default value: `" + value + "`"); + return value; + } + } else { + // We get the property value directly from file and return its internal value + return getInternalTermuxPropertyValueFromValue(mContext, key, mSharedProperties.getProperty(key, false)); + } + } + + + + + + /** + * Get the internal {@link Object} value for the key passed from the first file found in + * {@link TermuxConstants#TERMUX_PROPERTIES_FILE_PATHS_LIST}. The {@link Properties} object is + * read directly from the file and internal value is returned for the property value against the key. + * + * @param context The context for operations. + * @param key The key for which the internal object is required. + * @return Returns the {@link Object} object. This will be {@code null} if key is not found or + * the object stored against the key is {@code null}. + */ + public static Object getTermuxInternalPropertyValue(Context context, String key) { + return SharedProperties.getInternalProperty(context, + SharedProperties.getPropertiesFileFromList(TermuxConstants.TERMUX_PROPERTIES_FILE_PATHS_LIST, LOG_TAG), + key, new SharedPropertiesParserClient()); + } + + /** + * The class that implements the {@link SharedPropertiesParser} interface. + */ + public static class SharedPropertiesParserClient implements SharedPropertiesParser { + @NonNull + @Override + public Properties preProcessPropertiesOnReadFromDisk(@NonNull Context context, @NonNull Properties properties) { + return replaceUseBlackUIProperty(properties); + } + + /** + * Override the + * {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)} + * interface function. + */ + @Override + public Object getInternalPropertyValueFromValue(@NonNull Context context, String key, String value) { + return getInternalTermuxPropertyValueFromValue(context, key, value); + } + } + + @NonNull + public static Properties replaceUseBlackUIProperty(@NonNull Properties properties) { + String useBlackUIStringValue = properties.getProperty(TermuxPropertyConstants.KEY_USE_BLACK_UI); + if (useBlackUIStringValue == null) return properties; + + Logger.logWarn(LOG_TAG, "Removing deprecated property " + TermuxPropertyConstants.KEY_USE_BLACK_UI + "=" + useBlackUIStringValue); + properties.remove(TermuxPropertyConstants.KEY_USE_BLACK_UI); + + // If KEY_NIGHT_MODE is not set + if (properties.getProperty(TermuxPropertyConstants.KEY_NIGHT_MODE) == null) { + Boolean useBlackUI = SharedProperties.getBooleanValueForStringValue(useBlackUIStringValue); + if (useBlackUI != null) { + String termuxAppTheme = useBlackUI ? TermuxPropertyConstants.IVALUE_NIGHT_MODE_TRUE : + TermuxPropertyConstants.IVALUE_NIGHT_MODE_FALSE; + Logger.logWarn(LOG_TAG, "Replacing deprecated property " + TermuxPropertyConstants.KEY_USE_BLACK_UI + "=" + useBlackUI + " with " + TermuxPropertyConstants.KEY_NIGHT_MODE + "=" + termuxAppTheme); + properties.put(TermuxPropertyConstants.KEY_NIGHT_MODE, termuxAppTheme); + } + } + + return properties; + } + + + + /** + * A static function that should return the internal termux {@link Object} for a key/value pair + * read from properties file. + * + * @param context The context for operations. + * @param key The key for which the internal object is required. + * @param value The literal value for the property found is the properties file. + * @return Returns the internal termux {@link Object} object. + */ + public static Object getInternalTermuxPropertyValueFromValue(Context context, String key, String value) { + if (key == null) return null; + /* + For keys where a MAP_* is checked by respective functions. Note that value to this function + would actually be the key for the MAP_*: + - If the value is currently null, then searching MAP_* should also return null and internal default value will be used. + - If the value is not null and does not exist in MAP_*, then internal default value will be used. + - If the value is not null and does exist in MAP_*, then internal value returned by map will be used. + */ + switch (key) { + /* int */ + case TermuxPropertyConstants.KEY_BELL_BEHAVIOUR: + return (int) getBellBehaviourInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT: + return (int) getDeleteTMPDIRFilesOlderThanXDaysOnExitInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_TERMINAL_CURSOR_BLINK_RATE: + return (int) getTerminalCursorBlinkRateInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_TERMINAL_CURSOR_STYLE: + return (int) getTerminalCursorStyleInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_TERMINAL_MARGIN_HORIZONTAL: + return (int) getTerminalMarginHorizontalInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_TERMINAL_MARGIN_VERTICAL: + return (int) getTerminalMarginVerticalInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS: + return (int) getTerminalTranscriptRowsInternalPropertyValueFromValue(value); + + /* float */ + case TermuxPropertyConstants.KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR: + return (float) getTerminalToolbarHeightScaleFactorInternalPropertyValueFromValue(value); + + /* Integer (may be null) */ + case TermuxPropertyConstants.KEY_SHORTCUT_CREATE_SESSION: + case TermuxPropertyConstants.KEY_SHORTCUT_NEXT_SESSION: + case TermuxPropertyConstants.KEY_SHORTCUT_PREVIOUS_SESSION: + case TermuxPropertyConstants.KEY_SHORTCUT_RENAME_SESSION: + return (Integer) getCodePointForSessionShortcuts(key, value); + + /* String (may be null) */ + case TermuxPropertyConstants.KEY_BACK_KEY_BEHAVIOUR: + return (String) getBackKeyBehaviourInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_DEFAULT_WORKING_DIRECTORY: + return (String) getDefaultWorkingDirectoryInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_EXTRA_KEYS: + return (String) getExtraKeysInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE: + return (String) getExtraKeysStyleInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_NIGHT_MODE: + return (String) getNightModeInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR: + return (String) getSoftKeyboardToggleBehaviourInternalPropertyValueFromValue(value); + case TermuxPropertyConstants.KEY_VOLUME_KEYS_BEHAVIOUR: + return (String) getVolumeKeysBehaviourInternalPropertyValueFromValue(value); + + default: + // default false boolean behaviour + if (TermuxPropertyConstants.TERMUX_DEFAULT_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key)) + return (boolean) SharedProperties.getBooleanValueForStringValue(key, value, false, true, LOG_TAG); + // default true boolean behaviour + if (TermuxPropertyConstants.TERMUX_DEFAULT_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key)) + return (boolean) SharedProperties.getBooleanValueForStringValue(key, value, true, true, LOG_TAG); + // default inverted false boolean behaviour + //else if (TermuxPropertyConstants.TERMUX_DEFAULT_INVERETED_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key)) + // return (boolean) SharedProperties.getInvertedBooleanValueForStringValue(key, value, false, true, LOG_TAG); + // default inverted true boolean behaviour + // else if (TermuxPropertyConstants.TERMUX_DEFAULT_INVERETED_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key)) + // return (boolean) SharedProperties.getInvertedBooleanValueForStringValue(key, value, true, true, LOG_TAG); + // just use String object as is (may be null) + else + return value; + } + } + + + + + + /** + * Returns the internal value after mapping it based on + * {@code TermuxPropertyConstants#MAP_BELL_BEHAVIOUR} if the value is not {@code null} + * and is valid, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_BELL_BEHAVIOUR}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getBellBehaviourInternalPropertyValueFromValue(String value) { + return (int) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_BELL_BEHAVIOUR, TermuxPropertyConstants.MAP_BELL_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_BELL_BEHAVIOUR, true, LOG_TAG); + } + + /** + * Returns the int for the value if its not null and is between + * {@link TermuxPropertyConstants#IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT_MIN} and + * {@link TermuxPropertyConstants#IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT_MAX}, + * otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getDeleteTMPDIRFilesOlderThanXDaysOnExitInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT, + DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT), + TermuxPropertyConstants.DEFAULT_IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT, + TermuxPropertyConstants.IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT_MIN, + TermuxPropertyConstants.IVALUE_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT_MAX, + true, true, LOG_TAG); + } + + /** + * Returns the int for the value if its not null and is between + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_CURSOR_BLINK_RATE_MIN} and + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_CURSOR_BLINK_RATE_MAX}, + * otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getTerminalCursorBlinkRateInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_BLINK_RATE, + DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE), + TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE, + TermuxPropertyConstants.IVALUE_TERMINAL_CURSOR_BLINK_RATE_MIN, + TermuxPropertyConstants.IVALUE_TERMINAL_CURSOR_BLINK_RATE_MAX, + true, true, LOG_TAG); + } + + /** + * Returns the internal value after mapping it based on + * {@link TermuxPropertyConstants#MAP_TERMINAL_CURSOR_STYLE} if the value is not {@code null} + * and is valid, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_CURSOR_STYLE}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getTerminalCursorStyleInternalPropertyValueFromValue(String value) { + return (int) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_STYLE, TermuxPropertyConstants.MAP_TERMINAL_CURSOR_STYLE, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_CURSOR_STYLE, true, LOG_TAG); + } + + /** + * Returns the int for the value if its not null and is between + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_HORIZONTAL_MIN} and + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_HORIZONTAL_MAX}, + * otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_MARGIN_HORIZONTAL}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getTerminalMarginHorizontalInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_HORIZONTAL, + DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_MARGIN_HORIZONTAL), + TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_MARGIN_HORIZONTAL, + TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_HORIZONTAL_MIN, + TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_HORIZONTAL_MAX, + true, true, LOG_TAG); + } + + /** + * Returns the int for the value if its not null and is between + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_VERTICAL_MIN} and + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_VERTICAL_MAX}, + * otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_MARGIN_VERTICAL}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getTerminalMarginVerticalInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_VERTICAL, + DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_MARGIN_VERTICAL), + TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_MARGIN_VERTICAL, + TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_VERTICAL_MIN, + TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_VERTICAL_MAX, + true, true, LOG_TAG); + } + + /** + * Returns the int for the value if its not null and is between + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_TRANSCRIPT_ROWS_MIN} and + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_TRANSCRIPT_ROWS_MAX}, + * otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static int getTerminalTranscriptRowsInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS, + DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS), + TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS, + TermuxPropertyConstants.IVALUE_TERMINAL_TRANSCRIPT_ROWS_MIN, + TermuxPropertyConstants.IVALUE_TERMINAL_TRANSCRIPT_ROWS_MAX, + true, true, LOG_TAG); + } + + /** + * Returns the int for the value if its not null and is between + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MIN} and + * {@link TermuxPropertyConstants#IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MAX}, + * otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static float getTerminalToolbarHeightScaleFactorInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR, + DataUtils.getFloatFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR), + TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR, + TermuxPropertyConstants.IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MIN, + TermuxPropertyConstants.IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MAX, + true, true, LOG_TAG); + } + + /** + * Returns the code point for the value if key is not {@code null} and value is not {@code null} and is valid, + * otherwise returns {@code null}. + * + * @param key The key for session shortcut. + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static Integer getCodePointForSessionShortcuts(String key, String value) { + if (key == null) return null; + if (value == null) return null; + String[] parts = value.toLowerCase().trim().split("\\+"); + String input = parts.length == 2 ? parts[1].trim() : null; + if (!(parts.length == 2 && parts[0].trim().equals("ctrl")) || input.isEmpty() || input.length() > 2) { + Logger.logError(LOG_TAG, "Keyboard shortcut '" + key + "' is not Ctrl+"); + return null; + } + + char c = input.charAt(0); + int codePoint = c; + if (Character.isLowSurrogate(c)) { + if (input.length() != 2 || Character.isHighSurrogate(input.charAt(1))) { + Logger.logError(LOG_TAG, "Keyboard shortcut '" + key + "' is not Ctrl+"); + return null; + } else { + codePoint = Character.toCodePoint(input.charAt(1), c); + } + } + + return codePoint; + } + + /** + * Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_BACK_KEY_BEHAVIOUR}. + * + * @param value {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static String getBackKeyBehaviourInternalPropertyValueFromValue(String value) { + return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_BACK_KEY_BEHAVIOUR, TermuxPropertyConstants.MAP_BACK_KEY_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_BACK_KEY_BEHAVIOUR, true, LOG_TAG); + } + + /** + * Returns the path itself if a directory exists at it and is readable, otherwise returns + * {@link TermuxPropertyConstants#DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY}. + * + * @param path The {@link String} path to check. + * @return Returns the internal value for value. + */ + public static String getDefaultWorkingDirectoryInternalPropertyValueFromValue(String path) { + if (path == null || path.isEmpty()) return TermuxPropertyConstants.DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY; + File workDir = new File(path); + if (!workDir.exists() || !workDir.isDirectory() || !workDir.canRead()) { + // Fallback to default directory if user configured working directory does not exist, + // is not a directory or is not readable. + Logger.logError(LOG_TAG, "The path \"" + path + "\" for the key \"" + TermuxPropertyConstants.KEY_DEFAULT_WORKING_DIRECTORY + "\" does not exist, is not a directory or is not readable. Using default value \"" + TermuxPropertyConstants.DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY + "\" instead."); + return TermuxPropertyConstants.DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY; + } else { + return path; + } + } + + /** + * Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_EXTRA_KEYS}. + * + * @param value The {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static String getExtraKeysInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNullOrEmpty(value, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS); + } + + /** + * Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_EXTRA_KEYS_STYLE}. + * + * @param value {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static String getExtraKeysStyleInternalPropertyValueFromValue(String value) { + return SharedProperties.getDefaultIfNullOrEmpty(value, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE); + } + + /** + * Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_NIGHT_MODE}. + * + * @param value {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static String getNightModeInternalPropertyValueFromValue(String value) { + return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_NIGHT_MODE, + TermuxPropertyConstants.MAP_NIGHT_MODE, SharedProperties.toLowerCase(value), + TermuxPropertyConstants.DEFAULT_IVALUE_NIGHT_MODE, true, LOG_TAG); + } + + /** + * Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR}. + * + * @param value {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static String getSoftKeyboardToggleBehaviourInternalPropertyValueFromValue(String value) { + return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, TermuxPropertyConstants.MAP_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, true, LOG_TAG); + } + + /** + * Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_VOLUME_KEYS_BEHAVIOUR}. + * + * @param value {@link String} value to convert. + * @return Returns the internal value for value. + */ + public static String getVolumeKeysBehaviourInternalPropertyValueFromValue(String value) { + return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_VOLUME_KEYS_BEHAVIOUR, TermuxPropertyConstants.MAP_VOLUME_KEYS_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_VOLUME_KEYS_BEHAVIOUR, true, LOG_TAG); + } + + + + + + public boolean shouldAllowExternalApps() { + return (boolean) getInternalPropertyValue(TermuxConstants.PROP_ALLOW_EXTERNAL_APPS, true); + } + + public boolean isFileShareReceiverDisabled() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_DISABLE_FILE_SHARE_RECEIVER, true); + } + + public boolean isFileViewReceiverDisabled() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_DISABLE_FILE_VIEW_RECEIVER, true); + } + + public boolean areHardwareKeyboardShortcutsDisabled() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS, true); + } + + public boolean areTerminalSessionChangeToastsDisabled() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST, true); + } + + public boolean isEnforcingCharBasedInput() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_ENFORCE_CHAR_BASED_INPUT, true); + } + + public boolean shouldExtraKeysTextBeAllCaps() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_TEXT_ALL_CAPS, true); + } + + public boolean shouldSoftKeyboardBeHiddenOnStartup() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP, true); + } + + public boolean shouldRunTermuxAmSocketServer() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_RUN_TERMUX_AM_SOCKET_SERVER, true); + } + + public boolean shouldOpenTerminalTranscriptURLOnClick() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_ONCLICK_URL_OPEN, true); + } + + public boolean isUsingCtrlSpaceWorkaround() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_CTRL_SPACE_WORKAROUND, true); + } + + public boolean isUsingFullScreen() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_FULLSCREEN, true); + } + + public boolean isUsingFullScreenWorkAround() { + return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_FULLSCREEN_WORKAROUND, true); + } + + public int getBellBehaviour() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_BELL_BEHAVIOUR, true); + } + + public int getDeleteTMPDIRFilesOlderThanXDaysOnExit() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_DELETE_TMPDIR_FILES_OLDER_THAN_X_DAYS_ON_EXIT, true); + } + + public int getTerminalCursorBlinkRate() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_BLINK_RATE, true); + } + + public int getTerminalCursorStyle() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_STYLE, true); + } + + public int getTerminalMarginHorizontal() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_HORIZONTAL, true); + } + + public int getTerminalMarginVertical() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_VERTICAL, true); + } + + public int getTerminalTranscriptRows() { + return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS, true); + } + + public float getTerminalToolbarHeightScaleFactor() { + return (float) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR, true); + } + + public boolean isBackKeyTheEscapeKey() { + return (boolean) TermuxPropertyConstants.IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE.equals(getInternalPropertyValue(TermuxPropertyConstants.KEY_BACK_KEY_BEHAVIOUR, true)); + } + + public String getDefaultWorkingDirectory() { + return (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_DEFAULT_WORKING_DIRECTORY, true); + } + + public String getNightMode() { + return (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_NIGHT_MODE, true); + } + + /** Get the {@link TermuxPropertyConstants#KEY_NIGHT_MODE} value from the properties file on disk. */ + public static String getNightMode(Context context) { + return (String) TermuxSharedProperties.getTermuxInternalPropertyValue(context, + TermuxPropertyConstants.KEY_NIGHT_MODE); + } + + public boolean shouldEnableDisableSoftKeyboardOnToggle() { + return (boolean) TermuxPropertyConstants.IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE.equals(getInternalPropertyValue(TermuxPropertyConstants.KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, true)); + } + + public boolean areVirtualVolumeKeysDisabled() { + return (boolean) TermuxPropertyConstants.IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME.equals(getInternalPropertyValue(TermuxPropertyConstants.KEY_VOLUME_KEYS_BEHAVIOUR, true)); + } + + + + + + public void dumpPropertiesToLog() { + Properties properties = getProperties(true); + StringBuilder propertiesDump = new StringBuilder(); + + propertiesDump.append(mLabel).append(" Termux Properties:"); + if (properties != null) { + for (String key : properties.stringPropertyNames()) { + propertiesDump.append("\n").append(key).append(": `").append(properties.get(key)).append("`"); + } + } else { + propertiesDump.append(" null"); + } + + Logger.logVerbose(LOG_TAG, propertiesDump.toString()); + } + + public void dumpInternalPropertiesToLog() { + HashMap internalProperties = (HashMap) getInternalProperties(); + StringBuilder internalPropertiesDump = new StringBuilder(); + + internalPropertiesDump.append(mLabel).append(" Internal Properties:"); + if (internalProperties != null) { + for (String key : internalProperties.keySet()) { + internalPropertiesDump.append("\n").append(key).append(": `").append(internalProperties.get(key)).append("`"); + } + } + + Logger.logVerbose(LOG_TAG, internalPropertiesDump.toString()); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellManager.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellManager.java new file mode 100644 index 000000000..d3988ddfd --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellManager.java @@ -0,0 +1,118 @@ +package com.termux.shared.termux.shell; + +import android.content.Context; +import android.content.Intent; +import android.widget.ArrayAdapter; + +import androidx.annotation.NonNull; + +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.runner.app.AppShell; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; + +import java.util.ArrayList; +import java.util.List; + +public class TermuxShellManager { + + private static TermuxShellManager shellManager; + + private static int SHELL_ID = 0; + + protected final Context mContext; + + /** + * The foreground TermuxSessions which this service manages. + * Note that this list is observed by an activity, like TermuxActivity.mTermuxSessionListViewController, + * so any changes must be made on the UI thread and followed by a call to + * {@link ArrayAdapter#notifyDataSetChanged()}. + */ + public final List mTermuxSessions = new ArrayList<>(); + + /** + * The background TermuxTasks which this service manages. + */ + public final List mTermuxTasks = new ArrayList<>(); + + /** + * The {@link ExecutionCommand.Runner#APP_SHELL} number after app process was started/restarted. + */ + public static int APP_SHELL_NUMBER_SINCE_APP_START; + + /** + * The {@link ExecutionCommand.Runner#TERMINAL_SESSION} number after app process was started/restarted. + */ + public static int TERMINAL_SESSION_NUMBER_SINCE_APP_START; + + + + public TermuxShellManager(@NonNull Context context) { + mContext = context.getApplicationContext(); + } + + /** + * Initialize the {@link #shellManager}. + * + * @param context The {@link Context} for operations. + * @return Returns the {@link TermuxShellManager}. + */ + public static TermuxShellManager init(@NonNull Context context) { + if (shellManager == null) + shellManager = new TermuxShellManager(context); + + return shellManager; + } + + /** + * Get the {@link #shellManager}. + * + * @return Returns the {@link TermuxShellManager}. + */ + public static TermuxShellManager getShellManager() { + return shellManager; + } + + + public synchronized static void onActionBootCompleted(@NonNull Context context, @NonNull Intent intent) { + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; + + // Ensure any shells started after boot have valid ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_BOOT and + // ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_BOOT exported + preferences.resetAppShellNumberSinceBoot(); + preferences.resetTerminalSessionNumberSinceBoot(); + } + + public static void onAppExit(@NonNull Context context) { + // Ensure any shells started after boot have valid ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_APP_START and + // ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_APP_START exported + APP_SHELL_NUMBER_SINCE_APP_START = 0; + TERMINAL_SESSION_NUMBER_SINCE_APP_START = 0; + } + + public static synchronized int getNextShellId() { + return SHELL_ID++; + } + + public static synchronized int getAndIncrementAppShellNumberSinceAppStart() { + // Keep value at MAX_VALUE on integer overflow and not 0, since not first shell + int curValue = APP_SHELL_NUMBER_SINCE_APP_START; + if (curValue < 0) curValue = Integer.MAX_VALUE; + + APP_SHELL_NUMBER_SINCE_APP_START = curValue + 1; + if (APP_SHELL_NUMBER_SINCE_APP_START < 0) APP_SHELL_NUMBER_SINCE_APP_START = Integer.MAX_VALUE; + return curValue; + } + + public static synchronized int getAndIncrementTerminalSessionNumberSinceAppStart() { + // Keep value at MAX_VALUE on integer overflow and not 0, since not first shell + int curValue = TERMINAL_SESSION_NUMBER_SINCE_APP_START; + if (curValue < 0) curValue = Integer.MAX_VALUE; + + TERMINAL_SESSION_NUMBER_SINCE_APP_START = curValue + 1; + if (TERMINAL_SESSION_NUMBER_SINCE_APP_START < 0) TERMINAL_SESSION_NUMBER_SINCE_APP_START = Integer.MAX_VALUE; + return curValue; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellUtils.java new file mode 100644 index 000000000..de56a29e3 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellUtils.java @@ -0,0 +1,122 @@ +package com.termux.shared.termux.shell; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.errors.Error; +import com.termux.shared.file.filesystem.FileTypes; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; + +import org.apache.commons.io.filefilter.TrueFileFilter; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class TermuxShellUtils { + + private static final String LOG_TAG = "TermuxShellUtils"; + + /** + * Setup shell command arguments for the execute. The file interpreter may be prefixed to + * command arguments if needed. + */ + @NonNull + public static String[] setupShellCommandArguments(@NonNull String executable, @Nullable String[] arguments) { + // The file to execute may either be: + // - An elf file, in which we execute it directly. + // - A script file without shebang, which we execute with our standard shell $PREFIX/bin/sh instead of the + // system /system/bin/sh. The system shell may vary and may not work at all due to LD_LIBRARY_PATH. + // - A file with shebang, which we try to handle with e.g. /bin/foo -> $PREFIX/bin/foo. + String interpreter = null; + try { + File file = new File(executable); + try (FileInputStream in = new FileInputStream(file)) { + byte[] buffer = new byte[256]; + int bytesRead = in.read(buffer); + if (bytesRead > 4) { + if (buffer[0] == 0x7F && buffer[1] == 'E' && buffer[2] == 'L' && buffer[3] == 'F') { + // Elf file, do nothing. + } else if (buffer[0] == '#' && buffer[1] == '!') { + // Try to parse shebang. + StringBuilder builder = new StringBuilder(); + for (int i = 2; i < bytesRead; i++) { + char c = (char) buffer[i]; + if (c == ' ' || c == '\n') { + if (builder.length() == 0) { + // Skip whitespace after shebang. + } else { + // End of shebang. + String shebangExecutable = builder.toString(); + if (shebangExecutable.startsWith("/usr") || shebangExecutable.startsWith("/bin")) { + String[] parts = shebangExecutable.split("/"); + String binary = parts[parts.length - 1]; + interpreter = TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/" + binary; + } + break; + } + } else { + builder.append(c); + } + } + } else { + // No shebang and no ELF, use standard shell. + interpreter = TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/sh"; + } + } + } + } catch (IOException e) { + // Ignore. + } + + List result = new ArrayList<>(); + if (interpreter != null) result.add(interpreter); + result.add(executable); + if (arguments != null) Collections.addAll(result, arguments); + return result.toArray(new String[0]); + } + + /** Clear files under {@link TermuxConstants#TERMUX_TMP_PREFIX_DIR_PATH}. */ + public static void clearTermuxTMPDIR(boolean onlyIfExists) { + // Existence check before clearing may be required since clearDirectory() will automatically + // re-create empty directory if doesn't exist, which should not be done for things like + // termux-reset (d6eb5e35). Moreover, TMPDIR must be a directory and not a symlink, this can + // also allow users who don't want TMPDIR to be cleared automatically on termux exit, since + // it may remove files still being used by background processes (#1159). + if(onlyIfExists && !FileUtils.directoryFileExists(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, false)) + return; + + Error error; + + TermuxAppSharedProperties properties = TermuxAppSharedProperties.getProperties(); + int days = properties.getDeleteTMPDIRFilesOlderThanXDaysOnExit(); + + // Disable currently until FileUtils.deleteFilesOlderThanXDays() is fixed. + if (days > 0) + days = 0; + + if (days < 0) { + Logger.logInfo(LOG_TAG, "Not clearing termux $TMPDIR"); + } else if (days == 0) { + error = FileUtils.clearDirectory("$TMPDIR", + FileUtils.getCanonicalPath(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, null)); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, "Failed to clear termux $TMPDIR\n" + error); + } + } else { + error = FileUtils.deleteFilesOlderThanXDays("$TMPDIR", + FileUtils.getCanonicalPath(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, null), + TrueFileFilter.INSTANCE, days, true, FileTypes.FILE_TYPE_ANY_FLAGS); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, "Failed to delete files from termux $TMPDIR older than " + days + " days\n" + error); + } + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/am/TermuxAmSocketServer.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/am/TermuxAmSocketServer.java new file mode 100644 index 000000000..0d68ae5c6 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/am/TermuxAmSocketServer.java @@ -0,0 +1,232 @@ +package com.termux.shared.termux.shell.am; + +import android.content.Context; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.errors.Error; +import com.termux.shared.logger.Logger; +import com.termux.shared.net.socket.local.LocalClientSocket; +import com.termux.shared.net.socket.local.LocalServerSocket; +import com.termux.shared.net.socket.local.LocalSocketManager; +import com.termux.shared.net.socket.local.LocalSocketManagerClientBase; +import com.termux.shared.net.socket.local.LocalSocketRunConfig; +import com.termux.shared.shell.am.AmSocketServerRunConfig; +import com.termux.shared.shell.am.AmSocketServer; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.crash.TermuxCrashUtils; +import com.termux.shared.termux.plugins.TermuxPluginUtils; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; +import com.termux.shared.termux.shell.command.environment.TermuxAppShellEnvironment; + +/** + * A wrapper for {@link AmSocketServer} for termux-app usage. + * + * The static {@link #termuxAmSocketServer} variable stores the {@link LocalSocketManager} for the + * {@link AmSocketServer}. + * + * The {@link TermuxAmSocketServerClient} extends the {@link AmSocketServer.AmSocketServerClient} + * class to also show plugin error notifications for errors and disallowed client connections in + * addition to logging the messages to logcat, which are only logged by {@link LocalSocketManagerClientBase} + * if log level is debug or higher for privacy issues. + * + * It uses a filesystem socket server with the socket file at + * {@link TermuxConstants.TERMUX_APP#TERMUX_AM_SOCKET_FILE_PATH}. It would normally only allow + * processes belonging to the termux user and root user to connect to it. If commands are sent by the + * root user, then the am commands executed will be run as the termux user and its permissions, + * capabilities and selinux context instead of root. + * + * The `$PREFIX/bin/termux-am` client connects to the server via `$PREFIX/bin/termux-am-socket` to + * run the am commands. It provides similar functionality to "$PREFIX/bin/am" + * (and "/system/bin/am"), but should be faster since it does not require starting a dalvik vm for + * every command as done by "am" via termux/TermuxAm. + * + * The server is started by termux-app Application class but is not started if + * {@link TermuxPropertyConstants#KEY_RUN_TERMUX_AM_SOCKET_SERVER} is `false` which can be done by + * adding the prop with value "false" to the "~/.termux/termux.properties" file. Changes + * require termux-app to be force stopped and restarted. + * + * The current state of the server can be checked with the + * {@link TermuxAppShellEnvironment#ENV_TERMUX_APP__AM_SOCKET_SERVER_ENABLED} env variable, which is exported + * for all shell sessions and tasks. + * + * https://github.com/termux/termux-am-socket + * https://github.com/termux/TermuxAm + */ +public class TermuxAmSocketServer { + + public static final String LOG_TAG = "TermuxAmSocketServer"; + + public static final String TITLE = "TermuxAm"; + + /** The static instance for the {@link TermuxAmSocketServer} {@link LocalSocketManager}. */ + private static LocalSocketManager termuxAmSocketServer; + + /** Whether {@link TermuxAmSocketServer} is enabled and running or not. */ + @Keep + protected static Boolean TERMUX_APP_AM_SOCKET_SERVER_ENABLED; + + /** + * Setup the {@link AmSocketServer} {@link LocalServerSocket} and start listening for + * new {@link LocalClientSocket} if enabled. + * + * @param context The {@link Context} for {@link LocalSocketManager}. + */ + public static void setupTermuxAmSocketServer(@NonNull Context context) { + // Start termux-am-socket server if enabled by user + boolean enabled = false; + if (TermuxAppSharedProperties.getProperties().shouldRunTermuxAmSocketServer()) { + Logger.logDebug(LOG_TAG, "Starting " + TITLE + " socket server since its enabled"); + start(context); + if (termuxAmSocketServer != null && termuxAmSocketServer.isRunning()) { + enabled = true; + Logger.logDebug(LOG_TAG, TITLE + " socket server successfully started"); + } + } else { + Logger.logDebug(LOG_TAG, "Not starting " + TITLE + " socket server since its not enabled"); + } + + // Once termux-app has started, the server state must not be changed since the variable is + // exported in shell sessions and tasks and if state is changed, then env of older shells will + // retain invalid value. User should force stop the app to update state after changing prop. + TERMUX_APP_AM_SOCKET_SERVER_ENABLED = enabled; + TermuxAppShellEnvironment.updateTermuxAppAMSocketServerEnabled(context); + } + + /** + * Create the {@link AmSocketServer} {@link LocalServerSocket} and start listening for new {@link LocalClientSocket}. + */ + public static synchronized void start(@NonNull Context context) { + stop(); + + AmSocketServerRunConfig amSocketServerRunConfig = new AmSocketServerRunConfig(TITLE, + TermuxConstants.TERMUX_APP.TERMUX_AM_SOCKET_FILE_PATH, new TermuxAmSocketServerClient()); + + termuxAmSocketServer = AmSocketServer.start(context, amSocketServerRunConfig); + } + + /** + * Stop the {@link AmSocketServer} {@link LocalServerSocket} and stop listening for new {@link LocalClientSocket}. + */ + public static synchronized void stop() { + if (termuxAmSocketServer != null) { + Error error = termuxAmSocketServer.stop(); + if (error != null) { + termuxAmSocketServer.onError(error); + } + termuxAmSocketServer = null; + } + } + + /** + * Update the state of the {@link AmSocketServer} {@link LocalServerSocket} depending on current + * value of {@link TermuxPropertyConstants#KEY_RUN_TERMUX_AM_SOCKET_SERVER}. + */ + public static synchronized void updateState(@NonNull Context context) { + TermuxAppSharedProperties properties = TermuxAppSharedProperties.getProperties(); + if (properties.shouldRunTermuxAmSocketServer()) { + if (termuxAmSocketServer == null) { + Logger.logDebug(LOG_TAG, "updateState: Starting " + TITLE + " socket server"); + start(context); + } + } else { + if (termuxAmSocketServer != null) { + Logger.logDebug(LOG_TAG, "updateState: Disabling " + TITLE + " socket server"); + stop(); + } + } + } + + /** + * Get {@link #termuxAmSocketServer}. + */ + public static synchronized LocalSocketManager getTermuxAmSocketServer() { + return termuxAmSocketServer; + } + + /** + * Show an error notification on the {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME} with a call + * to {@link TermuxPluginUtils#sendPluginCommandErrorNotification(Context, String, CharSequence, String, String)}. + * + * @param context The {@link Context} to send the notification with. + * @param error The {@link Error} generated. + * @param localSocketRunConfig The {@link LocalSocketRunConfig} for {@link LocalSocketManager}. + * @param clientSocket The optional {@link LocalClientSocket} for which the error was generated. + */ + public static synchronized void showErrorNotification(@NonNull Context context, @NonNull Error error, + @NonNull LocalSocketRunConfig localSocketRunConfig, + @Nullable LocalClientSocket clientSocket) { + TermuxPluginUtils.sendPluginCommandErrorNotification(context, LOG_TAG, + localSocketRunConfig.getTitle() + " Socket Server Error", error.getMinimalErrorString(), + LocalSocketManager.getErrorMarkdownString(error, localSocketRunConfig, clientSocket)); + } + + + + public static Boolean getTermuxAppAMSocketServerEnabled(@NonNull Context currentPackageContext) { + boolean isTermuxApp = TermuxConstants.TERMUX_PACKAGE_NAME.equals(currentPackageContext.getPackageName()); + if (isTermuxApp) { + return TERMUX_APP_AM_SOCKET_SERVER_ENABLED; + } else { + // Currently, unsupported since plugin app processes don't know that value is set in termux + // app process TermuxAmSocketServer class. A binder API or a way to check if server is actually + // running needs to be used. Long checks would also not be possible on main application thread + return null; + } + + } + + + + + + /** Enhanced implementation for {@link AmSocketServer.AmSocketServerClient} for {@link TermuxAmSocketServer}. */ + public static class TermuxAmSocketServerClient extends AmSocketServer.AmSocketServerClient { + + public static final String LOG_TAG = "TermuxAmSocketServerClient"; + + @Nullable + @Override + public Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH( + @NonNull LocalSocketManager localSocketManager) { + // Use termux crash handler for socket listener thread just like used for main app process thread. + return TermuxCrashUtils.getCrashHandler(localSocketManager.getContext()); + } + + @Override + public void onError(@NonNull LocalSocketManager localSocketManager, + @Nullable LocalClientSocket clientSocket, @NonNull Error error) { + // Don't show notification if server is not running since errors may be triggered + // when server is stopped and server and client sockets are closed. + if (localSocketManager.isRunning()) { + TermuxAmSocketServer.showErrorNotification(localSocketManager.getContext(), error, + localSocketManager.getLocalSocketRunConfig(), clientSocket); + } + + // But log the exception + super.onError(localSocketManager, clientSocket, error); + } + + @Override + public void onDisallowedClientConnected(@NonNull LocalSocketManager localSocketManager, + @NonNull LocalClientSocket clientSocket, @NonNull Error error) { + // Always show notification and log error regardless of if server is running or not + TermuxAmSocketServer.showErrorNotification(localSocketManager.getContext(), error, + localSocketManager.getLocalSocketRunConfig(), clientSocket); + super.onDisallowedClientConnected(localSocketManager, clientSocket, error); + } + + + + @Override + protected String getLogTag() { + return LOG_TAG; + } + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxAppShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxAppShellEnvironment.java new file mode 100644 index 000000000..abd11f652 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxAppShellEnvironment.java @@ -0,0 +1,172 @@ +package com.termux.shared.termux.shell.command.environment; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.android.PackageUtils; +import com.termux.shared.android.SELinuxUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.shell.command.environment.ShellEnvironmentUtils; +import com.termux.shared.termux.TermuxBootstrap; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.shell.am.TermuxAmSocketServer; + +import java.util.HashMap; + +/** + * Environment for {@link TermuxConstants#TERMUX_PACKAGE_NAME} app. + */ +public class TermuxAppShellEnvironment { + + /** Termux app environment variables. */ + public static HashMap termuxAppEnvironment; + + /** Environment variable for the Termux app version. */ + public static final String ENV_TERMUX_VERSION = TermuxConstants.TERMUX_ENV_PREFIX_ROOT + "_VERSION"; + + /** Environment variable prefix for the Termux app. */ + public static final String TERMUX_APP_ENV_PREFIX = TermuxConstants.TERMUX_ENV_PREFIX_ROOT + "_APP__"; + + /** Environment variable for the Termux app version name. */ + public static final String ENV_TERMUX_APP__VERSION_NAME = TERMUX_APP_ENV_PREFIX + "VERSION_NAME"; + /** Environment variable for the Termux app version code. */ + public static final String ENV_TERMUX_APP__VERSION_CODE = TERMUX_APP_ENV_PREFIX + "VERSION_CODE"; + /** Environment variable for the Termux app package name. */ + public static final String ENV_TERMUX_APP__PACKAGE_NAME = TERMUX_APP_ENV_PREFIX + "PACKAGE_NAME"; + /** Environment variable for the Termux app process id. */ + public static final String ENV_TERMUX_APP__PID = TERMUX_APP_ENV_PREFIX + "PID"; + /** Environment variable for the Termux app uid. */ + public static final String ENV_TERMUX_APP__UID = TERMUX_APP_ENV_PREFIX + "UID"; + /** Environment variable for the Termux app targetSdkVersion. */ + public static final String ENV_TERMUX_APP__TARGET_SDK = TERMUX_APP_ENV_PREFIX + "TARGET_SDK"; + /** Environment variable for the Termux app is debuggable apk build. */ + public static final String ENV_TERMUX_APP__IS_DEBUGGABLE_BUILD = TERMUX_APP_ENV_PREFIX + "IS_DEBUGGABLE_BUILD"; + /** Environment variable for the Termux app {@link TermuxConstants} APK_RELEASE_*. */ + public static final String ENV_TERMUX_APP__APK_RELEASE = TERMUX_APP_ENV_PREFIX + "APK_RELEASE"; + /** Environment variable for the Termux app install path. */ + public static final String ENV_TERMUX_APP__APK_PATH = TERMUX_APP_ENV_PREFIX + "APK_PATH"; + /** Environment variable for the Termux app is installed on external/portable storage. */ + public static final String ENV_TERMUX_APP__IS_INSTALLED_ON_EXTERNAL_STORAGE = TERMUX_APP_ENV_PREFIX + "IS_INSTALLED_ON_EXTERNAL_STORAGE"; + + /** Environment variable for the Termux app process selinux context. */ + public static final String ENV_TERMUX_APP__SE_PROCESS_CONTEXT = TERMUX_APP_ENV_PREFIX + "SE_PROCESS_CONTEXT"; + /** Environment variable for the Termux app data files selinux context. */ + public static final String ENV_TERMUX_APP__SE_FILE_CONTEXT = TERMUX_APP_ENV_PREFIX + "SE_FILE_CONTEXT"; + /** Environment variable for the Termux app seInfo tag found in selinux policy used to set app process and app data files selinux context. */ + public static final String ENV_TERMUX_APP__SE_INFO = TERMUX_APP_ENV_PREFIX + "SE_INFO"; + /** Environment variable for the Termux app user id. */ + public static final String ENV_TERMUX_APP__USER_ID = TERMUX_APP_ENV_PREFIX + "USER_ID"; + /** Environment variable for the Termux app profile owner. */ + public static final String ENV_TERMUX_APP__PROFILE_OWNER = TERMUX_APP_ENV_PREFIX + "PROFILE_OWNER"; + + /** Environment variable for the Termux app {@link TermuxBootstrap#TERMUX_APP_PACKAGE_MANAGER}. */ + public static final String ENV_TERMUX_APP__PACKAGE_MANAGER = TERMUX_APP_ENV_PREFIX + "PACKAGE_MANAGER"; + /** Environment variable for the Termux app {@link TermuxBootstrap#TERMUX_APP_PACKAGE_VARIANT}. */ + public static final String ENV_TERMUX_APP__PACKAGE_VARIANT = TERMUX_APP_ENV_PREFIX + "PACKAGE_VARIANT"; + /** Environment variable for the Termux app files directory. */ + public static final String ENV_TERMUX_APP__FILES_DIR = TERMUX_APP_ENV_PREFIX + "FILES_DIR"; + + + /** Environment variable for the Termux app {@link TermuxAmSocketServer#getTermuxAppAMSocketServerEnabled(Context)}. */ + public static final String ENV_TERMUX_APP__AM_SOCKET_SERVER_ENABLED = TERMUX_APP_ENV_PREFIX + "AM_SOCKET_SERVER_ENABLED"; + + + + /** Get shell environment for Termux app. */ + @Nullable + public static HashMap getEnvironment(@NonNull Context currentPackageContext) { + setTermuxAppEnvironment(currentPackageContext); + return termuxAppEnvironment; + } + + /** Set Termux app environment variables in {@link #termuxAppEnvironment}. */ + public synchronized static void setTermuxAppEnvironment(@NonNull Context currentPackageContext) { + boolean isTermuxApp = TermuxConstants.TERMUX_PACKAGE_NAME.equals(currentPackageContext.getPackageName()); + + // If current package context is of termux app and its environment is already set, then no need to set again since it won't change + // Other apps should always set environment again since termux app may be installed/updated/deleted in background + if (termuxAppEnvironment != null && isTermuxApp) + return; + + termuxAppEnvironment = null; + + String packageName = TermuxConstants.TERMUX_PACKAGE_NAME; + PackageInfo packageInfo = PackageUtils.getPackageInfoForPackage(currentPackageContext, packageName); + if (packageInfo == null) return; + ApplicationInfo applicationInfo = PackageUtils.getApplicationInfoForPackage(currentPackageContext, packageName); + if (applicationInfo == null || !applicationInfo.enabled) return; + + HashMap environment = new HashMap<>(); + + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_VERSION, PackageUtils.getVersionNameForPackage(packageInfo)); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__VERSION_NAME, PackageUtils.getVersionNameForPackage(packageInfo)); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__VERSION_CODE, String.valueOf(PackageUtils.getVersionCodeForPackage(packageInfo))); + + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__PACKAGE_NAME, packageName); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__PID, TermuxUtils.getTermuxAppPID(currentPackageContext)); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__UID, String.valueOf(PackageUtils.getUidForPackage(applicationInfo))); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__TARGET_SDK, String.valueOf(PackageUtils.getTargetSDKForPackage(applicationInfo))); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__IS_DEBUGGABLE_BUILD, PackageUtils.isAppForPackageADebuggableBuild(applicationInfo)); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__APK_PATH, PackageUtils.getBaseAPKPathForPackage(applicationInfo)); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__IS_INSTALLED_ON_EXTERNAL_STORAGE, PackageUtils.isAppInstalledOnExternalStorage(applicationInfo)); + + putTermuxAPKSignature(currentPackageContext, environment); + + Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext); + if (termuxPackageContext != null) { + // An app that does not have the same sharedUserId as termux app will not be able to get + // get termux context's classloader to get BuildConfig.TERMUX_PACKAGE_VARIANT via reflection. + // Check TermuxBootstrap.setTermuxPackageManagerAndVariantFromTermuxApp() + if (TermuxBootstrap.TERMUX_APP_PACKAGE_MANAGER != null) + environment.put(ENV_TERMUX_APP__PACKAGE_MANAGER, TermuxBootstrap.TERMUX_APP_PACKAGE_MANAGER.getName()); + if (TermuxBootstrap.TERMUX_APP_PACKAGE_VARIANT != null) + environment.put(ENV_TERMUX_APP__PACKAGE_VARIANT, TermuxBootstrap.TERMUX_APP_PACKAGE_VARIANT.getName()); + + // Will not be set for plugins + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__AM_SOCKET_SERVER_ENABLED, + TermuxAmSocketServer.getTermuxAppAMSocketServerEnabled(currentPackageContext)); + + String filesDirPath = currentPackageContext.getFilesDir().getAbsolutePath(); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__FILES_DIR, filesDirPath); + + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__SE_PROCESS_CONTEXT, SELinuxUtils.getContext()); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__SE_FILE_CONTEXT, SELinuxUtils.getFileContext(filesDirPath)); + + String seInfoUser = PackageUtils.getApplicationInfoSeInfoUserForPackage(applicationInfo); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__SE_INFO, PackageUtils.getApplicationInfoSeInfoForPackage(applicationInfo) + + (DataUtils.isNullOrEmpty(seInfoUser) ? "" : seInfoUser)); + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__USER_ID, String.valueOf(PackageUtils.getUserIdForPackage(currentPackageContext))); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__PROFILE_OWNER, PackageUtils.getProfileOwnerPackageNameForUser(currentPackageContext)); + } + + termuxAppEnvironment = environment; + } + + /** Put {@link #ENV_TERMUX_APP__APK_RELEASE} in {@code environment}. */ + public static void putTermuxAPKSignature(@NonNull Context currentPackageContext, + @NonNull HashMap environment) { + String signingCertificateSHA256Digest = PackageUtils.getSigningCertificateSHA256DigestForPackage(currentPackageContext, + TermuxConstants.TERMUX_PACKAGE_NAME); + if (signingCertificateSHA256Digest != null) { + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_APP__APK_RELEASE, + TermuxUtils.getAPKRelease(signingCertificateSHA256Digest).replaceAll("[^a-zA-Z]", "_").toUpperCase()); + } + } + + /** Update {@link #ENV_TERMUX_APP__AM_SOCKET_SERVER_ENABLED} value in {@code environment}. */ + public synchronized static void updateTermuxAppAMSocketServerEnabled(@NonNull Context currentPackageContext) { + if (termuxAppEnvironment == null) return; + termuxAppEnvironment.remove(ENV_TERMUX_APP__AM_SOCKET_SERVER_ENABLED); + ShellEnvironmentUtils.putToEnvIfSet(termuxAppEnvironment, ENV_TERMUX_APP__AM_SOCKET_SERVER_ENABLED, + TermuxAmSocketServer.getTermuxAppAMSocketServerEnabled(currentPackageContext)); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellCommandShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellCommandShellEnvironment.java new file mode 100644 index 000000000..421d94b58 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellCommandShellEnvironment.java @@ -0,0 +1,48 @@ +package com.termux.shared.termux.shell.command.environment; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.environment.ShellCommandShellEnvironment; +import com.termux.shared.shell.command.environment.ShellEnvironmentUtils; +import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.termux.shell.TermuxShellManager; + +import java.util.HashMap; + +/** + * Environment for Termux {@link ExecutionCommand}. + */ +public class TermuxShellCommandShellEnvironment extends ShellCommandShellEnvironment { + + /** Get shell environment containing info for Termux {@link ExecutionCommand}. */ + @NonNull + @Override + public HashMap getEnvironment(@NonNull Context currentPackageContext, + @NonNull ExecutionCommand executionCommand) { + HashMap environment = super.getEnvironment(currentPackageContext, executionCommand); + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(currentPackageContext); + if (preferences == null) return environment; + + if (ExecutionCommand.Runner.APP_SHELL.equalsRunner(executionCommand.runner)) { + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_BOOT, + String.valueOf(preferences.getAndIncrementAppShellNumberSinceBoot())); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_APP_START, + String.valueOf(TermuxShellManager.getAndIncrementAppShellNumberSinceAppStart())); + + } else if (ExecutionCommand.Runner.TERMINAL_SESSION.equalsRunner(executionCommand.runner)) { + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_BOOT, + String.valueOf(preferences.getAndIncrementTerminalSessionNumberSinceBoot())); + ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_APP_START, + String.valueOf(TermuxShellManager.getAndIncrementTerminalSessionNumberSinceAppStart())); + } else { + return environment; + } + + return environment; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellEnvironment.java new file mode 100644 index 000000000..8bdd47916 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellEnvironment.java @@ -0,0 +1,113 @@ +package com.termux.shared.termux.shell.command.environment; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.environment.AndroidShellEnvironment; +import com.termux.shared.shell.command.environment.ShellEnvironmentUtils; +import com.termux.shared.shell.command.environment.ShellCommandShellEnvironment; +import com.termux.shared.termux.TermuxBootstrap; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.shell.TermuxShellUtils; + +import java.nio.charset.Charset; +import java.util.HashMap; + +/** + * Environment for Termux. + */ +public class TermuxShellEnvironment extends AndroidShellEnvironment { + + private static final String LOG_TAG = "TermuxShellEnvironment"; + + /** Environment variable for the termux {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH}. */ + public static final String ENV_PREFIX = "PREFIX"; + + public TermuxShellEnvironment() { + super(); + shellCommandShellEnvironment = new TermuxShellCommandShellEnvironment(); + } + + + /** Init {@link TermuxShellEnvironment} constants and caches. */ + public synchronized static void init(@NonNull Context currentPackageContext) { + TermuxAppShellEnvironment.setTermuxAppEnvironment(currentPackageContext); + } + + /** Init {@link TermuxShellEnvironment} constants and caches. */ + public synchronized static void writeEnvironmentToFile(@NonNull Context currentPackageContext) { + HashMap environmentMap = new TermuxShellEnvironment().getEnvironment(currentPackageContext, false); + String environmentString = ShellEnvironmentUtils.convertEnvironmentToDotEnvFile(environmentMap); + + // Write environment string to temp file and then move to final location since otherwise + // writing may happen while file is being sourced/read + Error error = FileUtils.writeTextToFile("termux.env.tmp", TermuxConstants.TERMUX_ENV_TEMP_FILE_PATH, + Charset.defaultCharset(), environmentString, false); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + return; + } + + error = FileUtils.moveRegularFile("termux.env.tmp", TermuxConstants.TERMUX_ENV_TEMP_FILE_PATH, TermuxConstants.TERMUX_ENV_FILE_PATH, true); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + } + } + + /** Get shell environment for Termux. */ + @NonNull + @Override + public HashMap getEnvironment(@NonNull Context currentPackageContext, boolean isFailSafe) { + + // Termux environment builds upon the Android environment + HashMap environment = super.getEnvironment(currentPackageContext, isFailSafe); + + HashMap termuxAppEnvironment = TermuxAppShellEnvironment.getEnvironment(currentPackageContext); + if (termuxAppEnvironment != null) + environment.putAll(termuxAppEnvironment); + + environment.put(ENV_HOME, TermuxConstants.TERMUX_HOME_DIR_PATH); + environment.put(ENV_PREFIX, TermuxConstants.TERMUX_PREFIX_DIR_PATH); + + // If failsafe is not enabled, then we keep default PATH and TMPDIR so that system binaries can be used + if (!isFailSafe) { + environment.put(ENV_TMPDIR, TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH); + if (TermuxBootstrap.isAppPackageVariantAPTAndroid5()) { + // Termux in android 5/6 era shipped busybox binaries in applets directory + environment.put(ENV_PATH, TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + ":" + TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/applets"); + environment.put(ENV_LD_LIBRARY_PATH, TermuxConstants.TERMUX_LIB_PREFIX_DIR_PATH); + } else { + // Termux binaries on Android 7+ rely on DT_RUNPATH, so LD_LIBRARY_PATH should be unset by default + environment.put(ENV_PATH, TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH); + environment.remove(ENV_LD_LIBRARY_PATH); + } + } + + return environment; + } + + + @NonNull + @Override + public String getDefaultWorkingDirectoryPath() { + return TermuxConstants.TERMUX_HOME_DIR_PATH; + } + + @NonNull + @Override + public String getDefaultBinPath() { + return TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH; + } + + @NonNull + @Override + public String[] setupShellCommandArguments(@NonNull String executable, String[] arguments) { + return TermuxShellUtils.setupShellCommandArguments(executable, arguments); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/runner/terminal/TermuxSession.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/runner/terminal/TermuxSession.java new file mode 100644 index 000000000..7aa874d1e --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/shell/command/runner/terminal/TermuxSession.java @@ -0,0 +1,296 @@ +package com.termux.shared.termux.shell.command.runner.terminal; + +import android.content.Context; +import android.system.OsConstants; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.base.Joiner; +import com.termux.shared.R; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.environment.ShellEnvironmentUtils; +import com.termux.shared.shell.command.environment.UnixShellEnvironment; +import com.termux.shared.shell.command.result.ResultData; +import com.termux.shared.errors.Errno; +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.environment.IShellEnvironment; +import com.termux.shared.shell.ShellUtils; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; + +import java.io.File; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +/** + * A class that maintains info for foreground Termux sessions. + * It also provides a way to link each {@link TerminalSession} with the {@link ExecutionCommand} + * that started it. + */ +public class TermuxSession { + + private final TerminalSession mTerminalSession; + private final ExecutionCommand mExecutionCommand; + private final TermuxSessionClient mTermuxSessionClient; + private final boolean mSetStdoutOnExit; + + private static final String LOG_TAG = "TermuxSession"; + + private TermuxSession(@NonNull final TerminalSession terminalSession, @NonNull final ExecutionCommand executionCommand, + final TermuxSessionClient termuxSessionClient, final boolean setStdoutOnExit) { + this.mTerminalSession = terminalSession; + this.mExecutionCommand = executionCommand; + this.mTermuxSessionClient = termuxSessionClient; + this.mSetStdoutOnExit = setStdoutOnExit; + } + + /** + * Start execution of an {@link ExecutionCommand} with {@link Runtime#exec(String[], String[], File)}. + * + * The {@link ExecutionCommand#executable}, must be set, {@link ExecutionCommand#commandLabel}, + * {@link ExecutionCommand#arguments} and {@link ExecutionCommand#workingDirectory} may optionally + * be set. + * + * If {@link ExecutionCommand#executable} is {@code null}, then a default shell is automatically + * chosen. + * + * @param currentPackageContext The {@link Context} for operations. This must be the context for + * the current package and not the context of a `sharedUserId` package, + * since environment setup may be dependent on current package. + * @param executionCommand The {@link ExecutionCommand} containing the information for execution command. + * @param terminalSessionClient The {@link TerminalSessionClient} interface implementation. + * @param termuxSessionClient The {@link TermuxSessionClient} interface implementation. + * @param shellEnvironmentClient The {@link IShellEnvironment} interface implementation. + * @param additionalEnvironment The additional shell environment variables to export. Existing + * variables will be overridden. + * @param setStdoutOnExit If set to {@code true}, then the {@link ResultData#stdout} + * available in the {@link TermuxSessionClient#onTermuxSessionExited(TermuxSession)} + * callback will be set to the {@link TerminalSession} transcript. The session + * transcript will contain both stdout and stderr combined, basically + * anything sent to the the pseudo terminal /dev/pts, including PS1 prefixes. + * Set this to {@code true} only if the session transcript is required, + * since this requires extra processing to get it. + * @return Returns the {@link TermuxSession}. This will be {@code null} if failed to start the execution command. + */ + public static TermuxSession execute(@NonNull final Context currentPackageContext, @NonNull ExecutionCommand executionCommand, + @NonNull final TerminalSessionClient terminalSessionClient, final TermuxSessionClient termuxSessionClient, + @NonNull final IShellEnvironment shellEnvironmentClient, + @Nullable HashMap additionalEnvironment, + final boolean setStdoutOnExit) { + if (executionCommand.executable != null && executionCommand.executable.isEmpty()) + executionCommand.executable = null; + if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty()) + executionCommand.workingDirectory = shellEnvironmentClient.getDefaultWorkingDirectoryPath(); + if (executionCommand.workingDirectory.isEmpty()) + executionCommand.workingDirectory = "/"; + + String defaultBinPath = shellEnvironmentClient.getDefaultBinPath(); + if (defaultBinPath.isEmpty()) + defaultBinPath = "/system/bin"; + + boolean isLoginShell = false; + if (executionCommand.executable == null) { + if (!executionCommand.isFailsafe) { + for (String shellBinary : UnixShellEnvironment.LOGIN_SHELL_BINARIES) { + File shellFile = new File(defaultBinPath, shellBinary); + if (shellFile.canExecute()) { + executionCommand.executable = shellFile.getAbsolutePath(); + break; + } + } + } + + if (executionCommand.executable == null) { + // Fall back to system shell as last resort: + // Do not start a login shell since ~/.profile may cause startup failure if its invalid. + // /system/bin/sh is provided by mksh (not toybox) and does load .mkshrc but for android its set + // to /system/etc/mkshrc even though its default is ~/.mkshrc. + // So /system/etc/mkshrc must still be valid for failsafe session to start properly. + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:external/mksh/src/main.c;l=663 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:external/mksh/src/main.c;l=41 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:external/mksh/Android.bp;l=114 + executionCommand.executable = "/system/bin/sh"; + } else { + isLoginShell = true; + } + + } + + // Setup command args + String[] commandArgs = shellEnvironmentClient.setupShellCommandArguments(executionCommand.executable, executionCommand.arguments); + + executionCommand.executable = commandArgs[0]; + String processName = (isLoginShell ? "-" : "") + ShellUtils.getExecutableBasename(executionCommand.executable); + + String[] arguments = new String[commandArgs.length]; + arguments[0] = processName; + if (commandArgs.length > 1) System.arraycopy(commandArgs, 1, arguments, 1, commandArgs.length - 1); + + executionCommand.arguments = arguments; + + if (executionCommand.commandLabel == null) + executionCommand.commandLabel = processName; + + // Setup command environment + HashMap environment = shellEnvironmentClient.setupShellCommandEnvironment(currentPackageContext, + executionCommand); + if (additionalEnvironment != null) + environment.putAll(additionalEnvironment); + List environmentList = ShellEnvironmentUtils.convertEnvironmentToEnviron(environment); + Collections.sort(environmentList); + String[] environmentArray = environmentList.toArray(new String[0]); + + if (!executionCommand.setState(ExecutionCommand.ExecutionState.EXECUTING)) { + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), currentPackageContext.getString(R.string.error_failed_to_execute_termux_session_command, executionCommand.getCommandIdAndLabelLogString())); + TermuxSession.processTermuxSessionResult(null, executionCommand); + return null; + } + + Logger.logDebugExtended(LOG_TAG, executionCommand.toString()); + Logger.logVerboseExtended(LOG_TAG, "\"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession Environment:\n" + + Joiner.on("\n").join(environmentArray)); + + Logger.logDebug(LOG_TAG, "Running \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + TerminalSession terminalSession = new TerminalSession(executionCommand.executable, + executionCommand.workingDirectory, executionCommand.arguments, environmentArray, + executionCommand.terminalTranscriptRows, terminalSessionClient); + + if (executionCommand.shellName != null) { + terminalSession.mSessionName = executionCommand.shellName; + } + + return new TermuxSession(terminalSession, executionCommand, termuxSessionClient, setStdoutOnExit); + } + + /** + * Signal that this {@link TermuxSession} has finished. This should be called when + * {@link TerminalSessionClient#onSessionFinished(TerminalSession)} callback is received by the caller. + * + * If the processes has finished, then sets {@link ResultData#stdout}, {@link ResultData#stderr} + * and {@link ResultData#exitCode} for the {@link #mExecutionCommand} of the {@code termuxTask} + * and then calls {@link #processTermuxSessionResult(TermuxSession, ExecutionCommand)} to process the result}. + * + */ + public void finish() { + // If process is still running, then ignore the call + if (mTerminalSession.isRunning()) return; + + int exitCode = mTerminalSession.getExitStatus(); + + if (exitCode == 0) + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession exited normally"); + else + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession exited with code: " + exitCode); + + // If the execution command has already failed, like SIGKILL was sent, then don't continue + if (mExecutionCommand.isStateFailed()) { + Logger.logDebug(LOG_TAG, "Ignoring setting \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession state to ExecutionState.EXECUTED and processing results since it has already failed"); + return; + } + + mExecutionCommand.resultData.exitCode = exitCode; + + if (this.mSetStdoutOnExit) + mExecutionCommand.resultData.stdout.append(ShellUtils.getTerminalSessionTranscriptText(mTerminalSession, true, false)); + + if (!mExecutionCommand.setState(ExecutionCommand.ExecutionState.EXECUTED)) + return; + + TermuxSession.processTermuxSessionResult(this, null); + } + + /** + * Kill this {@link TermuxSession} by sending a {@link OsConstants#SIGILL} to its {@link #mTerminalSession} + * if its still executing. + * + * @param context The {@link Context} for operations. + * @param processResult If set to {@code true}, then the {@link #processTermuxSessionResult(TermuxSession, ExecutionCommand)} + * will be called to process the failure. + */ + public void killIfExecuting(@NonNull final Context context, boolean processResult) { + // If execution command has already finished executing, then no need to process results or send SIGKILL + if (mExecutionCommand.hasExecuted()) { + Logger.logDebug(LOG_TAG, "Ignoring sending SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession since it has already finished executing"); + return; + } + + Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + if (mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_sending_sigkill_to_process))) { + if (processResult) { + mExecutionCommand.resultData.exitCode = 137; // SIGKILL + + // Get whatever output has been set till now in case its needed + if (this.mSetStdoutOnExit) + mExecutionCommand.resultData.stdout.append(ShellUtils.getTerminalSessionTranscriptText(mTerminalSession, true, false)); + + TermuxSession.processTermuxSessionResult(this, null); + } + } + + // Send SIGKILL to process + mTerminalSession.finishIfRunning(); + } + + /** + * Process the results of {@link TermuxSession} or {@link ExecutionCommand}. + * + * Only one of {@code termuxSession} and {@code executionCommand} must be set. + * + * If the {@code termuxSession} and its {@link #mTermuxSessionClient} are not {@code null}, + * then the {@link TermuxSession.TermuxSessionClient#onTermuxSessionExited(TermuxSession)} + * callback will be called. + * + * @param termuxSession The {@link TermuxSession}, which should be set if + * {@link #execute(Context, ExecutionCommand, TerminalSessionClient, TermuxSessionClient, IShellEnvironment, HashMap, boolean)} + * successfully started the process. + * @param executionCommand The {@link ExecutionCommand}, which should be set if + * {@link #execute(Context, ExecutionCommand, TerminalSessionClient, TermuxSessionClient, IShellEnvironment, HashMap, boolean)} + * failed to start the process. + */ + private static void processTermuxSessionResult(final TermuxSession termuxSession, ExecutionCommand executionCommand) { + if (termuxSession != null) + executionCommand = termuxSession.mExecutionCommand; + + if (executionCommand == null) return; + + if (executionCommand.shouldNotProcessResults()) { + Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession result"); + return; + } + + Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession result"); + + if (termuxSession != null && termuxSession.mTermuxSessionClient != null) { + termuxSession.mTermuxSessionClient.onTermuxSessionExited(termuxSession); + } else { + // If a callback is not set and execution command didn't fail, then we set success state now + // Otherwise, the callback host can set it himself when its done with the termuxSession + if (!executionCommand.isStateFailed()) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + } + + public TerminalSession getTerminalSession() { + return mTerminalSession; + } + + public ExecutionCommand getExecutionCommand() { + return mExecutionCommand; + } + + + + public interface TermuxSessionClient { + + /** + * Callback function for when {@link TermuxSession} exits. + * + * @param termuxSession The {@link TermuxSession} that exited. + */ + void onTermuxSessionExited(TermuxSession termuxSession); + + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalSessionClientBase.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalSessionClientBase.java new file mode 100644 index 000000000..295a82752 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalSessionClientBase.java @@ -0,0 +1,94 @@ +package com.termux.shared.termux.terminal; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; + +public class TermuxTerminalSessionClientBase implements TerminalSessionClient { + + public TermuxTerminalSessionClientBase() { + } + + @Override + public void onTextChanged(@NonNull TerminalSession changedSession) { + } + + @Override + public void onTitleChanged(@NonNull TerminalSession updatedSession) { + } + + @Override + public void onSessionFinished(@NonNull TerminalSession finishedSession) { + } + + @Override + public void onCopyTextToClipboard(@NonNull TerminalSession session, String text) { + } + + @Override + public void onPasteTextFromClipboard(@Nullable TerminalSession session) { + } + + @Override + public void onBell(@NonNull TerminalSession session) { + } + + @Override + public void onColorsChanged(@NonNull TerminalSession changedSession) { + } + + @Override + public void onTerminalCursorStateChange(boolean state) { + } + + @Override + public void setTerminalShellPid(@NonNull TerminalSession session, int pid) { + } + + + @Override + public Integer getTerminalCursorStyle() { + return null; + } + + + + @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/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalViewClientBase.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalViewClientBase.java new file mode 100644 index 000000000..a4d30726f --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalViewClientBase.java @@ -0,0 +1,127 @@ +package com.termux.shared.termux.terminal; + +import android.view.KeyEvent; +import android.view.MotionEvent; + +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalSession; +import com.termux.view.TerminalViewClient; + +public class TermuxTerminalViewClientBase implements TerminalViewClient { + + public TermuxTerminalViewClientBase() { + } + + @Override + public float onScale(float scale) { + return 1.0f; + } + + @Override + public void onSingleTapUp(MotionEvent e) { + } + + public boolean shouldBackButtonBeMappedToEscape() { + return false; + } + + public boolean shouldEnforceCharBasedInput() { + return false; + } + + public boolean shouldUseCtrlSpaceWorkaround() { + return false; + } + + @Override + public boolean isTerminalViewSelected() { + return true; + } + + @Override + public void copyModeChanged(boolean copyMode) { + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session) { + return false; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent e) { + return false; + } + + @Override + public boolean onLongPress(MotionEvent event) { + return false; + } + + @Override + public boolean readControlKey() { + return false; + } + + @Override + public boolean readAltKey() { + return false; + } + + @Override + public boolean readShiftKey() { + return false; + } + + @Override + public boolean readFnKey() { + return false; + } + + + + @Override + public boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session) { + return false; + } + + @Override + public void onEmulatorSet() { + + } + + @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/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/BellHandler.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/BellHandler.java new file mode 100644 index 000000000..1ec7b181a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/BellHandler.java @@ -0,0 +1,79 @@ +package com.termux.shared.termux.terminal.io; + +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.os.VibrationEffect; +import android.os.Vibrator; + +import com.termux.shared.logger.Logger; + +public class BellHandler { + private static BellHandler instance = null; + private static final Object lock = new Object(); + + private static final String LOG_TAG = "BellHandler"; + + public static BellHandler getInstance(Context context) { + if (instance == null) { + synchronized (lock) { + if (instance == null) { + instance = new BellHandler((Vibrator) context.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE)); + } + } + } + + return instance; + } + + private static final long DURATION = 50; + private static final long MIN_PAUSE = 3 * DURATION; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private long lastBell = 0; + private final Runnable bellRunnable; + + private BellHandler(final Vibrator vibrator) { + bellRunnable = new Runnable() { + @Override + public void run() { + if (vibrator != null) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(DURATION, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(DURATION); + } + } catch (Exception e) { + // Issue on samsung devices on android 8 + // java.lang.NullPointerException: Attempt to read from field 'android.os.VibrationEffect com.android.server.VibratorService$Vibration.mEffect' on a null object reference + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to run vibrator", e); + } + } + } + }; + } + + public synchronized void doBell() { + long now = now(); + long timeSinceLastBell = now - lastBell; + + if (timeSinceLastBell < 0) { + // there is a next bell pending; don't schedule another one + } else if (timeSinceLastBell < MIN_PAUSE) { + // there was a bell recently, schedule the next one + handler.postDelayed(bellRunnable, MIN_PAUSE - timeSinceLastBell); + lastBell = lastBell + MIN_PAUSE; + } else { + // the last bell was long ago, do it now + bellRunnable.run(); + lastBell = now; + } + } + + private long now() { + return SystemClock.uptimeMillis(); + } +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java new file mode 100644 index 000000000..8aa77772f --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java @@ -0,0 +1,85 @@ +package com.termux.shared.termux.terminal.io; + +import android.os.Build; +import android.view.KeyEvent; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.google.android.material.button.MaterialButton; +import com.termux.shared.termux.extrakeys.ExtraKeyButton; +import com.termux.shared.termux.extrakeys.ExtraKeysView; +import com.termux.shared.termux.extrakeys.SpecialButton; +import com.termux.terminal.TerminalSession; +import com.termux.view.TerminalView; + +import static com.termux.shared.termux.extrakeys.ExtraKeysConstants.PRIMARY_KEY_CODES_FOR_STRINGS; + + +public class TerminalExtraKeys implements ExtraKeysView.IExtraKeysView { + + private final TerminalView mTerminalView; + + public TerminalExtraKeys(@NonNull TerminalView terminalView) { + mTerminalView = terminalView; + } + + @Override + public void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, MaterialButton button) { + if (buttonInfo.isMacro()) { + String[] keys = buttonInfo.getKey().split(" "); + boolean ctrlDown = false; + boolean altDown = false; + boolean shiftDown = false; + boolean fnDown = false; + for (String key : keys) { + if (SpecialButton.CTRL.getKey().equals(key)) { + ctrlDown = true; + } else if (SpecialButton.ALT.getKey().equals(key)) { + altDown = true; + } else if (SpecialButton.SHIFT.getKey().equals(key)) { + shiftDown = true; + } else if (SpecialButton.FN.getKey().equals(key)) { + fnDown = true; + } else { + onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown); + ctrlDown = false; altDown = false; shiftDown = false; fnDown = false; + } + } + } else { + onTerminalExtraKeyButtonClick(view, buttonInfo.getKey(), false, false, false, false); + } + } + + protected void onTerminalExtraKeyButtonClick(View view, String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) { + if (PRIMARY_KEY_CODES_FOR_STRINGS.containsKey(key)) { + Integer keyCode = PRIMARY_KEY_CODES_FOR_STRINGS.get(key); + if (keyCode == null) return; + int metaState = 0; + if (ctrlDown) metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON; + if (altDown) metaState |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; + if (shiftDown) metaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON; + if (fnDown) metaState |= KeyEvent.META_FUNCTION_ON; + + KeyEvent keyEvent = new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState); + mTerminalView.onKeyDown(keyCode, keyEvent); + } else { + // not a control char + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + key.codePoints().forEach(codePoint -> { + mTerminalView.inputCodePoint(TerminalView.KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD, codePoint, ctrlDown, altDown); + }); + } else { + TerminalSession session = mTerminalView.getCurrentSession(); + if (session != null && key.length() > 0) + session.write(key); + } + } + } + + @Override + public boolean performExtraKeyButtonHapticFeedback(View view, ExtraKeyButton buttonInfo, MaterialButton button) { + return false; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/theme/TermuxThemeUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/theme/TermuxThemeUtils.java new file mode 100644 index 000000000..e347fb8d1 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/theme/TermuxThemeUtils.java @@ -0,0 +1,25 @@ +package com.termux.shared.termux.theme; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; +import com.termux.shared.termux.settings.properties.TermuxSharedProperties; +import com.termux.shared.theme.NightMode; + +public class TermuxThemeUtils { + + /** Get the {@link TermuxPropertyConstants#KEY_NIGHT_MODE} value from the properties file on disk + * and set it to app wide night mode value. */ + public static void setAppNightMode(@NonNull Context context) { + NightMode.setAppNightMode(TermuxSharedProperties.getNightMode(context)); + } + + /** Set name as app wide night mode value. */ + public static void setAppNightMode(@Nullable String name) { + NightMode.setAppNightMode(name); + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/theme/NightMode.java b/termux/termux-shared/src/main/java/com/termux/shared/theme/NightMode.java new file mode 100644 index 000000000..22e45386b --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/theme/NightMode.java @@ -0,0 +1,91 @@ +package com.termux.shared.theme; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDelegate; + +import com.termux.shared.logger.Logger; + +/** The modes used by to decide night mode for themes. */ +public enum NightMode { + + /** Night theme should be enabled. */ + TRUE("true", AppCompatDelegate.MODE_NIGHT_YES), + + /** Dark theme should be enabled. */ + FALSE("false", AppCompatDelegate.MODE_NIGHT_NO), + + /** + * Use night or dark theme depending on system night mode. + * https://developer.android.com/guide/topics/resources/providing-resources#NightQualifier + */ + SYSTEM("system", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + + /** The current app wide night mode used by various libraries. Defaults to {@link #SYSTEM}. */ + private static NightMode APP_NIGHT_MODE; + + private static final String LOG_TAG = "NightMode"; + + private final String name; + private final @AppCompatDelegate.NightMode int mode; + + NightMode(final String name, int mode) { + this.name = name; + this.mode = mode; + } + + public String getName() { + return name; + } + + public int getMode() { + return mode; + } + + + /** Get {@link NightMode} for {@code name} if found, otherwise {@code null}. */ + @Nullable + public static NightMode modeOf(String name) { + for (NightMode v : NightMode.values()) { + if (v.name.equals(name)) { + return v; + } + } + + return null; + } + + /** Get {@link NightMode} for {@code name} if found, otherwise {@code def}. */ + @NonNull + public static NightMode modeOf(@Nullable String name, @NonNull NightMode def) { + NightMode nightMode = modeOf(name); + return nightMode != null ? nightMode : def; + } + + + /** Set {@link #APP_NIGHT_MODE}. */ + public static void setAppNightMode(@Nullable String name) { + if (name == null || name.isEmpty()) { + APP_NIGHT_MODE = SYSTEM; + } else { + NightMode nightMode = NightMode.modeOf(name); + if (nightMode == null) { + Logger.logError(LOG_TAG, "Invalid APP_NIGHT_MODE \"" + name + "\""); + return; + } + APP_NIGHT_MODE = nightMode; + } + + Logger.logVerbose(LOG_TAG, "Set APP_NIGHT_MODE to \"" + APP_NIGHT_MODE.getName() + "\""); + } + + /** Get {@link #APP_NIGHT_MODE}. */ + @NonNull + public static NightMode getAppNightMode() { + if (APP_NIGHT_MODE == null) + APP_NIGHT_MODE = SYSTEM; + + return APP_NIGHT_MODE; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/theme/ThemeUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/theme/ThemeUtils.java new file mode 100644 index 000000000..72db1b78a --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/theme/ThemeUtils.java @@ -0,0 +1,81 @@ +package com.termux.shared.theme; + +import static com.itsaky.androidide.preferences.internal.GeneralPreferencesKt.*; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.TypedArray; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import com.itsaky.androidide.utils.ResourceUtilsKt; + +public class ThemeUtils { + + public static final int ATTR_TEXT_COLOR_PRIMARY = android.R.attr.textColorPrimary; + public static final int ATTR_TEXT_COLOR_SECONDARY = android.R.attr.textColorSecondary; + public static final int ATTR_TEXT_COLOR = android.R.attr.textColor; + public static final int ATTR_TEXT_COLOR_LINK = android.R.attr.textColorLink; + + /** + * Will return true if system has enabled night mode. + * https://developer.android.com/guide/topics/resources/providing-resources#NightQualifier + */ + public static boolean isNightModeEnabled(Context context) { + if (context == null) return false; + return ResourceUtilsKt.isSystemInDarkMode(context); + } + + /** Will return true if mode is set to {@link NightMode#TRUE}, otherwise will return true if + * mode is set to {@link NightMode#SYSTEM} and night mode is enabled by system. */ + public static boolean shouldEnableDarkTheme(Context context, String name) { + return getUiMode() == AppCompatDelegate.getDefaultNightMode() || isNightModeEnabled(context); + } + + + /** Get {@link #ATTR_TEXT_COLOR_PRIMARY} value being used by current theme. */ + public static int getTextColorPrimary(Context context) { + return getSystemAttrColor(context, ATTR_TEXT_COLOR_PRIMARY); + } + + /** Get {@link #ATTR_TEXT_COLOR_SECONDARY} value being used by current theme. */ + public static int getTextColorSecondary(Context context) { + return getSystemAttrColor(context, ATTR_TEXT_COLOR_SECONDARY); + } + + /** Get {@link #ATTR_TEXT_COLOR} value being used by current theme. */ + public static int getTextColor(Context context) { + return getSystemAttrColor(context, ATTR_TEXT_COLOR); + } + + /** Get {@link #ATTR_TEXT_COLOR_LINK} value being used by current theme. */ + public static int getTextColorLink(Context context) { + return getSystemAttrColor(context, ATTR_TEXT_COLOR_LINK); + } + + + + /** Wrapper for {@link #getSystemAttrColor(Context, int, int)} with {@code def} value {@code 0}. */ + public static int getSystemAttrColor(Context context, int attr) { + return getSystemAttrColor(context, attr, 0); + } + + /** + * Get a values defined by the current heme listed in attrs. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity} or one with which a theme attribute can be got. + * Do no use application context. + * @param attr The attr id. + * @param def The def value to return. + * @return Returns the {@code attr} value if found, otherwise {@code def}. + */ + public static int getSystemAttrColor(Context context, int attr, int def) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(new int[] { attr }); + int color = typedArray.getColor(0, def); + typedArray.recycle(); + return color; + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/view/KeyboardUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/view/KeyboardUtils.java new file mode 100644 index 000000000..5df35d2b0 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/view/KeyboardUtils.java @@ -0,0 +1,198 @@ +package com.termux.shared.view; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.inputmethodservice.InputMethodService; +import android.os.Build; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.view.WindowInsetsCompat; + +import com.termux.shared.logger.Logger; + +public class KeyboardUtils { + + private static final String LOG_TAG = "KeyboardUtils"; + + public static void setSoftKeyboardVisibility(@NonNull final Runnable showSoftKeyboardRunnable, final Activity activity, final View view, final boolean visible) { + if (visible) { + // A Runnable with a delay is used, otherwise soft keyboard may not automatically open + // on some devices, but still may fail + view.postDelayed(showSoftKeyboardRunnable, 500); + } else { + view.removeCallbacks(showSoftKeyboardRunnable); + hideSoftKeyboard(activity, view); + } + } + + /** + * Toggle the soft keyboard. The {@link InputMethodManager#SHOW_FORCED} is passed as + * {@code showFlags} so that keyboard is forcefully shown if it needs to be enabled. + * + * This is also important for soft keyboard to be shown when a hardware keyboard is connected, and + * user has disabled the {@code Show on-screen keyboard while hardware keyboard is connected} toggle + * in Android "Language and Input" settings but the current soft keyboard app overrides the + * default implementation of {@link InputMethodService#onEvaluateInputViewShown()} and returns + * {@code true}. + */ + public static void toggleSoftKeyboard(final Context context) { + if (context == null) return; + InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) + inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + } + + /** + * Show the soft keyboard. The {@code 0} value is passed as {@code flags} so that keyboard is + * forcefully shown. + * + * This is also important for soft keyboard to be shown on app startup when a hardware keyboard + * is connected, and user has disabled the {@code Show on-screen keyboard while hardware keyboard + * is connected} toggle in Android "Language and Input" settings but the current soft keyboard app + * overrides the default implementation of {@link InputMethodService#onEvaluateInputViewShown()} + * and returns {@code true}. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/core/java/android/inputmethodservice/InputMethodService.java;l=1751 + * + * Also check {@link InputMethodService#onShowInputRequested(int, boolean)} which must return + * {@code true}, which can be done by failing its {@code ((flags&InputMethod.SHOW_EXPLICIT) == 0)} + * check by passing {@code 0} as {@code flags}. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/core/java/android/inputmethodservice/InputMethodService.java;l=2022 + */ + public static void showSoftKeyboard(final Context context, final View view) { + if (context == null || view == null) return; + InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) + inputMethodManager.showSoftInput(view, 0); + } + + public static void hideSoftKeyboard(final Context context, final View view) { + if (context == null || view == null) return; + InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) + inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + public static void disableSoftKeyboard(final Activity activity, final View view) { + if (activity == null || view == null) return; + hideSoftKeyboard(activity, view); + setDisableSoftKeyboardFlags(activity); + } + + public static void setDisableSoftKeyboardFlags(final Activity activity) { + if (activity != null && activity.getWindow() != null) + activity.getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } + + public static void clearDisableSoftKeyboardFlags(final Activity activity) { + if (activity != null && activity.getWindow() != null) + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } + + public static boolean areDisableSoftKeyboardFlagsSet(final Activity activity) { + if (activity == null || activity.getWindow() == null) return false; + return (activity.getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) != 0; + } + + public static void setSoftKeyboardAlwaysHiddenFlags(final Activity activity) { + if (activity != null && activity.getWindow() != null) + activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + } + + public static void setSoftInputModeAdjustResize(final Activity activity) { + // TODO: The flag is deprecated for API 30 and WindowInset API should be used + // https://developer.android.com/reference/android/view/WindowManager.LayoutParams#SOFT_INPUT_ADJUST_RESIZE + // https://medium.com/androiddevelopers/animating-your-keyboard-fb776a8fb66d + // https://stackoverflow.com/a/65194077/14686958 + if (activity != null && activity.getWindow() != null) + activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + } + + /** + * Check if soft keyboard is visible. + * Does not work on android 7 but does on android 11 avd. + * + * @param activity The Activity of the root view for which the visibility should be checked. + * @return Returns {@code true} if soft keyboard is visible, otherwise {@code false}. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + public static boolean isSoftKeyboardVisible(final Activity activity) { + if (activity != null && activity.getWindow() != null) { + WindowInsets insets = activity.getWindow().getDecorView().getRootWindowInsets(); + if (insets != null) { + WindowInsetsCompat insetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets); + if (insetsCompat.isVisible(WindowInsetsCompat.Type.ime())) { + Logger.logVerbose(LOG_TAG, "Soft keyboard visible"); + return true; + } + } + } + + Logger.logVerbose(LOG_TAG, "Soft keyboard not visible"); + return false; + } + + /** + * Check if hardware keyboard is connected. + * Based on default implementation of {@link InputMethodService#onEvaluateInputViewShown()}. + * + * https://developer.android.com/guide/topics/resources/providing-resources#ImeQualifier + * + * @param context The Context for operations. + * @return Returns {@code true} if device has hardware keys for text input or an external hardware + * keyboard is connected, otherwise {@code false}. + */ + public static boolean isHardKeyboardConnected(final Context context) { + if (context == null) return false; + + Configuration config = context.getResources().getConfiguration(); + return config.keyboard != Configuration.KEYBOARD_NOKEYS + || config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO; + } + + /** + * Check if soft keyboard should be disabled based on user configuration. + * + * @param context The Context for operations. + * @return Returns {@code true} if device has soft keyboard should be disabled, otherwise {@code false}. + */ + public static boolean shouldSoftKeyboardBeDisabled(final Context context, final boolean isSoftKeyboardEnabled, final boolean isSoftKeyboardEnabledOnlyIfNoHardware) { + // If soft keyboard is disabled by user regardless of hardware keyboard + if (!isSoftKeyboardEnabled) { + return true; + } else { + /* + * Currently, for this case, soft keyboard will be disabled on Termux app startup and + * when switching back from another app. Soft keyboard can be temporarily enabled in + * show/hide soft keyboard toggle behaviour with keyboard toggle buttons and will continue + * to work when tapping on terminal view for opening and back button for closing, until + * Termux app is switched to another app. After returning back, keyboard will be disabled + * until toggle is pressed again. + * This may also be helpful for the Lineage OS bug where if "Show soft keyboard" toggle + * in "Language and Input" is disabled and Termux is started without a hardware keyboard + * in landscape mode, and then the keyboard is connected and phone is rotated to portrait + * mode and then keyboard is toggled with Termux keyboard toggle buttons, then a blank + * space is shown in-place of the soft keyboard. Its likely related to + * WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE which pushes up the view when + * keyboard is opened instead of the keyboard opening on top of the view (hiding stuff). + * If the "Show soft keyboard" toggle was disabled, then this resizing shouldn't happen. + * But it seems resizing does happen, but keyboard is never opened since its not supposed to. + * https://github.com/termux/termux-app/issues/1995#issuecomment-837080079 + */ + // If soft keyboard is disabled by user only if hardware keyboard is connected + if(isSoftKeyboardEnabledOnlyIfNoHardware) { + boolean isHardKeyboardConnected = KeyboardUtils.isHardKeyboardConnected(context); + Logger.logVerbose(LOG_TAG, "Hardware keyboard connected=" + isHardKeyboardConnected); + return isHardKeyboardConnected; + } else { + return false; + } + } + } + +} diff --git a/termux/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java new file mode 100644 index 000000000..5a0323370 --- /dev/null +++ b/termux/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java @@ -0,0 +1,249 @@ +package com.termux.shared.view; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.res.Configuration; +import android.graphics.Point; +import android.graphics.Rect; +import android.os.Build; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; + +import com.itsaky.androidide.window.WindowManager; +import com.itsaky.androidide.window.WindowMetrics; +import com.termux.shared.logger.Logger; + +public class ViewUtils { + + /** Log root view events. */ + public static boolean VIEW_UTILS_LOGGING_ENABLED = false; + + private static final String LOG_TAG = "ViewUtils"; + + /** + * Sets whether view utils logging is enabled or not. + * + * @param value The boolean value that defines the state. + */ + public static void setIsViewUtilsLoggingEnabled(boolean value) { + VIEW_UTILS_LOGGING_ENABLED = value; + } + + /** + * Check if a {@link View} is fully visible and not hidden or partially covered by another view. + * + * https://stackoverflow.com/a/51078418/14686958 + * + * @param view The {@link View} to check. + * @param statusBarHeight The status bar height received by {@link View.OnApplyWindowInsetsListener}. + * @return Returns {@code true} if view is fully visible. + */ + public static boolean isViewFullyVisible(View view, int statusBarHeight) { + Rect[] windowAndViewRects = getWindowAndViewRects(view, statusBarHeight); + if (windowAndViewRects == null) + return false; + return windowAndViewRects[0].contains(windowAndViewRects[1]); + } + + /** + * Get the {@link Rect} of a {@link View} and the {@link Rect} of the window inside which it + * exists. + * + * https://stackoverflow.com/a/51078418/14686958 + * + * @param view The {@link View} inside the window whose {@link Rect} to get. + * @param statusBarHeight The status bar height received by {@link View.OnApplyWindowInsetsListener}. + * @return Returns {@link Rect[]} if view is visible where Rect[0] will contain window + * {@link Rect} and Rect[1] will contain view {@link Rect}. This will be {@code null} + * if view is not visible. + */ + @Nullable + public static Rect[] getWindowAndViewRects(View view, int statusBarHeight) { + if (view == null || !view.isShown()) + return null; + + boolean view_utils_logging_enabled = VIEW_UTILS_LOGGING_ENABLED; + + // windowRect - will hold available area where content remain visible to users + // Takes into account screen decorations (e.g. statusbar) + Rect windowRect = new Rect(); + view.getWindowVisibleDisplayFrame(windowRect); + + // If there is actionbar, get his height + int actionBarHeight = 0; + boolean isInMultiWindowMode = false; + Context context = view.getContext(); + if (context instanceof AppCompatActivity) { + ActionBar actionBar = ((AppCompatActivity) context).getSupportActionBar(); + if (actionBar != null) actionBarHeight = actionBar.getHeight(); + isInMultiWindowMode = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) && ((AppCompatActivity) context).isInMultiWindowMode(); + } else if (context instanceof Activity) { + android.app.ActionBar actionBar = ((Activity) context).getActionBar(); + if (actionBar != null) actionBarHeight = actionBar.getHeight(); + isInMultiWindowMode = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) && ((Activity) context).isInMultiWindowMode(); + } + + int displayOrientation = getDisplayOrientation(context); + + // windowAvailableRect - takes into account actionbar and statusbar height + Rect windowAvailableRect; + windowAvailableRect = new Rect(windowRect.left, windowRect.top + actionBarHeight, windowRect.right, windowRect.bottom); + + // viewRect - holds position of the view in window + // (methods as getGlobalVisibleRect, getHitRect, getDrawingRect can return different result, + // when partialy visible) + Rect viewRect; + final int[] viewsLocationInWindow = new int[2]; + view.getLocationInWindow(viewsLocationInWindow); + int viewLeft = viewsLocationInWindow[0]; + int viewTop = viewsLocationInWindow[1]; + + if (view_utils_logging_enabled) { + Logger.logVerbose(LOG_TAG, "getWindowAndViewRects:"); + Logger.logVerbose(LOG_TAG, "windowRect: " + toRectString(windowRect) + ", windowAvailableRect: " + toRectString(windowAvailableRect)); + Logger.logVerbose(LOG_TAG, "viewsLocationInWindow: " + toPointString(new Point(viewLeft, viewTop))); + Logger.logVerbose(LOG_TAG, "activitySize: " + toPointString(getDisplaySize(context, true)) + + ", displaySize: " + toPointString(getDisplaySize(context, false)) + + ", displayOrientation=" + displayOrientation); + } + + if (isInMultiWindowMode) { + if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) { + // The windowRect.top of the window at the of split screen mode should start right + // below the status bar + if (statusBarHeight != windowRect.top) { + if (view_utils_logging_enabled) + Logger.logVerbose(LOG_TAG, "Window top does not equal statusBarHeight " + statusBarHeight + " in multi-window portrait mode. Window is possibly bottom app in split screen mode. Adding windowRect.top " + windowRect.top + " to viewTop."); + viewTop += windowRect.top; + } else { + if (view_utils_logging_enabled) + Logger.logVerbose(LOG_TAG, "windowRect.top equals statusBarHeight " + statusBarHeight + " in multi-window portrait mode. Window is possibly top app in split screen mode."); + } + + } else if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) { + // If window is on the right in landscape mode of split screen, the viewLeft actually + // starts at windowRect.left instead of 0 returned by getLocationInWindow + viewLeft += windowRect.left; + } + } + + int viewRight = viewLeft + view.getWidth(); + int viewBottom = viewTop + view.getHeight(); + viewRect = new Rect(viewLeft, viewTop, viewRight, viewBottom); + + if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE && viewRight > windowAvailableRect.right) { + if (view_utils_logging_enabled) + Logger.logVerbose(LOG_TAG, "viewRight " + viewRight + " is greater than windowAvailableRect.right " + windowAvailableRect.right + " in landscape mode. Setting windowAvailableRect.right to viewRight since it may not include navbar height."); + windowAvailableRect.right = viewRight; + } + + return new Rect[]{windowAvailableRect, viewRect}; + } + + /** + * Check if {@link Rect} r2 is above r2. An empty rectangle never contains another rectangle. + * + * @param r1 The base rectangle. + * @param r2 The rectangle being tested that should be above. + * @return Returns {@code true} if r2 is above r1. + */ + public static boolean isRectAbove(@NonNull Rect r1, @NonNull Rect r2) { + // check for empty first + return r1.left < r1.right && r1.top < r1.bottom + // now check if above + && r1.left <= r2.left && r1.bottom >= r2.bottom; + } + + /** + * Get device orientation. + * + * Related: https://stackoverflow.com/a/29392593/14686958 + * + * @param context The {@link Context} to check with. + * @return {@link Configuration#ORIENTATION_PORTRAIT} or {@link Configuration#ORIENTATION_LANDSCAPE}. + */ + public static int getDisplayOrientation(@NonNull Context context) { + Point size = getDisplaySize(context, false); + return (size.x < size.y) ? Configuration.ORIENTATION_PORTRAIT : Configuration.ORIENTATION_LANDSCAPE; + } + + /** + * Get device display size. + * + * @param context The {@link Context} to check with. It must be {@link Activity} context, otherwise + * android will throw: + * `java.lang.IllegalArgumentException: Used non-visual Context to obtain an instance of WindowManager. Please use an Activity or a ContextWrapper around one instead.` + * @param activitySize The set to {@link true}, then size returned will be that of the activity + * and can be smaller than physical display size in multi-window mode. + * @return Returns the display size as {@link Point}. + */ + public static Point getDisplaySize( @NonNull Context context, boolean activitySize) { + // android.view.WindowManager.getDefaultDisplay() and Display.getSize() are deprecated in + // API 30 and give wrong values in API 30 for activitySize=false in multi-window + WindowManager windowManager = new WindowManager(context); + WindowMetrics windowMetrics; + if (activitySize) + windowMetrics = windowManager.getCurrentWindowMetrics(); + else + windowMetrics = windowManager.getMaximumWindowMetrics(); + return new Point(windowMetrics.getBounds().width(), windowMetrics.getBounds().height()); + } + + /** Convert {@link Rect} to {@link String}. */ + public static String toRectString(Rect rect) { + if (rect == null) return "null"; + return "(" + rect.left + "," + rect.top + "), (" + rect.right + "," + rect.bottom + ")"; + } + + /** Convert {@link Point} to {@link String}. */ + public static String toPointString(Point point) { + if (point == null) return "null"; + return "(" + point.x + "," + point.y + ")"; + } + + /** Get the {@link Activity} associated with the {@link Context} if available. */ + @Nullable + public static Activity getActivity(Context context) { + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity)context; + } + context = ((ContextWrapper)context).getBaseContext(); + } + return null; + } + + + /** Convert value in device independent pixels (dp) to pixels (px) units. */ + public static float dpToPx(Context context, float dp) { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + } + + /** Convert value in pixels (px) to device independent pixels (dp) units. */ + public static float pxToDp(Context context, float px) { + return px / context.getResources().getDisplayMetrics().density; + } + + + public static void setLayoutMarginsInDp(@NonNull View view, int left, int top, int right, int bottom) { + Context context = view.getContext(); + setLayoutMarginsInPixels(view, (int) dpToPx(context, left), (int) dpToPx(context, top), + (int) dpToPx(context, right), (int) dpToPx(context, bottom)); + } + + public static void setLayoutMarginsInPixels(@NonNull View view, int left, int top, int right, int bottom) { + if (view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + params.setMargins(left, top, right, bottom); + view.setLayoutParams(params); + } + } + +} diff --git a/termux/termux-shared/src/main/res/drawable/ic_copy.xml b/termux/termux-shared/src/main/res/drawable/ic_copy.xml new file mode 100644 index 000000000..0fb13c2d0 --- /dev/null +++ b/termux/termux-shared/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,5 @@ + + + diff --git a/termux/termux-shared/src/main/res/drawable/ic_error_notification.xml b/termux/termux-shared/src/main/res/drawable/ic_error_notification.xml new file mode 100644 index 000000000..67f177125 --- /dev/null +++ b/termux/termux-shared/src/main/res/drawable/ic_error_notification.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/termux/termux-shared/src/main/res/drawable/ic_info.xml b/termux/termux-shared/src/main/res/drawable/ic_info.xml new file mode 100644 index 000000000..654cfb396 --- /dev/null +++ b/termux/termux-shared/src/main/res/drawable/ic_info.xml @@ -0,0 +1,5 @@ + + + diff --git a/termux/termux-shared/src/main/res/drawable/ic_settings.xml b/termux/termux-shared/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..b240b8300 --- /dev/null +++ b/termux/termux-shared/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/termux/termux-shared/src/main/res/drawable/ic_share.xml b/termux/termux-shared/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..9300daf6f --- /dev/null +++ b/termux/termux-shared/src/main/res/drawable/ic_share.xml @@ -0,0 +1,5 @@ + + + diff --git a/termux/termux-shared/src/main/res/layout/activity_report.xml b/termux/termux-shared/src/main/res/layout/activity_report.xml new file mode 100644 index 000000000..7df43a974 --- /dev/null +++ b/termux/termux-shared/src/main/res/layout/activity_report.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/termux/termux-shared/src/main/res/layout/activity_text_io.xml b/termux/termux-shared/src/main/res/layout/activity_text_io.xml new file mode 100644 index 000000000..de0f4fc86 --- /dev/null +++ b/termux/termux-shared/src/main/res/layout/activity_text_io.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/termux/termux-shared/src/main/res/layout/dialog_show_message.xml b/termux/termux-shared/src/main/res/layout/dialog_show_message.xml new file mode 100644 index 000000000..f1c137c70 --- /dev/null +++ b/termux/termux-shared/src/main/res/layout/dialog_show_message.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/termux/termux-shared/src/main/res/layout/markdown_adapter_node_code_block.xml b/termux/termux-shared/src/main/res/layout/markdown_adapter_node_code_block.xml new file mode 100644 index 000000000..06105ff58 --- /dev/null +++ b/termux/termux-shared/src/main/res/layout/markdown_adapter_node_code_block.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/termux/termux-shared/src/main/res/layout/markdown_adapter_node_default.xml b/termux/termux-shared/src/main/res/layout/markdown_adapter_node_default.xml new file mode 100644 index 000000000..37b583000 --- /dev/null +++ b/termux/termux-shared/src/main/res/layout/markdown_adapter_node_default.xml @@ -0,0 +1,16 @@ + + diff --git a/termux/termux-shared/src/main/res/layout/partial_primary_toolbar.xml b/termux/termux-shared/src/main/res/layout/partial_primary_toolbar.xml new file mode 100644 index 000000000..a3f1be50d --- /dev/null +++ b/termux/termux-shared/src/main/res/layout/partial_primary_toolbar.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/termux/termux-shared/src/main/res/menu/menu_report.xml b/termux/termux-shared/src/main/res/menu/menu_report.xml new file mode 100644 index 000000000..f1faa1df7 --- /dev/null +++ b/termux/termux-shared/src/main/res/menu/menu_report.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/termux/termux-shared/src/main/res/menu/menu_text_io.xml b/termux/termux-shared/src/main/res/menu/menu_text_io.xml new file mode 100644 index 000000000..192ddad0f --- /dev/null +++ b/termux/termux-shared/src/main/res/menu/menu_text_io.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/termux/termux-shared/src/main/res/raw/apt_info_script.sh b/termux/termux-shared/src/main/res/raw/apt_info_script.sh new file mode 100644 index 000000000..4812c1003 --- /dev/null +++ b/termux/termux-shared/src/main/res/raw/apt_info_script.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +subscribed_repositories() { + local main_sources + main_sources=$(grep -P '^\s*deb\s' "@TERMUX_PREFIX@/etc/apt/sources.list") + + if [ -n "$main_sources" ]; then + echo "#### sources.list" + echo "\`$main_sources\`" + fi + + local filename repo_package supl_sources + while read -r filename; do + repo_package=$(dpkg -S "$filename" 2>/dev/null | cut -d : -f 1) + supl_sources=$(grep -P '^\s*deb\s' "$filename") + + if [ -n "$supl_sources" ]; then + if [ -n "$repo_package" ]; then + echo "#### $repo_package (sources.list.d/$(basename "$filename"))" + else + echo "#### sources.list.d/$(basename "$filename")" + fi + echo "\`$supl_sources\` " + fi + done < <(find "@TERMUX_PREFIX@/etc/apt/sources.list.d" -maxdepth 1 ! -type d) +} + +updatable_packages() { + local updatable + + if [ "$(id -u)" = "0" ]; then + echo "Running as root. Cannot check updatable packages." + else + apt update >/dev/null 2>&1 + updatable=$(apt list --upgradable 2>/dev/null | tail -n +2) + + if [ -z "$updatable" ];then + echo "All packages up to date" + else + echo $'```\n'"$updatable"$'\n```\n' + fi + fi +} + +output=" +### Subscribed Repositories + +$(subscribed_repositories) +## + + +### Updatable Packages + +$(updatable_packages) +## + +" + +echo "$output" diff --git a/termux/termux-shared/src/main/res/raw/bell.ogg b/termux/termux-shared/src/main/res/raw/bell.ogg new file mode 100644 index 000000000..674f25d02 Binary files /dev/null and b/termux/termux-shared/src/main/res/raw/bell.ogg differ diff --git a/termux/termux-shared/src/main/res/raw/keep.xml b/termux/termux-shared/src/main/res/raw/keep.xml new file mode 100644 index 000000000..7b29406ce --- /dev/null +++ b/termux/termux-shared/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/termux/termux-view/.gitignore b/termux/termux-view/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/termux/termux-view/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/termux/termux-view/build.gradle.kts b/termux/termux-view/build.gradle.kts new file mode 100644 index 000000000..84adc7146 --- /dev/null +++ b/termux/termux-view/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + namespace = "com.termux.view" + ndkVersion = BuildConfig.ndkVersion +} + +dependencies { + implementation(libs.androidx.annotation) + implementation(projects.resources) + api(projects.termux.termuxEmulator) + + testImplementation(projects.testing.unit) +} \ No newline at end of file diff --git a/termux/termux-view/proguard-rules.pro b/termux/termux-view/proguard-rules.pro new file mode 100644 index 000000000..2e56e600a --- /dev/null +++ b/termux/termux-view/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/fornwall/lib/android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/termux/termux-view/src/main/AndroidManifest.xml b/termux/termux-view/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/termux/termux-view/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/termux/termux-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java b/termux/termux-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java new file mode 100644 index 000000000..f7fc9d237 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java @@ -0,0 +1,112 @@ +package com.termux.view; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; + +/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */ +final class GestureAndScaleRecognizer { + + public interface Listener { + boolean onSingleTapUp(MotionEvent e); + + boolean onDoubleTap(MotionEvent e); + + boolean onScroll(MotionEvent e2, float dx, float dy); + + boolean onFling(MotionEvent e, float velocityX, float velocityY); + + boolean onScale(float focusX, float focusY, float scale); + + boolean onDown(float x, float y); + + boolean onUp(MotionEvent e); + + void onLongPress(MotionEvent e); + } + + private final GestureDetector mGestureDetector; + private final ScaleGestureDetector mScaleDetector; + final Listener mListener; + boolean isAfterLongPress; + + public GestureAndScaleRecognizer(Context context, Listener listener) { + mListener = listener; + + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) { + return mListener.onScroll(e2, dx, dy); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return mListener.onFling(e2, velocityX, velocityY); + } + + @Override + public boolean onDown(MotionEvent e) { + return mListener.onDown(e.getX(), e.getY()); + } + + @Override + public void onLongPress(MotionEvent e) { + mListener.onLongPress(e); + isAfterLongPress = true; + } + }, null, true /* ignoreMultitouch */); + + mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return mListener.onSingleTapUp(e); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + return mListener.onDoubleTap(e); + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return true; + } + }); + + mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor()); + } + }); + mScaleDetector.setQuickScaleEnabled(false); + } + + public void onTouchEvent(MotionEvent event) { + mGestureDetector.onTouchEvent(event); + mScaleDetector.onTouchEvent(event); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isAfterLongPress = false; + break; + case MotionEvent.ACTION_UP: + if (!isAfterLongPress) { + // This behaviour is desired when in e.g. vim with mouse events, where we do not + // want to move the cursor when lifting finger after a long press. + mListener.onUp(event); + } + break; + } + } + + public boolean isInProgress() { + return mScaleDetector.isInProgress(); + } + +} diff --git a/termux/termux-view/src/main/java/com/termux/view/TerminalRenderer.java b/termux/termux-view/src/main/java/com/termux/view/TerminalRenderer.java new file mode 100644 index 000000000..307e42269 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -0,0 +1,249 @@ +package com.termux.view; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Typeface; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalRow; +import com.termux.terminal.TextStyle; +import com.termux.terminal.WcWidth; + +/** + * Renderer of a {@link TerminalEmulator} into a {@link Canvas}. + *

+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes. + */ +public final class TerminalRenderer { + + final int mTextSize; + final Typeface mTypeface; + private final Paint mTextPaint = new Paint(); + + /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ + final float mFontWidth; + /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + final int mFontLineSpacing; + /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + private final int mFontAscent; + /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */ + final int mFontLineSpacingAndAscent; + + private final float[] asciiMeasures = new float[127]; + + public TerminalRenderer(int textSize, Typeface typeface) { + mTextSize = textSize; + mTypeface = typeface; + + mTextPaint.setTypeface(typeface); + mTextPaint.setAntiAlias(true); + mTextPaint.setTextSize(textSize); + + mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing()); + mFontAscent = (int) Math.ceil(mTextPaint.ascent()); + mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent; + mFontWidth = mTextPaint.measureText("X"); + + StringBuilder sb = new StringBuilder(" "); + for (int i = 0; i < asciiMeasures.length; i++) { + sb.setCharAt(0, (char) i); + asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1); + } + } + + /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */ + public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, + int selectionY1, int selectionY2, int selectionX1, int selectionX2) { + final boolean reverseVideo = mEmulator.isReverseVideo(); + final int endRow = topRow + mEmulator.mRows; + final int columns = mEmulator.mColumns; + final int cursorCol = mEmulator.getCursorCol(); + final int cursorRow = mEmulator.getCursorRow(); + final boolean cursorVisible = mEmulator.shouldCursorBeVisible(); + final TerminalBuffer screen = mEmulator.getScreen(); + final int[] palette = mEmulator.mColors.mCurrentColors; + final int cursorShape = mEmulator.getCursorStyle(); + + if (reverseVideo) + canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); + + float heightOffset = mFontLineSpacingAndAscent; + for (int row = topRow; row < endRow; row++) { + heightOffset += mFontLineSpacing; + + final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1; + int selx1 = -1, selx2 = -1; + if (row >= selectionY1 && row <= selectionY2) { + if (row == selectionY1) selx1 = selectionX1; + selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns; + } + + TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); + final char[] line = lineObject.mText; + final int charsUsedInLine = lineObject.getSpaceUsed(); + + long lastRunStyle = 0; + boolean lastRunInsideCursor = false; + boolean lastRunInsideSelection = false; + int lastRunStartColumn = -1; + int lastRunStartIndex = 0; + boolean lastRunFontWidthMismatch = false; + int currentCharIndex = 0; + float measuredWidthForRun = 0.f; + + for (int column = 0; column < columns; ) { + final char charAtIndex = line[currentCharIndex]; + final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); + final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; + final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; + final int codePointWcWidth = WcWidth.width(codePoint); + final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); + final boolean insideSelection = column >= selx1 && column <= selx2; + final long style = lineObject.getStyle(column); + + // Check if the measured text width for this code point is not the same as that expected by wcwidth(). + // This could happen for some fonts which are not truly monospace, or for more exotic characters such as + // smileys which android font renders as wide. + // If this is detected, we draw this code point scaled to match what wcwidth() expects. + final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line, + currentCharIndex, charsForCodePoint); + final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; + + if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { + if (column == 0) { + // Skip first column as there is nothing to draw, just record the current style. + } else { + final int columnWidthSinceLastRun = column - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, + lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, + cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + measuredWidthForRun = 0.f; + lastRunStyle = style; + lastRunInsideCursor = insideCursor; + lastRunInsideSelection = insideSelection; + lastRunStartColumn = column; + lastRunStartIndex = currentCharIndex; + lastRunFontWidthMismatch = fontWidthMismatch; + } + measuredWidthForRun += measuredCodePointWidth; + column += codePointWcWidth; + currentCharIndex += charsForCodePoint; + while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) { + // Eat combining chars so that they are treated as part of the last non-combining code point, + // instead of e.g. being considered inside the cursor in the next run. + currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1; + } + } + + final int columnWidthSinceLastRun = columns - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, + measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + } + + private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, + int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, + long textStyle, boolean reverseVideo) { + int foreColor = TextStyle.decodeForeColor(textStyle); + final int effect = TextStyle.decodeEffect(textStyle); + int backColor = TextStyle.decodeBackColor(textStyle); + final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0; + final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; + final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0; + final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0; + final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; + + if ((foreColor & 0xff000000) != 0xff000000) { + // Let bold have bright colors if applicable (one of the first 8): + if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8; + foreColor = palette[foreColor]; + } + + if ((backColor & 0xff000000) != 0xff000000) { + backColor = palette[backColor]; + } + + // Reverse video here if _one and only one_ of the reverse flags are set: + final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0; + if (reverseVideoHere) { + int tmp = foreColor; + foreColor = backColor; + backColor = tmp; + } + + float left = startColumn * mFontWidth; + float right = left + runWidthColumns * mFontWidth; + + mes = mes / mFontWidth; + boolean savedMatrix = false; + if (Math.abs(mes - runWidthColumns) > 0.01) { + canvas.save(); + canvas.scale(runWidthColumns / mes, 1.f); + left *= mes / runWidthColumns; + right *= mes / runWidthColumns; + savedMatrix = true; + } + + if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) { + // Only draw non-default background. + mTextPaint.setColor(backColor); + canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint); + } + + if (cursor != 0) { + mTextPaint.setColor(cursor); + float cursorHeight = mFontLineSpacingAndAscent - mFontAscent; + if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.; + else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.; + canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint); + } + + if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) { + if (dim) { + int red = (0xFF & (foreColor >> 16)); + int green = (0xFF & (foreColor >> 8)); + int blue = (0xFF & foreColor); + // Dim color handling used by libvte which in turn took it from xterm + // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267): + red = red * 2 / 3; + green = green * 2 / 3; + blue = blue * 2 / 3; + foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue; + } + + mTextPaint.setFakeBoldText(bold); + mTextPaint.setUnderlineText(underline); + mTextPaint.setTextSkewX(italic ? -0.35f : 0.f); + mTextPaint.setStrikeThruText(strikeThrough); + mTextPaint.setColor(foreColor); + + // The text alignment is the default Paint.Align.LEFT. + canvas.drawText(text, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, mTextPaint); + } + + if (savedMatrix) canvas.restore(); + } + + public float getFontWidth() { + return mFontWidth; + } + + public int getFontLineSpacing() { + return mFontLineSpacing; + } +} diff --git a/termux/termux-view/src/main/java/com/termux/view/TerminalView.java b/termux/termux-view/src/main/java/com/termux/view/TerminalView.java new file mode 100644 index 000000000..586cd6339 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/TerminalView.java @@ -0,0 +1,1362 @@ +package com.termux.view; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.HapticFeedbackConstants; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityManager; +import android.view.autofill.AutofillValue; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Scroller; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.view.textselection.TextSelectionCursorController; + +/** View displaying and interacting with a {@link TerminalSession}. */ +public final class TerminalView extends View { + + /** Log terminal view key and IME events. */ + private static boolean TERMINAL_VIEW_KEY_LOGGING_ENABLED = false; + + /** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */ + public TerminalSession mTermSession; + /** Our terminal emulator whose session is {@link #mTermSession}. */ + public TerminalEmulator mEmulator; + + public TerminalRenderer mRenderer; + + public TerminalViewClient mClient; + + private TextSelectionCursorController mTextSelectionCursorController; + + private Handler mTerminalCursorBlinkerHandler; + private TerminalCursorBlinkerRunnable mTerminalCursorBlinkerRunnable; + private int mTerminalCursorBlinkerRate; + private boolean mCursorInvisibleIgnoreOnce; + public static final int TERMINAL_CURSOR_BLINK_RATE_MIN = 100; + public static final int TERMINAL_CURSOR_BLINK_RATE_MAX = 2000; + + /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */ + int mTopRow; + int[] mDefaultSelectors = new int[]{-1,-1,-1,-1}; + + float mScaleFactor = 1.f; + final GestureAndScaleRecognizer mGestureRecognizer; + + /** Keep track of where mouse touch event started which we report as mouse scroll. */ + private int mMouseScrollStartX = -1, mMouseScrollStartY = -1; + /** Keep track of the time when a touch event leading to sending mouse scroll events started. */ + private long mMouseStartDownTime = -1; + + final Scroller mScroller; + + /** What was left in from scrolling movement. */ + float mScrollRemainder; + + /** If non-zero, this is the last unicode code point received if that was a combining character. */ + int mCombiningAccent; + + private final boolean mAccessibilityEnabled; + + /** The {@link KeyEvent} is generated from a virtual keyboard, like manually with the {@link KeyEvent#KeyEvent(int, int)} constructor. */ + public final static int KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD = KeyCharacterMap.VIRTUAL_KEYBOARD; // -1 + + /** The {@link KeyEvent} is generated from a non-physical device, like if 0 value is returned by {@link KeyEvent#getDeviceId()}. */ + public final static int KEY_EVENT_SOURCE_SOFT_KEYBOARD = 0; + + private static final String LOG_TAG = "TerminalView"; + + public TerminalView(Context context, AttributeSet attributes) { // NO_UCD (unused code) + super(context, attributes); + mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() { + + boolean scrolledWithFinger; + + @Override + public boolean onUp(MotionEvent event) { + mScrollRemainder = 0.0f; + if (mEmulator != null && mEmulator.isMouseTrackingActive() && !event.isFromSource(InputDevice.SOURCE_MOUSE) && !isSelectingText() && !scrolledWithFinger) { + // Quick event processing when mouse tracking is active - do not wait for check of double tapping + // for zooming. + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, true); + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, false); + return true; + } + scrolledWithFinger = false; + return false; + } + + @Override + public boolean onSingleTapUp(MotionEvent event) { + if (mEmulator == null) return true; + + if (isSelectingText()) { + stopTextSelectionMode(); + return true; + } + requestFocus(); + mClient.onSingleTapUp(event); + return true; + } + + @Override + public boolean onScroll(MotionEvent e, float distanceX, float distanceY) { + if (mEmulator == null) return true; + if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) { + // If moving with mouse pointer while pressing button, report that instead of scroll. + // This means that we never report moving with button press-events for touch input, + // since we cannot just start sending these events without a starting press event, + // which we do not do for touch input, only mouse in onTouchEvent(). + sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + } else { + scrolledWithFinger = true; + distanceY += mScrollRemainder; + int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing); + mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing; + doScroll(e, deltaRows); + } + return true; + } + + @Override + public boolean onScale(float focusX, float focusY, float scale) { + if (mEmulator == null || isSelectingText()) return true; + mScaleFactor *= scale; + mScaleFactor = mClient.onScale(mScaleFactor); + return true; + } + + @Override + public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) { + if (mEmulator == null) return true; + // Do not start scrolling until last fling has been taken care of: + if (!mScroller.isFinished()) return true; + + final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive(); + float SCALE = 0.25f; + if (mouseTrackingAtStartOfFling) { + mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2); + } else { + mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0); + } + + post(new Runnable() { + private int mLastY = 0; + + @Override + public void run() { + if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) { + mScroller.abortAnimation(); + return; + } + if (mScroller.isFinished()) return; + boolean more = mScroller.computeScrollOffset(); + int newY = mScroller.getCurrY(); + int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow); + doScroll(e2, diff); + mLastY = newY; + if (more) post(this); + } + }); + + return true; + } + + @Override + public boolean onDown(float x, float y) { + // Why is true not returned here? + // https://developer.android.com/training/gestures/detector.html#detect-a-subset-of-supported-gestures + // Although setting this to true still does not solve the following errors when long pressing in terminal view text area + // ViewDragHelper: Ignoring pointerId=0 because ACTION_DOWN was not received for this pointer before ACTION_MOVE + // Commenting out the call to mGestureDetector.onTouchEvent(event) in GestureAndScaleRecognizer#onTouchEvent() removes + // the error logging, so issue is related to GestureDetector + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent event) { + // Do not treat is as a single confirmed tap - it may be followed by zoom. + return false; + } + + @Override + public void onLongPress(MotionEvent event) { + if (mGestureRecognizer.isInProgress()) return; + if (mClient.onLongPress(event)) return; + if (!isSelectingText()) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + startTextSelectionMode(event); + } + } + }); + mScroller = new Scroller(context); + AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + mAccessibilityEnabled = am.isEnabled(); + } + + + + /** + * @param client The {@link TerminalViewClient} interface implementation to allow + * for communication between {@link TerminalView} and its client. + */ + public void setTerminalViewClient(TerminalViewClient client) { + this.mClient = client; + } + + /** + * Sets whether terminal view key logging is enabled or not. + * + * @param value The boolean value that defines the state. + */ + public void setIsTerminalViewKeyLoggingEnabled(boolean value) { + TERMINAL_VIEW_KEY_LOGGING_ENABLED = value; + } + + + + /** + * Attach a {@link TerminalSession} to this view. + * + * @param session The {@link TerminalSession} this view will be displaying. + */ + public boolean attachSession(TerminalSession session) { + if (session == mTermSession) return false; + mTopRow = 0; + + mTermSession = session; + mEmulator = null; + mCombiningAccent = 0; + + updateSize(); + + // Wait with enabling the scrollbar until we have a terminal to get scroll position from. + setVerticalScrollBarEnabled(true); + + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // Ensure that inputType is only set if TerminalView is selected view with the keyboard and + // an alternate view is not selected, like an EditText. This is necessary if an activity is + // initially started with the alternate view or if activity is returned to from another app + // and the alternate view was the one selected the last time. + if (mClient.isTerminalViewSelected()) { + if (mClient.shouldEnforceCharBasedInput()) { + // Some keyboards seems do not reset the internal state on TYPE_NULL. + // Affects mostly Samsung stock keyboards. + // https://github.com/termux/termux-app/issues/686 + // However, this is not a valid value as per AOSP since `InputType.TYPE_CLASS_*` is + // not set and it logs a warning: + // W/InputAttributes: Unexpected input class: inputType=0x00080090 imeOptions=0x02000000 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/inputmethods/LatinIME/java/src/com/android/inputmethod/latin/InputAttributes.java;l=79 + outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } else { + // Using InputType.NULL is the most correct input type and avoids issues with other hacks. + // + // Previous keyboard issues: + // https://github.com/termux/termux-packages/issues/25 + // https://github.com/termux/termux-app/issues/87. + // https://github.com/termux/termux-app/issues/126. + // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL). + outAttrs.inputType = InputType.TYPE_NULL; + } + } else { + // Corresponds to android:inputType="text" + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL; + } + + // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen + // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221). + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; + + return new BaseInputConnection(this, true) { + + @Override + public boolean finishComposingText() { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "IME: finishComposingText()"); + super.finishComposingText(); + + sendTextToTerminal(getEditable()); + getEditable().clear(); + return true; + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")"); + } + super.commitText(text, newCursorPosition); + + if (mEmulator == null) return true; + + Editable content = getEditable(); + sendTextToTerminal(content); + content.clear(); + return true; + } + + @Override + public boolean deleteSurroundingText(int leftLength, int rightLength) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")"); + } + // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1. + KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); + for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey); + return super.deleteSurroundingText(leftLength, rightLength); + } + + void sendTextToTerminal(CharSequence text) { + stopTextSelectionMode(); + final int textLengthInChars = text.length(); + for (int i = 0; i < textLengthInChars; i++) { + char firstChar = text.charAt(i); + int codePoint; + if (Character.isHighSurrogate(firstChar)) { + if (++i < textLengthInChars) { + codePoint = Character.toCodePoint(firstChar, text.charAt(i)); + } else { + // At end of string, with no low surrogate following the high: + codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR; + } + } else { + codePoint = firstChar; + } + + // Check onKeyDown() for details. + if (mClient.readShiftKey()) + codePoint = Character.toUpperCase(codePoint); + + boolean ctrlHeld = false; + if (codePoint <= 31 && codePoint != 27) { + if (codePoint == '\n') { + // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed, + // instead of a key event like most other keyboard apps. A terminal expects \r for the enter + // key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to + // check the behaviour). + codePoint = '\r'; + } + + // E.g. penti keyboard for ctrl input. + ctrlHeld = true; + switch (codePoint) { + case 31: + codePoint = '_'; + break; + case 30: + codePoint = '^'; + break; + case 29: + codePoint = ']'; + break; + case 28: + codePoint = '\\'; + break; + default: + codePoint += 96; + break; + } + } + + inputCodePoint(KEY_EVENT_SOURCE_SOFT_KEYBOARD, codePoint, ctrlHeld, false); + } + } + + }; + } + + @Override + protected int computeVerticalScrollRange() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows(); + } + + @Override + protected int computeVerticalScrollExtent() { + return mEmulator == null ? 1 : mEmulator.mRows; + } + + @Override + protected int computeVerticalScrollOffset() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows; + } + + public void onScreenUpdated() { + onScreenUpdated(false); + } + + public void onScreenUpdated(boolean skipScrolling) { + if (mEmulator == null) return; + + int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows(); + if (mTopRow < -rowsInHistory) mTopRow = -rowsInHistory; + + if (isSelectingText() || mEmulator.isAutoScrollDisabled()) { + + // Do not scroll when selecting text. + int rowShift = mEmulator.getScrollCounter(); + if (-mTopRow + rowShift > rowsInHistory) { + // .. unless we're hitting the end of history transcript, in which + // case we abort text selection and scroll to end. + if (isSelectingText()) + stopTextSelectionMode(); + + if (mEmulator.isAutoScrollDisabled()) { + mTopRow = -rowsInHistory; + skipScrolling = true; + } + } else { + skipScrolling = true; + mTopRow -= rowShift; + decrementYTextSelectionCursors(rowShift); + } + } + + if (!skipScrolling && mTopRow != 0) { + // Scroll down if not already there. + if (mTopRow < -3) { + // Awaken scroll bars only if scrolling a noticeable amount + // - we do not want visible scroll bars during normal typing + // of one row at a time. + awakenScrollBars(); + } + mTopRow = 0; + } + + mEmulator.clearScrollCounter(); + + invalidate(); + if (mAccessibilityEnabled) setContentDescription(getText()); + } + + /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)} + * when context menu for the {@link TerminalView} is started by + * {@link TextSelectionCursorController#ACTION_MORE} is closed. */ + public void onContextMenuClosed(Menu menu) { + // Unset the stored text since it shouldn't be used anymore and should be cleared from memory + unsetStoredSelectedText(); + } + + /** + * Sets the text size, which in turn sets the number of rows and columns. + * + * @param textSize the new font size, in density-independent pixels. + */ + public void setTextSize(int textSize) { + mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface); + updateSize(); + } + + public void setTypeface(Typeface newTypeface) { + mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface); + updateSize(); + invalidate(); + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean isOpaque() { + return true; + } + + /** + * Get the zero indexed column and row of the terminal view for the + * position of the event. + * + * @param event The event with the position to get the column and row for. + * @param relativeToScroll If true the column number will take the scroll + * position into account. E.g. if scrolled 3 lines up and the event + * position is in the top left, column will be -3 if relativeToScroll is + * true and 0 if relativeToScroll is false. + * @return Array with the column and row. + */ + public int[] getColumnAndRow(MotionEvent event, boolean relativeToScroll) { + int column = (int) (event.getX() / mRenderer.mFontWidth); + int row = (int) ((event.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + if (relativeToScroll) { + row += mTopRow; + } + return new int[] { column, row }; + } + + /** Send a single mouse event code to the terminal. */ + void sendMouseEventCode(MotionEvent e, int button, boolean pressed) { + int[] columnAndRow = getColumnAndRow(e, false); + int x = columnAndRow[0] + 1; + int y = columnAndRow[1] + 1; + if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) { + if (mMouseStartDownTime == e.getDownTime()) { + x = mMouseScrollStartX; + y = mMouseScrollStartY; + } else { + mMouseStartDownTime = e.getDownTime(); + mMouseScrollStartX = x; + mMouseScrollStartY = y; + } + } + mEmulator.sendMouseEvent(button, x, y, pressed); + } + + /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */ + void doScroll(MotionEvent event, int rowsDown) { + boolean up = rowsDown < 0; + int amount = Math.abs(rowsDown); + for (int i = 0; i < amount; i++) { + if (mEmulator.isMouseTrackingActive()) { + sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true); + } else if (mEmulator.isAlternateBufferActive()) { + // Send up and down key events for scrolling, which is what some terminals do to make scroll work in + // e.g. less, which shifts to the alt screen without mouse handling. + handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0); + } else { + mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1))); + if (!awakenScrollBars()) invalidate(); + } + } + } + + /** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */ + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) { + // Handle mouse wheel scrolling. + boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f; + doScroll(event, up ? -3 : 3); + return true; + } + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + @TargetApi(23) + public boolean onTouchEvent(MotionEvent event) { + if (mEmulator == null) return true; + final int action = event.getAction(); + + if (isSelectingText()) { + updateFloatingToolbarVisibility(event); + mGestureRecognizer.onTouchEvent(event); + return true; + } else if (event.isFromSource(InputDevice.SOURCE_MOUSE)) { + if (event.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) { + if (action == MotionEvent.ACTION_DOWN) showContextMenu(); + return true; + } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) { + ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData != null) { + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem != null) { + CharSequence text = clipItem.coerceToText(getContext()); + if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString()); + } + } + } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY. + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_UP: + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, event.getAction() == MotionEvent.ACTION_DOWN); + break; + case MotionEvent.ACTION_MOVE: + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + break; + } + } + } + + mGestureRecognizer.onTouchEvent(event); + return true; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")"); + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (isSelectingText()) { + stopTextSelectionMode(); + return true; + } else if (mClient.shouldBackButtonBeMappedToEscape()) { + // Intercept back button to treat it as escape: + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + return onKeyDown(keyCode, event); + case KeyEvent.ACTION_UP: + return onKeyUp(keyCode, event); + } + } + } else if (mClient.shouldUseCtrlSpaceWorkaround() && + keyCode == KeyEvent.KEYCODE_SPACE && event.isCtrlPressed()) { + /* ctrl+space does not work on some ROMs without this workaround. + However, this breaks it on devices where it works out of the box. */ + return onKeyDown(keyCode, event); + } + return super.onKeyPreIme(keyCode, event); + } + + /** + * Key presses in software keyboards will generally NOT trigger this listener, although some + * may elect to do so in some situations. Do not rely on this to catch software key presses. + * Gboard calls this when shouldEnforceCharBasedInput() is disabled (InputType.TYPE_NULL) instead + * of calling commitText(), with deviceId=-1. However, Hacker's Keyboard, OpenBoard, LG Keyboard + * call commitText(). + * + * This function may also be called directly without android calling it, like by + * `TerminalExtraKeys` which generates a KeyEvent manually which uses {@link KeyCharacterMap#VIRTUAL_KEYBOARD} + * as the device (deviceId=-1), as does Gboard. That would normally use mappings defined in + * `/system/usr/keychars/Virtual.kcm`. You can run `dumpsys input` to find the `KeyCharacterMapFile` + * used by virtual keyboard or hardware keyboard. Note that virtual keyboard device is not the + * same as software keyboard, like Gboard, etc. Its a fake device used for generating events and + * for testing. + * + * We handle shift key in `commitText()` to convert codepoint to uppercase case there with a + * call to {@link Character#toUpperCase(int)}, but here we instead rely on getUnicodeChar() for + * conversion of keyCode, for both hardware keyboard shift key (via effectiveMetaState) and + * `mClient.readShiftKey()`, based on value in kcm files. + * This may result in different behaviour depending on keyboard and android kcm files set for the + * InputDevice for the event passed to this function. This will likely be an issue for non-english + * languages since `Virtual.kcm` in english only by default or at least in AOSP. For both hardware + * shift key (via effectiveMetaState) and `mClient.readShiftKey()`, `getUnicodeChar()` is used + * for shift specific behaviour which usually is to uppercase. + * + * For fn key on hardware keyboard, android checks kcm files for hardware keyboards, which is + * `Generic.kcm` by default, unless a vendor specific one is defined. The event passed will have + * {@link KeyEvent#META_FUNCTION_ON} set. If the kcm file only defines a single character or unicode + * code point `\\uxxxx`, then only one event is passed with that value. However, if kcm defines + * a `fallback` key for fn or others, like `key DPAD_UP { ... fn: fallback PAGE_UP }`, then + * android will first pass an event with original key `DPAD_UP` and {@link KeyEvent#META_FUNCTION_ON} + * set. But this function will not consume it and android will pass another event with `PAGE_UP` + * and {@link KeyEvent#META_FUNCTION_ON} not set, which will be consumed. + * + * Now there are some other issues as well, firstly ctrl and alt flags are not passed to + * `getUnicodeChar()`, so modified key values in kcm are not used. Secondly, if the kcm file + * for other modifiers like shift or fn define a non-alphabet, like { fn: '\u0015' } to act as + * DPAD_LEFT, the `getUnicodeChar()` will correctly return `21` as the code point but action will + * not happen because the `handleKeyCode()` function that transforms DPAD_LEFT to `\033[D` + * escape sequence for the terminal to perform the left action would not be called since its + * called before `getUnicodeChar()` and terminal will instead get `21 0x15 Negative Acknowledgement`. + * The solution to such issues is calling `getUnicodeChar()` before the call to `handleKeyCode()` + * if user has defined a custom kcm file, like done in POC mentioned in #2237. Note that + * Hacker's Keyboard calls `commitText()` so don't test fn/shift with it for this function. + * https://github.com/termux/termux-app/pull/2237 + * https://github.com/agnostic-apollo/termux-app/blob/terminal-code-point-custom-mapping/terminal-view/src/main/java/com/termux/view/TerminalView.java + * + * Key Character Map (kcm) and Key Layout (kl) files info: + * https://source.android.com/devices/input/key-character-map-files + * https://source.android.com/devices/input/key-layout-files + * https://source.android.com/devices/input/keyboard-devices + * AOSP kcm and kl files: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/data/keyboards + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/packages/InputDevices/res/raw + * + * KeyCodes: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java + * https://cs.android.com/android/platform/superproject/+/master:frameworks/native/include/android/keycodes.h + * + * `dumpsys input`: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1917 + * + * Loading of keymap: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1644 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/Keyboard.cpp;l=41 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/InputDevice.cpp + * OVERLAY keymaps for hardware keyboards may be combined as well: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=165 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=831 + * + * Parse kcm file: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=727 + * Parse key value: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=981 + * + * `KeyEvent.getUnicodeChar()` + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java;l=2716 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/KeyCharacterMap.java;l=368 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/jni/android_view_KeyCharacterMap.cpp;l=117 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=231 + * + * Keyboard layouts advertised by applications, like for hardware keyboards via #ACTION_QUERY_KEYBOARD_LAYOUTS + * Config is stored in `/data/system/input-manager-state.xml` + * https://github.com/ris58h/custom-keyboard-layout + * Loading from apps: + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1221 + * Set: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=89 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=543 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/apps/Settings/src/com/android/settings/inputmethod/KeyboardLayoutDialogFragment.java;l=167 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1385 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/PersistentDataStore.java + * Get overlay keyboard layout + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=2158 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp;l=616 + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")"); + if (mEmulator == null) return true; + if (isSelectingText()) { + stopTextSelectionMode(); + } + + if (mClient.onKeyDown(keyCode, event, mTermSession)) { + invalidate(); + return true; + } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) { + return super.onKeyDown(keyCode, event); + } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) { + mTermSession.write(event.getCharacters()); + return true; + } + + final int metaState = event.getMetaState(); + final boolean controlDown = event.isCtrlPressed() || mClient.readControlKey(); + final boolean leftAltDown = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0 || mClient.readAltKey(); + final boolean shiftDown = event.isShiftPressed() || mClient.readShiftKey(); + final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0; + + int keyMod = 0; + if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL; + if (event.isAltPressed() || leftAltDown) keyMod |= KeyHandler.KEYMOD_ALT; + if (shiftDown) keyMod |= KeyHandler.KEYMOD_SHIFT; + if (event.isNumLockOn()) keyMod |= KeyHandler.KEYMOD_NUM_LOCK; + // https://github.com/termux/termux-app/issues/731 + if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event"); + return true; + } + + // Clear Ctrl since we handle that ourselves: + int bitsToClear = KeyEvent.META_CTRL_MASK; + if (rightAltDownFromEvent) { + // Let right Alt/Alt Gr be used to compose characters. + } else { + // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove: + bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; + } + int effectiveMetaState = event.getMetaState() & ~bitsToClear; + + if (shiftDown) effectiveMetaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON; + if (mClient.readFnKey()) effectiveMetaState |= KeyEvent.META_FUNCTION_ON; + + int result = event.getUnicodeChar(effectiveMetaState); + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result); + if (result == 0) { + return false; + } + + int oldCombiningAccent = mCombiningAccent; + if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) { + // If entered combining accent previously, write it out: + if (mCombiningAccent != 0) + inputCodePoint(event.getDeviceId(), mCombiningAccent, controlDown, leftAltDown); + mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK; + } else { + if (mCombiningAccent != 0) { + int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result); + if (combinedChar > 0) result = combinedChar; + mCombiningAccent = 0; + } + inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown); + } + + if (mCombiningAccent != oldCombiningAccent) invalidate(); + + return true; + } + + public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "inputCodePoint(eventSource=" + eventSource + ", codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + + leftAltDownFromEvent + ")"); + } + + if (mTermSession == null) return; + + // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys + if (mEmulator != null) + mEmulator.setCursorBlinkState(true); + + final boolean controlDown = controlDownFromEvent || mClient.readControlKey(); + final boolean altDown = leftAltDownFromEvent || mClient.readAltKey(); + + if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return; + + if (controlDown) { + if (codePoint >= 'a' && codePoint <= 'z') { + codePoint = codePoint - 'a' + 1; + } else if (codePoint >= 'A' && codePoint <= 'Z') { + codePoint = codePoint - 'A' + 1; + } else if (codePoint == ' ' || codePoint == '2') { + codePoint = 0; + } else if (codePoint == '[' || codePoint == '3') { + codePoint = 27; // ^[ (Esc) + } else if (codePoint == '\\' || codePoint == '4') { + codePoint = 28; + } else if (codePoint == ']' || codePoint == '5') { + codePoint = 29; + } else if (codePoint == '^' || codePoint == '6') { + codePoint = 30; // control-^ + } else if (codePoint == '_' || codePoint == '7' || codePoint == '/') { + // "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102" + // - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal + codePoint = 31; + } else if (codePoint == '8') { + codePoint = 127; // DEL + } + } + + if (codePoint > -1) { + // If not virtual or soft keyboard. + if (eventSource > KEY_EVENT_SOURCE_SOFT_KEYBOARD) { + // Work around bluetooth keyboards sending funny unicode characters instead + // of the more normal ones from ASCII that terminal programs expect - the + // desire to input the original characters should be low. + switch (codePoint) { + case 0x02DC: // SMALL TILDE. + codePoint = 0x007E; // TILDE (~). + break; + case 0x02CB: // MODIFIER LETTER GRAVE ACCENT. + codePoint = 0x0060; // GRAVE ACCENT (`). + break; + case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT. + codePoint = 0x005E; // CIRCUMFLEX ACCENT (^). + break; + } + } + + // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline: + mTermSession.writeCodePoint(altDown, codePoint); + } + } + + /** Input the specified keyCode if applicable and return if the input was consumed. */ + public boolean handleKeyCode(int keyCode, int keyMod) { + // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys + if (mEmulator != null) + mEmulator.setCursorBlinkState(true); + + if (handleKeyCodeAction(keyCode, keyMod)) + return true; + + TerminalEmulator term = mTermSession.getEmulator(); + String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()); + if (code == null) return false; + mTermSession.write(code); + return true; + } + + public boolean handleKeyCodeAction(int keyCode, int keyMod) { + boolean shiftDown = (keyMod & KeyHandler.KEYMOD_SHIFT) != 0; + + switch (keyCode) { + case KeyEvent.KEYCODE_PAGE_UP: + case KeyEvent.KEYCODE_PAGE_DOWN: + // shift+page_up and shift+page_down should scroll scrollback history instead of + // scrolling command history or changing pages + if (shiftDown) { + long time = SystemClock.uptimeMillis(); + MotionEvent motionEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0); + doScroll(motionEvent, keyCode == KeyEvent.KEYCODE_PAGE_UP ? -1 : 1); + motionEvent.recycle(); + return true; + } + } + + return false; + } + + /** + * Called when a key is released in the view. + * + * @param keyCode The keycode of the key which was released. + * @param event A {@link KeyEvent} describing the event. + * @return Whether the event was handled. + */ + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")"); + + // Do not return for KEYCODE_BACK and send it to the client since user may be trying + // to exit the activity. + if (mEmulator == null && keyCode != KeyEvent.KEYCODE_BACK) return true; + + if (mClient.onKeyUp(keyCode, event)) { + invalidate(); + return true; + } else if (event.isSystem()) { + // Let system key events through. + return super.onKeyUp(keyCode, event); + } + + return true; + } + + /** + * This is called during layout when the size of this view has changed. If you were just added to the view + * hierarchy, you're called with the old values of 0. + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + updateSize(); + } + + /** Check if the terminal size in rows and columns should be updated. */ + public void updateSize() { + int viewWidth = getWidth(); + int viewHeight = getHeight(); + if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return; + + // Set to 80 and 24 if you want to enable vttest. + int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth)); + int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + + if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) { + mTermSession.updateSize(newColumns, newRows); + mEmulator = mTermSession.getEmulator(); + mClient.onEmulatorSet(); + + // Update mTerminalCursorBlinkerRunnable inner class mEmulator on session change + if (mTerminalCursorBlinkerRunnable != null) + mTerminalCursorBlinkerRunnable.setEmulator(mEmulator); + + mTopRow = 0; + scrollTo(0, 0); + invalidate(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (mEmulator == null) { + canvas.drawColor(0XFF000000); + } else { + // render the terminal view and highlight any selected text + int[] sel = mDefaultSelectors; + if (mTextSelectionCursorController != null) { + mTextSelectionCursorController.getSelectors(sel); + } + + mRenderer.render(mEmulator, canvas, mTopRow, sel[0], sel[1], sel[2], sel[3]); + + // render the text selection handles + renderTextSelection(); + } + } + + public TerminalSession getCurrentSession() { + return mTermSession; + } + + private CharSequence getText() { + return mEmulator.getScreen().getSelectedText(0, mTopRow, mEmulator.mColumns, mTopRow + mEmulator.mRows); + } + + public int getCursorX(float x) { + return (int) (x / mRenderer.mFontWidth); + } + + public int getCursorY(float y) { + return (int) (((y - 40) / mRenderer.mFontLineSpacing) + mTopRow); + } + + public int getPointX(int cx) { + if (cx > mEmulator.mColumns) { + cx = mEmulator.mColumns; + } + return Math.round(cx * mRenderer.mFontWidth); + } + + public int getPointY(int cy) { + return Math.round((cy - mTopRow) * mRenderer.mFontLineSpacing); + } + + public int getTopRow() { + return mTopRow; + } + + public void setTopRow(int mTopRow) { + this.mTopRow = mTopRow; + } + + + + /** + * Define functions required for AutoFill API + */ + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public void autofill(AutofillValue value) { + if (value.isText()) { + mTermSession.write(value.getTextValue().toString()); + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public int getAutofillType() { + return AUTOFILL_TYPE_TEXT; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public AutofillValue getAutofillValue() { + return AutofillValue.forText(""); + } + + + + /** + * Set terminal cursor blinker rate. It must be between {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} + * and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}, otherwise it will be disabled. + * + * The {@link #setTerminalCursorBlinkerState(boolean, boolean)} must be called after this + * for changes to take effect if not disabling. + * + * @param blinkRate The value to set. + * @return Returns {@code true} if setting blinker rate was successfully set, otherwise [@code false}. + */ + public synchronized boolean setTerminalCursorBlinkerRate(int blinkRate) { + boolean result; + + // If cursor blinking rate is not valid + if (blinkRate != 0 && (blinkRate < TERMINAL_CURSOR_BLINK_RATE_MIN || blinkRate > TERMINAL_CURSOR_BLINK_RATE_MAX)) { + mClient.logError(LOG_TAG, "The cursor blink rate must be in between " + TERMINAL_CURSOR_BLINK_RATE_MIN + "-" + TERMINAL_CURSOR_BLINK_RATE_MAX + ": " + blinkRate); + mTerminalCursorBlinkerRate = 0; + result = false; + } else { + mClient.logVerbose(LOG_TAG, "Setting cursor blinker rate to " + blinkRate); + mTerminalCursorBlinkerRate = blinkRate; + result = true; + } + + if (mTerminalCursorBlinkerRate == 0) { + mClient.logVerbose(LOG_TAG, "Cursor blinker disabled"); + stopTerminalCursorBlinker(); + } + + return result; + } + + /** + * Sets whether cursor blinker should be started or stopped. Cursor blinker will only be + * started if {@link #mTerminalCursorBlinkerRate} does not equal 0 and is between + * {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}. + * + * This should be called when the view holding this activity is resumed or stopped so that + * cursor blinker does not run when activity is not visible. If you call this on onResume() + * to start cursor blinking, then ensure that {@link #mEmulator} is set, otherwise wait for the + * {@link TerminalViewClient#onEmulatorSet()} event after calling {@link #attachSession(TerminalSession)} + * for the first session added in the activity since blinking will not start if {@link #mEmulator} + * is not set, like if activity is started again after exiting it with double back press. Do not + * call this directly after {@link #attachSession(TerminalSession)} since {@link #updateSize()} + * may return without setting {@link #mEmulator} since width/height may be 0. Its called again in + * {@link #onSizeChanged(int, int, int, int)}. Calling on onResume() if emulator is already set + * is necessary, since onEmulatorSet() may not be called after activity is started after device + * display timeout with double tap and not power button. + * + * It should also be called on the + * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)} + * callback when cursor is enabled or disabled so that blinker is disabled if cursor is not + * to be shown. It should also be checked if activity is visible if blinker is to be started + * before calling this. + * + * It should also be called after terminal is reset with {@link TerminalSession#reset()} in case + * cursor blinker was disabled before reset due to call to + * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)}. + * + * How cursor blinker starting works is by registering a {@link Runnable} with the looper of + * the main thread of the app which when run, toggles the cursor blinking state and re-registers + * itself to be called with the delay set by {@link #mTerminalCursorBlinkerRate}. When cursor + * blinking needs to be disabled, we just cancel any callbacks registered. We don't run our own + * "thread" and let the thread for the main looper do the work for us, whose usage is also + * required to update the UI, since it also handles other calls to update the UI as well based + * on a queue. + * + * Note that when moving cursor in text editors like nano, the cursor state is quickly + * toggled `-> off -> on`, which would call this very quickly sequentially. So that if cursor + * is moved 2 or more times quickly, like long hold on arrow keys, it would trigger + * `-> off -> on -> off -> on -> ...`, and the "on" callback at index 2 is automatically + * cancelled by next "off" callback at index 3 before getting a chance to be run. For this case + * we log only if {@link #TERMINAL_VIEW_KEY_LOGGING_ENABLED} is enabled, otherwise would clutter + * the log. We don't start the blinking with a delay to immediately show cursor in case it was + * previously not visible. + * + * @param start If cursor blinker should be started or stopped. + * @param startOnlyIfCursorEnabled If set to {@code true}, then it will also be checked if the + * cursor is even enabled by {@link TerminalEmulator} before + * starting the cursor blinker. + */ + public synchronized void setTerminalCursorBlinkerState(boolean start, boolean startOnlyIfCursorEnabled) { + // Stop any existing cursor blinker callbacks + stopTerminalCursorBlinker(); + + if (mEmulator == null) return; + + mEmulator.setCursorBlinkingEnabled(false); + + if (start) { + // If cursor blinker is not enabled or is not valid + if (mTerminalCursorBlinkerRate < TERMINAL_CURSOR_BLINK_RATE_MIN || mTerminalCursorBlinkerRate > TERMINAL_CURSOR_BLINK_RATE_MAX) + return; + // If cursor blinder is to be started only if cursor is enabled + else if (startOnlyIfCursorEnabled && ! mEmulator.isCursorEnabled()) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Ignoring call to start cursor blinker since cursor is not enabled"); + return; + } + + // Start cursor blinker runnable + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Starting cursor blinker with the blink rate " + mTerminalCursorBlinkerRate); + if (mTerminalCursorBlinkerHandler == null) + mTerminalCursorBlinkerHandler = new Handler(Looper.getMainLooper()); + mTerminalCursorBlinkerRunnable = new TerminalCursorBlinkerRunnable(mEmulator, mTerminalCursorBlinkerRate); + mEmulator.setCursorBlinkingEnabled(true); + mTerminalCursorBlinkerRunnable.run(); + } + } + + /** + * Cancel the terminal cursor blinker callbacks + */ + private void stopTerminalCursorBlinker() { + if (mTerminalCursorBlinkerHandler != null && mTerminalCursorBlinkerRunnable != null) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Stopping cursor blinker"); + mTerminalCursorBlinkerHandler.removeCallbacks(mTerminalCursorBlinkerRunnable); + } + } + + private class TerminalCursorBlinkerRunnable implements Runnable { + + private TerminalEmulator mEmulator; + private final int mBlinkRate; + + // Initialize with false so that initial blink state is visible after toggling + boolean mCursorVisible = false; + + public TerminalCursorBlinkerRunnable(TerminalEmulator emulator, int blinkRate) { + mEmulator = emulator; + mBlinkRate = blinkRate; + } + + public void setEmulator(TerminalEmulator emulator) { + mEmulator = emulator; + } + + public void run() { + try { + if (mEmulator != null) { + // Toggle the blink state and then invalidate() the view so + // that onDraw() is called, which then calls TerminalRenderer.render() + // which checks with TerminalEmulator.shouldCursorBeVisible() to decide whether + // to draw the cursor or not + mCursorVisible = !mCursorVisible; + //mClient.logVerbose(LOG_TAG, "Toggling cursor blink state to " + mCursorVisible); + mEmulator.setCursorBlinkState(mCursorVisible); + invalidate(); + } + } finally { + // Recall the Runnable after mBlinkRate milliseconds to toggle the blink state + mTerminalCursorBlinkerHandler.postDelayed(this, mBlinkRate); + } + } + } + + + + /** + * Define functions required for text selection and its handles. + */ + TextSelectionCursorController getTextSelectionCursorController() { + if (mTextSelectionCursorController == null) { + mTextSelectionCursorController = new TextSelectionCursorController(this); + + final ViewTreeObserver observer = getViewTreeObserver(); + if (observer != null) { + observer.addOnTouchModeChangeListener(mTextSelectionCursorController); + } + } + + return mTextSelectionCursorController; + } + + private void showTextSelectionCursors(MotionEvent event) { + getTextSelectionCursorController().show(event); + } + + private boolean hideTextSelectionCursors() { + return getTextSelectionCursorController().hide(); + } + + private void renderTextSelection() { + if (mTextSelectionCursorController != null) + mTextSelectionCursorController.render(); + } + + public boolean isSelectingText() { + if (mTextSelectionCursorController != null) { + return mTextSelectionCursorController.isActive(); + } else { + return false; + } + } + + /** Get the currently selected text if selecting. */ + public String getSelectedText() { + if (isSelectingText() && mTextSelectionCursorController != null) + return mTextSelectionCursorController.getSelectedText(); + else + return null; + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mTextSelectionCursorController != null ? mTextSelectionCursorController.getStoredSelectedText() : null; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + if (mTextSelectionCursorController != null) mTextSelectionCursorController.unsetStoredSelectedText(); + } + + private ActionMode getTextSelectionActionMode() { + if (mTextSelectionCursorController != null) { + return mTextSelectionCursorController.getActionMode(); + } else { + return null; + } + } + + public void startTextSelectionMode(MotionEvent event) { + if (!requestFocus()) { + return; + } + + showTextSelectionCursors(event); + mClient.copyModeChanged(isSelectingText()); + + invalidate(); + } + + public void stopTextSelectionMode() { + if (hideTextSelectionCursors()) { + mClient.copyModeChanged(isSelectingText()); + invalidate(); + } + } + + private void decrementYTextSelectionCursors(int decrement) { + if (mTextSelectionCursorController != null) { + mTextSelectionCursorController.decrementYTextSelectionCursors(decrement); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (mTextSelectionCursorController != null) { + getViewTreeObserver().addOnTouchModeChangeListener(mTextSelectionCursorController); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (mTextSelectionCursorController != null) { + // Might solve the following exception + // android.view.WindowLeaked: Activity com.termux.app.TermuxActivity has leaked window android.widget.PopupWindow + stopTextSelectionMode(); + + getViewTreeObserver().removeOnTouchModeChangeListener(mTextSelectionCursorController); + mTextSelectionCursorController.onDetached(); + } + } + + + + /** + * Define functions required for long hold toolbar. + */ + private final Runnable mShowFloatingToolbar = new Runnable() { + @RequiresApi(api = Build.VERSION_CODES.M) + @Override + public void run() { + if (getTextSelectionActionMode() != null) { + getTextSelectionActionMode().hide(0); // hide off. + } + } + }; + + @RequiresApi(api = Build.VERSION_CODES.M) + private void showFloatingToolbar() { + if (getTextSelectionActionMode() != null) { + int delay = ViewConfiguration.getDoubleTapTimeout(); + postDelayed(mShowFloatingToolbar, delay); + } + } + + @RequiresApi(api = Build.VERSION_CODES.M) + void hideFloatingToolbar() { + if (getTextSelectionActionMode() != null) { + removeCallbacks(mShowFloatingToolbar); + getTextSelectionActionMode().hide(-1); + } + } + + public void updateFloatingToolbarVisibility(MotionEvent event) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && getTextSelectionActionMode() != null) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_MOVE: + hideFloatingToolbar(); + break; + case MotionEvent.ACTION_UP: // fall through + case MotionEvent.ACTION_CANCEL: + showFloatingToolbar(); + } + } + } + +} diff --git a/termux/termux-view/src/main/java/com/termux/view/TerminalViewClient.java b/termux/termux-view/src/main/java/com/termux/view/TerminalViewClient.java new file mode 100644 index 000000000..d6b49b892 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/TerminalViewClient.java @@ -0,0 +1,83 @@ +package com.termux.view; + +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; + +import com.termux.terminal.TerminalSession; + +/** + * The interface for communication between {@link TerminalView} and its client. It allows for getting + * various configuration options from the client and for sending back data to the client like logs, + * key events, both hardware and IME (which makes it different from that available with + * {@link View#setOnKeyListener(View.OnKeyListener)}, etc. It must be set for the + * {@link TerminalView} through {@link TerminalView#setTerminalViewClient(TerminalViewClient)}. + */ +public interface TerminalViewClient { + + /** + * Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}. + */ + float onScale(float scale); + + + + /** + * On a single tap on the terminal if terminal mouse reporting not enabled. + */ + void onSingleTapUp(MotionEvent e); + + boolean shouldBackButtonBeMappedToEscape(); + + boolean shouldEnforceCharBasedInput(); + + boolean shouldUseCtrlSpaceWorkaround(); + + boolean isTerminalViewSelected(); + + + + void copyModeChanged(boolean copyMode); + + + + boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session); + + boolean onKeyUp(int keyCode, KeyEvent e); + + boolean onLongPress(MotionEvent event); + + + + boolean readControlKey(); + + boolean readAltKey(); + + boolean readShiftKey(); + + boolean readFnKey(); + + + + boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session); + + + void onEmulatorSet(); + + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/termux/termux-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java b/termux/termux-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java new file mode 100644 index 000000000..24a1797c5 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.termux.view.support; + +import android.util.Log; +import android.widget.PopupWindow; + +import java.lang.reflect.Method; + +/** + * Implementation of PopupWindow compatibility that can call Gingerbread APIs. + * https://chromium.googlesource.com/android_tools/+/HEAD/sdk/extras/android/support/v4/src/gingerbread/android/support/v4/widget/PopupWindowCompatGingerbread.java + */ +public class PopupWindowCompatGingerbread { + + private static Method sSetWindowLayoutTypeMethod; + private static boolean sSetWindowLayoutTypeMethodAttempted; + private static Method sGetWindowLayoutTypeMethod; + private static boolean sGetWindowLayoutTypeMethodAttempted; + + public static void setWindowLayoutType(PopupWindow popupWindow, int layoutType) { + if (!sSetWindowLayoutTypeMethodAttempted) { + try { + sSetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod( + "setWindowLayoutType", int.class); + sSetWindowLayoutTypeMethod.setAccessible(true); + } catch (Exception e) { + // Reflection method fetch failed. Oh well. + } + sSetWindowLayoutTypeMethodAttempted = true; + } + if (sSetWindowLayoutTypeMethod != null) { + try { + sSetWindowLayoutTypeMethod.invoke(popupWindow, layoutType); + } catch (Exception e) { + // Reflection call failed. Oh well. + } + } + } + + public static int getWindowLayoutType(PopupWindow popupWindow) { + if (!sGetWindowLayoutTypeMethodAttempted) { + try { + sGetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod( + "getWindowLayoutType"); + sGetWindowLayoutTypeMethod.setAccessible(true); + } catch (Exception e) { + // Reflection method fetch failed. Oh well. + } + sGetWindowLayoutTypeMethodAttempted = true; + } + if (sGetWindowLayoutTypeMethod != null) { + try { + return (Integer) sGetWindowLayoutTypeMethod.invoke(popupWindow); + } catch (Exception e) { + // Reflection call failed. Oh well. + } + } + return 0; + } + +} diff --git a/termux/termux-view/src/main/java/com/termux/view/textselection/CursorController.java b/termux/termux-view/src/main/java/com/termux/view/textselection/CursorController.java new file mode 100644 index 000000000..f0e1cc5e4 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/textselection/CursorController.java @@ -0,0 +1,55 @@ +package com.termux.view.textselection; + +import android.view.MotionEvent; +import android.view.ViewTreeObserver; + +import com.termux.view.TerminalView; + +/** + * A CursorController instance can be used to control cursors in the text. + * It is not used outside of {@link TerminalView}. + */ +public interface CursorController extends ViewTreeObserver.OnTouchModeChangeListener { + /** + * Show the cursors on screen. Will be drawn by {@link #render()} by a call during onDraw. + * See also {@link #hide()}. + */ + void show(MotionEvent event); + + /** + * Hide the cursors from screen. + * See also {@link #show(MotionEvent event)}. + */ + boolean hide(); + + /** + * Render the cursors. + */ + void render(); + + /** + * Update the cursor positions. + */ + void updatePosition(TextSelectionHandleView handle, int x, int y); + + /** + * This method is called by {@link #onTouchEvent(MotionEvent)} and gives the cursors + * a chance to become active and/or visible. + * + * @param event The touch event + */ + boolean onTouchEvent(MotionEvent event); + + /** + * Called when the view is detached from window. Perform house keeping task, such as + * stopping Runnable thread that would otherwise keep a reference on the context, thus + * preventing the activity to be recycled. + */ + void onDetached(); + + /** + * @return true if the cursors are currently active. + */ + boolean isActive(); + +} diff --git a/termux/termux-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java b/termux/termux-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java new file mode 100644 index 000000000..d9c5a7517 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java @@ -0,0 +1,407 @@ +package com.termux.view.textselection; + +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.WcWidth; +import com.termux.view.R; +import com.termux.view.TerminalView; + +public class TextSelectionCursorController implements CursorController { + + private final TerminalView terminalView; + private final TextSelectionHandleView mStartHandle, mEndHandle; + private String mStoredSelectedText; + private boolean mIsSelectingText = false; + private long mShowStartTime = System.currentTimeMillis(); + + private final int mHandleHeight; + private int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1; + + private ActionMode mActionMode; + public final int ACTION_COPY = 1; + public final int ACTION_PASTE = 2; + public final int ACTION_MORE = 3; + + public TextSelectionCursorController(TerminalView terminalView) { + this.terminalView = terminalView; + mStartHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT); + mEndHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT); + + mHandleHeight = Math.max(mStartHandle.getHandleHeight(), mEndHandle.getHandleHeight()); + } + + @Override + public void show(MotionEvent event) { + setInitialTextSelectionPosition(event); + mStartHandle.positionAtCursor(mSelX1, mSelY1, true); + mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true); + + setActionModeCallBacks(); + mShowStartTime = System.currentTimeMillis(); + mIsSelectingText = true; + } + + @Override + public boolean hide() { + if (!isActive()) return false; + + // prevent hide calls right after a show call, like long pressing the down key + // 300ms seems long enough that it wouldn't cause hide problems if action button + // is quickly clicked after the show, otherwise decrease it + if (System.currentTimeMillis() - mShowStartTime < 300) { + return false; + } + + mStartHandle.hide(); + mEndHandle.hide(); + + if (mActionMode != null) { + // This will hide the TextSelectionCursorController + mActionMode.finish(); + } + + mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1; + mIsSelectingText = false; + + return true; + } + + @Override + public void render() { + if (!isActive()) return; + + mStartHandle.positionAtCursor(mSelX1, mSelY1, false); + mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false); + + if (mActionMode != null) { + mActionMode.invalidate(); + } + } + + public void setInitialTextSelectionPosition(MotionEvent event) { + int[] columnAndRow = terminalView.getColumnAndRow(event, true); + mSelX1 = mSelX2 = columnAndRow[0]; + mSelY1 = mSelY2 = columnAndRow[1]; + + TerminalBuffer screen = terminalView.mEmulator.getScreen(); + if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) { + // Selecting something other than whitespace. Expand to word. + while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) { + mSelX1--; + } + while (mSelX2 < terminalView.mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) { + mSelX2++; + } + } + } + + public void setActionModeCallBacks() { + final ActionMode.Callback callback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + int show = MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT; + + ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, android.R.string.paste).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.terminal_more); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (!isActive()) { + // Fix issue where the dialog is pressed while being dismissed. + return true; + } + + switch (item.getItemId()) { + case ACTION_COPY: + String selectedText = getSelectedText(); + terminalView.mTermSession.onCopyTextToClipboard(selectedText); + terminalView.stopTextSelectionMode(); + break; + case ACTION_PASTE: + terminalView.stopTextSelectionMode(); + terminalView.mTermSession.onPasteTextFromClipboard(); + break; + case ACTION_MORE: + // We first store the selected text in case TerminalViewClient needs the + // selected text before MORE button was pressed since we are going to + // stop selection mode + mStoredSelectedText = getSelectedText(); + // The text selection needs to be stopped before showing context menu, + // otherwise handles will show above popup + terminalView.stopTextSelectionMode(); + terminalView.showContextMenu(); + break; + } + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + + }; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + mActionMode = terminalView.startActionMode(callback); + return; + } + + //noinspection NewApi + mActionMode = terminalView.startActionMode(new ActionMode.Callback2() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return callback.onCreateActionMode(mode, menu); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return callback.onActionItemClicked(mode, item); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + // Ignore. + } + + @Override + public void onGetContentRect(ActionMode mode, View view, Rect outRect) { + int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth()); + int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth()); + int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); + int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); + + if (x1 > x2) { + int tmp = x1; + x1 = x2; + x2 = tmp; + } + + int terminalBottom = terminalView.getBottom(); + int top = y1 + mHandleHeight; + int bottom = y2 + mHandleHeight; + if (top > terminalBottom) top = terminalBottom; + if (bottom > terminalBottom) bottom = terminalBottom; + + outRect.set(x1, top, x2, bottom); + } + }, ActionMode.TYPE_FLOATING); + } + + @Override + public void updatePosition(TextSelectionHandleView handle, int x, int y) { + TerminalBuffer screen = terminalView.mEmulator.getScreen(); + final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows; + if (handle == mStartHandle) { + mSelX1 = terminalView.getCursorX(x); + mSelY1 = terminalView.getCursorY(y); + if (mSelX1 < 0) { + mSelX1 = 0; + } + + if (mSelY1 < -scrollRows) { + mSelY1 = -scrollRows; + + } else if (mSelY1 > terminalView.mEmulator.mRows - 1) { + mSelY1 = terminalView.mEmulator.mRows - 1; + + } + + if (mSelY1 > mSelY2) { + mSelY1 = mSelY2; + } + if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { + mSelX1 = mSelX2; + } + + if (!terminalView.mEmulator.isAlternateBufferActive()) { + int topRow = terminalView.getTopRow(); + + if (mSelY1 <= topRow) { + topRow--; + if (topRow < -scrollRows) { + topRow = -scrollRows; + } + } else if (mSelY1 >= topRow + terminalView.mEmulator.mRows) { + topRow++; + if (topRow > 0) { + topRow = 0; + } + } + + terminalView.setTopRow(topRow); + } + + mSelX1 = getValidCurX(screen, mSelY1, mSelX1); + + } else { + mSelX2 = terminalView.getCursorX(x); + mSelY2 = terminalView.getCursorY(y); + if (mSelX2 < 0) { + mSelX2 = 0; + } + + if (mSelY2 < -scrollRows) { + mSelY2 = -scrollRows; + } else if (mSelY2 > terminalView.mEmulator.mRows - 1) { + mSelY2 = terminalView.mEmulator.mRows - 1; + } + + if (mSelY1 > mSelY2) { + mSelY2 = mSelY1; + } + if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { + mSelX2 = mSelX1; + } + + if (!terminalView.mEmulator.isAlternateBufferActive()) { + int topRow = terminalView.getTopRow(); + + if (mSelY2 <= topRow) { + topRow--; + if (topRow < -scrollRows) { + topRow = -scrollRows; + } + } else if (mSelY2 >= topRow + terminalView.mEmulator.mRows) { + topRow++; + if (topRow > 0) { + topRow = 0; + } + } + + terminalView.setTopRow(topRow); + } + + mSelX2 = getValidCurX(screen, mSelY2, mSelX2); + } + + terminalView.invalidate(); + } + + private int getValidCurX(TerminalBuffer screen, int cy, int cx) { + String line = screen.getSelectedText(0, cy, cx, cy); + if (!TextUtils.isEmpty(line)) { + int col = 0; + for (int i = 0, len = line.length(); i < len; i++) { + char ch1 = line.charAt(i); + if (ch1 == 0) { + break; + } + + int wc; + if (Character.isHighSurrogate(ch1) && i + 1 < len) { + char ch2 = line.charAt(++i); + wc = WcWidth.width(Character.toCodePoint(ch1, ch2)); + } else { + wc = WcWidth.width(ch1); + } + + final int cend = col + wc; + if (cx > col && cx < cend) { + return cend; + } + if (cend == col) { + return col; + } + col = cend; + } + } + return cx; + } + + public void decrementYTextSelectionCursors(int decrement) { + mSelY1 -= decrement; + mSelY2 -= decrement; + } + + public boolean onTouchEvent(MotionEvent event) { + return false; + } + + public void onTouchModeChanged(boolean isInTouchMode) { + if (!isInTouchMode) { + terminalView.stopTextSelectionMode(); + } + } + + @Override + public void onDetached() { + } + + @Override + public boolean isActive() { + return mIsSelectingText; + } + + public void getSelectors(int[] sel) { + if (sel == null || sel.length != 4) { + return; + } + + sel[0] = mSelY1; + sel[1] = mSelY2; + sel[2] = mSelX1; + sel[3] = mSelX2; + } + + /** Get the currently selected text. */ + public String getSelectedText() { + return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2); + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mStoredSelectedText; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + mStoredSelectedText = null; + } + + public ActionMode getActionMode() { + return mActionMode; + } + + /** + * @return true if this controller is currently used to move the start selection. + */ + public boolean isSelectionStartDragged() { + return mStartHandle.isDragging(); + } + + /** + * @return true if this controller is currently used to move the end selection. + */ + public boolean isSelectionEndDragged() { + return mEndHandle.isDragging(); + } + +} diff --git a/termux/termux-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java b/termux/termux-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java new file mode 100644 index 000000000..b3caca672 --- /dev/null +++ b/termux/termux-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java @@ -0,0 +1,352 @@ +package com.termux.view.textselection; + +import android.annotation.SuppressLint; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowManager; +import android.widget.PopupWindow; + +import com.termux.view.R; +import com.termux.view.TerminalView; +import com.termux.view.support.PopupWindowCompatGingerbread; + +@SuppressLint("ViewConstructor") +public class TextSelectionHandleView extends View { + private final TerminalView terminalView; + private PopupWindow mHandle; + private final CursorController mCursorController; + + private final Drawable mHandleLeftDrawable; + private final Drawable mHandleRightDrawable; + private Drawable mHandleDrawable; + + private boolean mIsDragging; + + final int[] mTempCoords = new int[2]; + Rect mTempRect; + + private int mPointX; + private int mPointY; + private float mTouchToWindowOffsetX; + private float mTouchToWindowOffsetY; + private float mHotspotX; + private float mHotspotY; + private float mTouchOffsetY; + private int mLastParentX; + private int mLastParentY; + + private int mHandleHeight; + private int mHandleWidth; + + private final int mInitialOrientation; + private int mOrientation; + + public static final int LEFT = 0; + public static final int RIGHT = 2; + + private long mLastTime; + + public TextSelectionHandleView(TerminalView terminalView, CursorController cursorController, int initialOrientation) { + super(terminalView.getContext()); + this.terminalView = terminalView; + mCursorController = cursorController; + mInitialOrientation = initialOrientation; + + mHandleLeftDrawable = getContext().getDrawable(R.drawable.text_select_handle_left_material); + mHandleRightDrawable = getContext().getDrawable(R.drawable.text_select_handle_right_material); + + setOrientation(mInitialOrientation); + } + + private void initHandle() { + mHandle = new PopupWindow(terminalView.getContext(), null, + android.R.attr.textSelectHandleWindowStyle); + mHandle.setSplitTouchEnabled(true); + mHandle.setClippingEnabled(false); + mHandle.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + mHandle.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mHandle.setBackgroundDrawable(null); + mHandle.setAnimationStyle(0); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mHandle.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL); + mHandle.setEnterTransition(null); + mHandle.setExitTransition(null); + } else { + PopupWindowCompatGingerbread.setWindowLayoutType(mHandle, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL); + } + mHandle.setContentView(this); + } + + public void setOrientation(int orientation) { + mOrientation = orientation; + int handleWidth = 0; + switch (orientation) { + case LEFT: { + mHandleDrawable = mHandleLeftDrawable; + handleWidth = mHandleDrawable.getIntrinsicWidth(); + mHotspotX = (handleWidth * 3) / (float) 4; + break; + } + + case RIGHT: { + mHandleDrawable = mHandleRightDrawable; + handleWidth = mHandleDrawable.getIntrinsicWidth(); + mHotspotX = handleWidth / (float) 4; + break; + } + } + + mHandleHeight = mHandleDrawable.getIntrinsicHeight(); + + mHandleWidth = handleWidth; + mTouchOffsetY = -mHandleHeight * 0.3f; + mHotspotY = 0; + invalidate(); + } + + public void show() { + if (!isPositionVisible()) { + hide(); + return; + } + + // We remove handle from its parent first otherwise the following exception may be thrown + // java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first. + removeFromParent(); + + initHandle(); // init the handle + invalidate(); // invalidate to make sure onDraw is called + + final int[] coords = mTempCoords; + terminalView.getLocationInWindow(coords); + coords[0] += mPointX; + coords[1] += mPointY; + + if (mHandle != null) + mHandle.showAtLocation(terminalView, 0, coords[0], coords[1]); + } + + public void hide() { + mIsDragging = false; + + if (mHandle != null) { + mHandle.dismiss(); + + // We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call + removeFromParent(); + mHandle = null; // garbage collect the handle + } + invalidate(); + } + + public void removeFromParent() { + if (!isParentNull()) { + ((ViewGroup)this.getParent()).removeView(this); + } + } + + public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) { + int x = terminalView.getPointX(cx); + int y = terminalView.getPointY(cy + 1); + moveTo(x, y, forceOrientationCheck); + } + + private void moveTo(int x, int y, boolean forceOrientationCheck) { + float oldHotspotX = mHotspotX; + checkChangedOrientation(x, forceOrientationCheck); + mPointX = (int) (x - (isShowing() ? oldHotspotX : mHotspotX)); + mPointY = y; + + if (isPositionVisible()) { + int[] coords = null; + + if (isShowing()) { + coords = mTempCoords; + terminalView.getLocationInWindow(coords); + int x1 = coords[0] + mPointX; + int y1 = coords[1] + mPointY; + if (mHandle != null) + mHandle.update(x1, y1, getWidth(), getHeight()); + } else { + show(); + } + + if (mIsDragging) { + if (coords == null) { + coords = mTempCoords; + terminalView.getLocationInWindow(coords); + } + if (coords[0] != mLastParentX || coords[1] != mLastParentY) { + mTouchToWindowOffsetX += coords[0] - mLastParentX; + mTouchToWindowOffsetY += coords[1] - mLastParentY; + mLastParentX = coords[0]; + mLastParentY = coords[1]; + } + } + } else { + hide(); + } + } + + public void changeOrientation(int orientation) { + if (mOrientation != orientation) { + setOrientation(orientation); + } + } + + private void checkChangedOrientation(int posX, boolean force) { + if (!mIsDragging && !force) { + return; + } + long millis = SystemClock.currentThreadTimeMillis(); + if (millis - mLastTime < 50 && !force) { + return; + } + mLastTime = millis; + + final TerminalView hostView = terminalView; + final int left = hostView.getLeft(); + final int right = hostView.getWidth(); + final int top = hostView.getTop(); + final int bottom = hostView.getHeight(); + + if (mTempRect == null) { + mTempRect = new Rect(); + } + final Rect clip = mTempRect; + clip.left = left + terminalView.getPaddingLeft(); + clip.top = top + terminalView.getPaddingTop(); + clip.right = right - terminalView.getPaddingRight(); + clip.bottom = bottom - terminalView.getPaddingBottom(); + + final ViewParent parent = hostView.getParent(); + if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) { + return; + } + + if (posX - mHandleWidth < clip.left) { + changeOrientation(RIGHT); + } else if (posX + mHandleWidth > clip.right) { + changeOrientation(LEFT); + } else { + changeOrientation(mInitialOrientation); + } + } + + private boolean isPositionVisible() { + // Always show a dragging handle. + if (mIsDragging) { + return true; + } + + final TerminalView hostView = terminalView; + final int left = 0; + final int right = hostView.getWidth(); + final int top = 0; + final int bottom = hostView.getHeight(); + + if (mTempRect == null) { + mTempRect = new Rect(); + } + final Rect clip = mTempRect; + clip.left = left + terminalView.getPaddingLeft(); + clip.top = top + terminalView.getPaddingTop(); + clip.right = right - terminalView.getPaddingRight(); + clip.bottom = bottom - terminalView.getPaddingBottom(); + + final ViewParent parent = hostView.getParent(); + if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) { + return false; + } + + final int[] coords = mTempCoords; + hostView.getLocationInWindow(coords); + final int posX = coords[0] + mPointX + (int) mHotspotX; + final int posY = coords[1] + mPointY + (int) mHotspotY; + + return posX >= clip.left && posX <= clip.right && + posY >= clip.top && posY <= clip.bottom; + } + + @Override + public void onDraw(Canvas c) { + final int width = mHandleDrawable.getIntrinsicWidth(); + int height = mHandleDrawable.getIntrinsicHeight(); + mHandleDrawable.setBounds(0, 0, width, height); + mHandleDrawable.draw(c); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent event) { + terminalView.updateFloatingToolbarVisibility(event); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + mTouchToWindowOffsetX = rawX - mPointX; + mTouchToWindowOffsetY = rawY - mPointY; + final int[] coords = mTempCoords; + terminalView.getLocationInWindow(coords); + mLastParentX = coords[0]; + mLastParentY = coords[1]; + mIsDragging = true; + break; + } + + case MotionEvent.ACTION_MOVE: { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + + final float newPosX = rawX - mTouchToWindowOffsetX + mHotspotX; + final float newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY; + + mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY)); + break; + } + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsDragging = false; + } + return true; + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(mHandleDrawable.getIntrinsicWidth(), + mHandleDrawable.getIntrinsicHeight()); + } + + public int getHandleHeight() { + return mHandleHeight; + } + + public int getHandleWidth() { + return mHandleWidth; + } + + public boolean isShowing() { + if (mHandle != null) + return mHandle.isShowing(); + else + return false; + } + + public boolean isParentNull() { + return this.getParent() == null; + } + + public boolean isDragging() { + return mIsDragging; + } + +} diff --git a/testing/lsp/build.gradle.kts b/testing/lsp/build.gradle.kts index 75ee35973..8ef7f0821 100644 --- a/testing/lsp/build.gradle.kts +++ b/testing/lsp/build.gradle.kts @@ -25,7 +25,7 @@ android { } dependencies { - api(libs.androidx.ktx) + api(libs.androidx.core.ktx) api(libs.common.kotlin) api(projects.lsp.api) diff --git a/uidesigner/build.gradle.kts b/uidesigner/build.gradle.kts index 5e1c5824a..43fab02ba 100644 --- a/uidesigner/build.gradle.kts +++ b/uidesigner/build.gradle.kts @@ -11,7 +11,7 @@ android { dependencies { implementation(libs.androidx.appcompat) - implementation(libs.androidx.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.nav.fragment) implementation(libs.androidx.nav.ui)