Repository: termux/termux-app Branch: master Commit: 3f0dec3574a6 Files: 318 Total size: 2.1 MB Directory structure: gitextract_hurpdc6s/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 01-bug-report.yml │ │ ├── 02-feature-request.yml │ │ └── config.yml │ ├── dependabot.yml │ └── workflows/ │ ├── attach_debug_apks_to_release.yml │ ├── debug_build.yml │ ├── dependency-submission.yml │ ├── gradle-wrapper-validation.yml │ ├── run_tests.yml │ └── trigger_library_builds_on_jitpack.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── SECURITY.md ├── app/ │ ├── build.gradle │ ├── proguard-rules.pro │ ├── src/ │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── cpp/ │ │ │ │ ├── Android.mk │ │ │ │ ├── termux-bootstrap-zip.S │ │ │ │ └── termux-bootstrap.c │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── termux/ │ │ │ │ ├── app/ │ │ │ │ │ ├── RunCommandService.java │ │ │ │ │ ├── TermuxActivity.java │ │ │ │ │ ├── TermuxApplication.java │ │ │ │ │ ├── TermuxInstaller.java │ │ │ │ │ ├── TermuxOpenReceiver.java │ │ │ │ │ ├── TermuxService.java │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── HelpActivity.java │ │ │ │ │ │ └── SettingsActivity.java │ │ │ │ │ ├── api/ │ │ │ │ │ │ └── file/ │ │ │ │ │ │ └── FileReceiverActivity.java │ │ │ │ │ ├── event/ │ │ │ │ │ │ └── SystemEventReceiver.java │ │ │ │ │ ├── fragments/ │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ ├── TermuxAPIPreferencesFragment.java │ │ │ │ │ │ ├── TermuxFloatPreferencesFragment.java │ │ │ │ │ │ ├── TermuxPreferencesFragment.java │ │ │ │ │ │ ├── TermuxTaskerPreferencesFragment.java │ │ │ │ │ │ ├── TermuxWidgetPreferencesFragment.java │ │ │ │ │ │ ├── termux/ │ │ │ │ │ │ │ ├── DebuggingPreferencesFragment.java │ │ │ │ │ │ │ ├── TerminalIOPreferencesFragment.java │ │ │ │ │ │ │ └── TerminalViewPreferencesFragment.java │ │ │ │ │ │ ├── termux_api/ │ │ │ │ │ │ │ └── DebuggingPreferencesFragment.java │ │ │ │ │ │ ├── termux_float/ │ │ │ │ │ │ │ └── DebuggingPreferencesFragment.java │ │ │ │ │ │ ├── termux_tasker/ │ │ │ │ │ │ │ └── DebuggingPreferencesFragment.java │ │ │ │ │ │ └── termux_widget/ │ │ │ │ │ │ └── DebuggingPreferencesFragment.java │ │ │ │ │ ├── models/ │ │ │ │ │ │ └── UserAction.java │ │ │ │ │ └── terminal/ │ │ │ │ │ ├── TermuxActivityRootView.java │ │ │ │ │ ├── TermuxSessionsListViewController.java │ │ │ │ │ ├── TermuxTerminalSessionActivityClient.java │ │ │ │ │ ├── TermuxTerminalSessionServiceClient.java │ │ │ │ │ ├── TermuxTerminalViewClient.java │ │ │ │ │ └── io/ │ │ │ │ │ ├── FullScreenWorkAround.java │ │ │ │ │ ├── KeyboardShortcut.java │ │ │ │ │ ├── TerminalToolbarViewPager.java │ │ │ │ │ └── TermuxTerminalExtraKeys.java │ │ │ │ └── filepicker/ │ │ │ │ └── TermuxDocumentsProvider.java │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ ├── current_session.xml │ │ │ │ ├── current_session_black.xml │ │ │ │ ├── ic_foreground.xml │ │ │ │ ├── ic_new_session.xml │ │ │ │ ├── ic_service_notification.xml │ │ │ │ ├── ic_settings.xml │ │ │ │ ├── session_background_black_selected.xml │ │ │ │ ├── session_background_selected.xml │ │ │ │ ├── session_ripple.xml │ │ │ │ ├── session_ripple_black.xml │ │ │ │ └── terminal_scroll_shape.xml │ │ │ ├── layout/ │ │ │ │ ├── activity_settings.xml │ │ │ │ ├── activity_termux.xml │ │ │ │ ├── item_terminal_sessions_list.xml │ │ │ │ ├── preference_markdown_text.xml │ │ │ │ ├── view_terminal_toolbar_extra_keys.xml │ │ │ │ └── view_terminal_toolbar_text_input.xml │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values/ │ │ │ │ ├── attrs.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── styles.xml │ │ │ │ └── themes.xml │ │ │ ├── values-night/ │ │ │ │ └── themes.xml │ │ │ └── xml/ │ │ │ ├── root_preferences.xml │ │ │ ├── shortcuts.xml │ │ │ ├── termux_api_debugging_preferences.xml │ │ │ ├── termux_api_preferences.xml │ │ │ ├── termux_debugging_preferences.xml │ │ │ ├── termux_float_debugging_preferences.xml │ │ │ ├── termux_float_preferences.xml │ │ │ ├── termux_preferences.xml │ │ │ ├── termux_tasker_debugging_preferences.xml │ │ │ ├── termux_tasker_preferences.xml │ │ │ ├── termux_terminal_io_preferences.xml │ │ │ ├── termux_terminal_view_preferences.xml │ │ │ ├── termux_widget_debugging_preferences.xml │ │ │ └── termux_widget_preferences.xml │ │ └── test/ │ │ └── java/ │ │ └── com/ │ │ └── termux/ │ │ └── app/ │ │ ├── TermuxActivityTest.java │ │ └── api/ │ │ └── file/ │ │ └── FileReceiverActivityTest.java │ └── testkey_untrusted.jks ├── art/ │ ├── copy-to-other-apps.sh │ ├── generate-big-icon.sh │ ├── generate-feature-graphic.sh │ ├── generate-launcher-images.sh │ └── generate-tv-banner.sh ├── build.gradle ├── docs/ │ └── en/ │ └── index.md ├── fastlane/ │ └── metadata/ │ └── android/ │ └── en-US/ │ ├── full_description.txt │ └── short_description.txt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── settings.gradle ├── terminal-emulator/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── termux/ │ │ │ └── terminal/ │ │ │ ├── ByteQueue.java │ │ │ ├── JNI.java │ │ │ ├── KeyHandler.java │ │ │ ├── Logger.java │ │ │ ├── TerminalBuffer.java │ │ │ ├── TerminalColorScheme.java │ │ │ ├── TerminalColors.java │ │ │ ├── TerminalEmulator.java │ │ │ ├── TerminalOutput.java │ │ │ ├── TerminalRow.java │ │ │ ├── TerminalSession.java │ │ │ ├── TerminalSessionClient.java │ │ │ ├── TextStyle.java │ │ │ └── WcWidth.java │ │ └── jni/ │ │ ├── Android.mk │ │ └── termux.c │ └── test/ │ └── java/ │ └── com/ │ └── termux/ │ └── terminal/ │ ├── ApcTest.java │ ├── ByteQueueTest.java │ ├── ControlSequenceIntroducerTest.java │ ├── CursorAndScreenTest.java │ ├── DecSetTest.java │ ├── DeviceControlStringTest.java │ ├── HistoryTest.java │ ├── KeyHandlerTest.java │ ├── OperatingSystemControlTest.java │ ├── RectangularAreasTest.java │ ├── ResizeTest.java │ ├── ScreenBufferTest.java │ ├── ScrollRegionTest.java │ ├── TerminalRowTest.java │ ├── TerminalTest.java │ ├── TerminalTestCase.java │ ├── TextStyleTest.java │ ├── UnicodeInputTest.java │ └── WcWidthTest.java ├── terminal-view/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── termux/ │ │ └── view/ │ │ ├── GestureAndScaleRecognizer.java │ │ ├── TerminalRenderer.java │ │ ├── TerminalView.java │ │ ├── TerminalViewClient.java │ │ ├── support/ │ │ │ └── PopupWindowCompatGingerbread.java │ │ └── textselection/ │ │ ├── CursorController.java │ │ ├── TextSelectionCursorController.java │ │ └── TextSelectionHandleView.java │ └── res/ │ ├── drawable/ │ │ ├── text_select_handle_left_material.xml │ │ └── text_select_handle_right_material.xml │ └── values/ │ └── strings.xml └── termux-shared/ ├── .gitignore ├── LICENSE.md ├── build.gradle ├── proguard-rules.pro └── src/ ├── androidTest/ │ └── java/ │ └── com/ │ └── termux/ │ └── shared/ │ └── ExampleInstrumentedTest.java └── main/ ├── AndroidManifest.xml ├── cpp/ │ ├── Android.mk │ ├── Application.mk │ └── local-socket.cpp ├── java/ │ └── com/ │ └── termux/ │ └── shared/ │ ├── activities/ │ │ ├── ReportActivity.java │ │ └── TextIOActivity.java │ ├── activity/ │ │ ├── ActivityErrno.java │ │ ├── ActivityUtils.java │ │ └── media/ │ │ └── AppCompatActivityUtils.java │ ├── android/ │ │ ├── AndroidUtils.java │ │ ├── FeatureFlagUtils.java │ │ ├── PackageUtils.java │ │ ├── PermissionUtils.java │ │ ├── PhantomProcessUtils.java │ │ ├── ProcessUtils.java │ │ ├── SELinuxUtils.java │ │ ├── SettingsProviderUtils.java │ │ ├── UserUtils.java │ │ └── resource/ │ │ └── ResourceUtils.java │ ├── crash/ │ │ └── CrashHandler.java │ ├── data/ │ │ ├── DataUtils.java │ │ └── IntentUtils.java │ ├── errors/ │ │ ├── Errno.java │ │ ├── Error.java │ │ └── FunctionErrno.java │ ├── file/ │ │ ├── FileUtils.java │ │ ├── FileUtilsErrno.java │ │ ├── filesystem/ │ │ │ ├── FileAttributes.java │ │ │ ├── FileKey.java │ │ │ ├── FilePermission.java │ │ │ ├── FilePermissions.java │ │ │ ├── FileTime.java │ │ │ ├── FileType.java │ │ │ ├── FileTypes.java │ │ │ ├── NativeDispatcher.java │ │ │ └── UnixConstants.java │ │ └── tests/ │ │ └── FileUtilsTests.java │ ├── interact/ │ │ ├── MessageDialogUtils.java │ │ └── ShareUtils.java │ ├── jni/ │ │ └── models/ │ │ └── JniResult.java │ ├── logger/ │ │ └── Logger.java │ ├── markdown/ │ │ └── MarkdownUtils.java │ ├── models/ │ │ ├── ReportInfo.java │ │ └── TextIOInfo.java │ ├── net/ │ │ ├── socket/ │ │ │ └── local/ │ │ │ ├── ILocalSocketManager.java │ │ │ ├── LocalClientSocket.java │ │ │ ├── LocalServerSocket.java │ │ │ ├── LocalSocketErrno.java │ │ │ ├── LocalSocketManager.java │ │ │ ├── LocalSocketManagerClientBase.java │ │ │ ├── LocalSocketRunConfig.java │ │ │ └── PeerCred.java │ │ ├── uri/ │ │ │ ├── UriScheme.java │ │ │ └── UriUtils.java │ │ └── url/ │ │ └── UrlUtils.java │ ├── notification/ │ │ └── NotificationUtils.java │ ├── reflection/ │ │ └── ReflectionUtils.java │ ├── settings/ │ │ ├── preferences/ │ │ │ ├── AppSharedPreferences.java │ │ │ └── SharedPreferenceUtils.java │ │ └── properties/ │ │ ├── SharedProperties.java │ │ └── SharedPropertiesParser.java │ ├── shell/ │ │ ├── ArgumentTokenizer.java │ │ ├── ShellUtils.java │ │ ├── StreamGobbler.java │ │ ├── am/ │ │ │ ├── AmSocketServer.java │ │ │ ├── AmSocketServerErrno.java │ │ │ └── AmSocketServerRunConfig.java │ │ └── command/ │ │ ├── ExecutionCommand.java │ │ ├── ShellCommandConstants.java │ │ ├── environment/ │ │ │ ├── AndroidShellEnvironment.java │ │ │ ├── IShellEnvironment.java │ │ │ ├── ShellCommandShellEnvironment.java │ │ │ ├── ShellEnvironmentUtils.java │ │ │ ├── ShellEnvironmentVariable.java │ │ │ └── UnixShellEnvironment.java │ │ ├── result/ │ │ │ ├── ResultConfig.java │ │ │ ├── ResultData.java │ │ │ ├── ResultSender.java │ │ │ └── ResultSenderErrno.java │ │ └── runner/ │ │ └── app/ │ │ └── AppShell.java │ ├── termux/ │ │ ├── TermuxBootstrap.java │ │ ├── TermuxConstants.java │ │ ├── TermuxUtils.java │ │ ├── crash/ │ │ │ └── TermuxCrashUtils.java │ │ ├── data/ │ │ │ └── TermuxUrlUtils.java │ │ ├── extrakeys/ │ │ │ ├── ExtraKeyButton.java │ │ │ ├── ExtraKeysConstants.java │ │ │ ├── ExtraKeysInfo.java │ │ │ ├── ExtraKeysView.java │ │ │ ├── SpecialButton.java │ │ │ └── SpecialButtonState.java │ │ ├── file/ │ │ │ └── TermuxFileUtils.java │ │ ├── interact/ │ │ │ └── TextInputDialogUtils.java │ │ ├── models/ │ │ │ └── UserAction.java │ │ ├── notification/ │ │ │ └── TermuxNotificationUtils.java │ │ ├── plugins/ │ │ │ └── TermuxPluginUtils.java │ │ ├── settings/ │ │ │ ├── preferences/ │ │ │ │ ├── TermuxAPIAppSharedPreferences.java │ │ │ │ ├── TermuxAppSharedPreferences.java │ │ │ │ ├── TermuxBootAppSharedPreferences.java │ │ │ │ ├── TermuxFloatAppSharedPreferences.java │ │ │ │ ├── TermuxPreferenceConstants.java │ │ │ │ ├── TermuxStylingAppSharedPreferences.java │ │ │ │ ├── TermuxTaskerAppSharedPreferences.java │ │ │ │ └── TermuxWidgetAppSharedPreferences.java │ │ │ └── properties/ │ │ │ ├── TermuxAppSharedProperties.java │ │ │ ├── TermuxPropertyConstants.java │ │ │ └── TermuxSharedProperties.java │ │ ├── shell/ │ │ │ ├── TermuxShellManager.java │ │ │ ├── TermuxShellUtils.java │ │ │ ├── am/ │ │ │ │ └── TermuxAmSocketServer.java │ │ │ └── command/ │ │ │ ├── environment/ │ │ │ │ ├── TermuxAPIShellEnvironment.java │ │ │ │ ├── TermuxAppShellEnvironment.java │ │ │ │ ├── TermuxShellCommandShellEnvironment.java │ │ │ │ └── TermuxShellEnvironment.java │ │ │ └── runner/ │ │ │ └── terminal/ │ │ │ └── TermuxSession.java │ │ ├── terminal/ │ │ │ ├── TermuxTerminalSessionClientBase.java │ │ │ ├── TermuxTerminalViewClientBase.java │ │ │ └── io/ │ │ │ ├── BellHandler.java │ │ │ └── TerminalExtraKeys.java │ │ └── theme/ │ │ └── TermuxThemeUtils.java │ ├── theme/ │ │ ├── NightMode.java │ │ └── ThemeUtils.java │ └── view/ │ ├── KeyboardUtils.java │ └── ViewUtils.java └── res/ ├── drawable/ │ ├── ic_copy.xml │ ├── ic_error_notification.xml │ ├── ic_info.xml │ ├── ic_settings.xml │ └── ic_share.xml ├── layout/ │ ├── activity_report.xml │ ├── activity_text_io.xml │ ├── dialog_show_message.xml │ ├── markdown_adapter_node_code_block.xml │ ├── markdown_adapter_node_default.xml │ └── partial_primary_toolbar.xml ├── menu/ │ ├── menu_report.xml │ └── menu_text_io.xml ├── raw/ │ ├── apt_info_script.sh │ ├── bell.ogg │ └── keep.xml ├── values/ │ ├── attrs.xml │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ ├── styles.xml │ └── themes.xml └── values-night/ └── themes.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # Copying and distribution of this file, with or without modification, # are permitted in any medium without royalty provided this notice is # preserved. This file is offered as-is, without any warranty. # EditorConfig # http://EditorConfig.org # top-most EditorConfig file root = true [*] end_of_line = lf insert_final_newline = true charset = utf-8 indent_style = space indent_size = 4 [*.y{a,}ml] indent_size = 2 indent_style = space ================================================ FILE: .gitattributes ================================================ * text=auto *.bat text eol=crlf *.gradle text eol=lf *.mk text eol=lf *.sh text eol=lf ================================================ FILE: .github/FUNDING.yml ================================================ custom: https://termux.dev/donate ================================================ FILE: .github/ISSUE_TEMPLATE/01-bug-report.yml ================================================ name: "Bug report" description: "Create a report to help us improve" title: "[Bug]: " labels: ["bug report"] body: - type: markdown attributes: value: | **THIS IS A BUG TRACKER OF THE TERMUX APP. IF YOU HAVE ISSUES WITH A PACKAGE INSIDE THE APP, THEN PLEASE OPEN AN ISSUE AT [TERMUX/TERMUX-PACKAGES](https://github.com/termux/termux-packages) INSTEAD.** If you are unsure if this is a bug with the Termux app itself or any of the packages, kindly open up the bug at [termux/termux-packages](https://github.com/termux/termux-packages) as it's very likely that it belongs there. Use search before you open an issue to check whether your issue has been already reported and perhaps solved. Android versions 5.x and 6.x are not supported anymore. If you have issues installing packages then please see https://github.com/termux/termux-packages/issues/6726. - type: textarea attributes: label: Problem description description: | A clear and concise description of what the problem is. You may attach the logs, screenshots, screen video recording and whatever else that will help to understand the issue. Issues without proper description will be closed without solution. validations: required: true - type: textarea attributes: label: Steps to reproduce the behavior. description: | Please post all necessary commands that are needed to reproduce the issue. validations: required: true - type: textarea attributes: label: What is the expected behavior? - type: textarea attributes: label: System information description: Please provide info about your device value: | * Termux application version: * Android OS version: * Device model: validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/02-feature-request.yml ================================================ name: "Feature request" description: "Suggest a new feature for Termux application" title: "[Feature]: " labels: ["feature request"] body: - type: textarea attributes: label: Feature description description: Describe the feature and why you want it. validations: required: true - type: textarea attributes: label: Additional information description: | Does another app/terminal emulator have this feature? Provide links to more background information. validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Want ask questions about the project? url: https://github.com/termux/termux-app/discussions about: Join GitHub Discussions ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: daily commit-message: # Prefix all commit messages with "Changed: " prefix: "Changed" ================================================ FILE: .github/workflows/attach_debug_apks_to_release.yml ================================================ name: Attach Debug APKs To Release on: release: types: - published jobs: attach-apks: runs-on: ubuntu-latest strategy: fail-fast: false matrix: package_variant: [ apt-android-7, apt-android-5 ] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Clone repository uses: actions/checkout@v6 with: ref: ${{ env.GITHUB_REF }} - name: Build and attach APKs to release shell: bash {0} env: PACKAGE_VARIANT: ${{ matrix.package_variant }} run: | exit_on_error() { echo "$1" echo "Deleting '$RELEASE_VERSION_NAME' release and '$GITHUB_REF' tag" hub release delete "$RELEASE_VERSION_NAME" git push --delete origin "$GITHUB_REF" exit 1 } echo "Setting vars" RELEASE_VERSION_NAME="${GITHUB_REF/refs\/tags\//}" if ! printf "%s" "${RELEASE_VERSION_NAME/v/}" | grep -qP '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'; then exit_on_error "The versionName '${RELEASE_VERSION_NAME/v/}' is not a valid version as per semantic version '2.0.0' spec in the format 'major.minor.patch(-prerelease)(+buildmetadata)'. https://semver.org/spec/v2.0.0.html." fi APK_DIR_PATH="./app/build/outputs/apk/debug" APK_VERSION_TAG="$RELEASE_VERSION_NAME+${{ env.PACKAGE_VARIANT }}-github-debug" APK_BASENAME_PREFIX="termux-app_$APK_VERSION_TAG" echo "Building APKs for 'APK_VERSION_TAG' release" export TERMUX_APK_VERSION_TAG="$APK_VERSION_TAG" # Used by app/build.gradle export TERMUX_PACKAGE_VARIANT="${{ env.PACKAGE_VARIANT }}" # Used by app/build.gradle if ! ./gradlew assembleDebug; then exit_on_error "Build failed for '$APK_VERSION_TAG' release." fi echo "Validating APKs" for abi in universal arm64-v8a armeabi-v7a x86_64 x86; do if ! test -f "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_$abi.apk"; then files_found="$(ls "$APK_DIR_PATH")" exit_on_error "Failed to find built APK at '$APK_DIR_PATH/${APK_BASENAME_PREFIX}_$abi.apk'. Files found: "$'\n'"$files_found" fi done echo "Generating sha25sums file" if ! (cd "$APK_DIR_PATH"; sha256sum \ "${APK_BASENAME_PREFIX}_universal.apk" \ "${APK_BASENAME_PREFIX}_arm64-v8a.apk" \ "${APK_BASENAME_PREFIX}_armeabi-v7a.apk" \ "${APK_BASENAME_PREFIX}_x86_64.apk" \ "${APK_BASENAME_PREFIX}_x86.apk" \ > "${APK_BASENAME_PREFIX}_sha256sums"); then exit_on_error "Generate sha25sums failed for '$APK_VERSION_TAG' release." fi echo "Attaching APKs to github release" if ! hub release edit \ -m "" \ -a "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_universal.apk" \ -a "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_arm64-v8a.apk" \ -a "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_armeabi-v7a.apk" \ -a "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_x86_64.apk" \ -a "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_x86.apk" \ -a "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_sha256sums" \ "$RELEASE_VERSION_NAME"; then exit_on_error "Attach APKs to release failed for '$APK_VERSION_TAG' release." fi ================================================ FILE: .github/workflows/debug_build.yml ================================================ name: Build on: push: branches: - master - 'github-releases/**' pull_request: branches: - master jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: package_variant: [ apt-android-7, apt-android-5 ] steps: - name: Clone repository uses: actions/checkout@v6 - name: Setup java 17 uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' - name: Build APKs shell: bash {0} env: PACKAGE_VARIANT: ${{ matrix.package_variant }} run: | exit_on_error() { echo "$1"; exit 1; } echo "Setting vars" if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then GITHUB_SHA="${{ github.event.pull_request.head.sha }}" # Do not use last merge commit set in GITHUB_SHA fi # Set RELEASE_VERSION_NAME to "+" CURRENT_VERSION_NAME_REGEX='\s+versionName "([^"]+)"$' CURRENT_VERSION_NAME="$(grep -m 1 -E "$CURRENT_VERSION_NAME_REGEX" ./app/build.gradle | sed -r "s/$CURRENT_VERSION_NAME_REGEX/\1/")" RELEASE_VERSION_NAME="v$CURRENT_VERSION_NAME+${GITHUB_SHA:0:7}" # The "+" is necessary so that versioning precedence is not affected if ! printf "%s" "${RELEASE_VERSION_NAME/v/}" | grep -qP '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'; then exit_on_error "The versionName '${RELEASE_VERSION_NAME/v/}' is not a valid version as per semantic version '2.0.0' spec in the format 'major.minor.patch(-prerelease)(+buildmetadata)'. https://semver.org/spec/v2.0.0.html." fi APK_DIR_PATH="./app/build/outputs/apk/debug" APK_VERSION_TAG="$RELEASE_VERSION_NAME-${{ env.PACKAGE_VARIANT }}-github-debug" # Note the "-", GITHUB_SHA will already have "+" before it APK_BASENAME_PREFIX="termux-app_$APK_VERSION_TAG" # Used by attachment steps later echo "APK_DIR_PATH=$APK_DIR_PATH" >> $GITHUB_ENV echo "APK_VERSION_TAG=$APK_VERSION_TAG" >> $GITHUB_ENV echo "APK_BASENAME_PREFIX=$APK_BASENAME_PREFIX" >> $GITHUB_ENV echo "Building APKs for 'APK_VERSION_TAG' build" export TERMUX_APP_VERSION_NAME="${RELEASE_VERSION_NAME/v/}" # Used by app/build.gradle export TERMUX_APK_VERSION_TAG="$APK_VERSION_TAG" # Used by app/build.gradle export TERMUX_PACKAGE_VARIANT="${{ env.PACKAGE_VARIANT }}" # Used by app/build.gradle if ! ./gradlew assembleDebug; then exit_on_error "Build failed for '$APK_VERSION_TAG' build." fi echo "Validating APKs" for abi in universal arm64-v8a armeabi-v7a x86_64 x86; do if ! test -f "$APK_DIR_PATH/${APK_BASENAME_PREFIX}_$abi.apk"; then files_found="$(ls "$APK_DIR_PATH")" exit_on_error "Failed to find built APK at '$APK_DIR_PATH/${APK_BASENAME_PREFIX}_$abi.apk'. Files found: "$'\n'"$files_found" fi done echo "Generating sha25sums file" if ! (cd "$APK_DIR_PATH"; sha256sum \ "${APK_BASENAME_PREFIX}_universal.apk" \ "${APK_BASENAME_PREFIX}_arm64-v8a.apk" \ "${APK_BASENAME_PREFIX}_armeabi-v7a.apk" \ "${APK_BASENAME_PREFIX}_x86_64.apk" \ "${APK_BASENAME_PREFIX}_x86.apk" \ > "${APK_BASENAME_PREFIX}_sha256sums"); then exit_on_error "Generate sha25sums failed for '$APK_VERSION_TAG' release." fi - name: Attach universal APK file uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_universal path: | ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_universal.apk ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach arm64-v8a APK file uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_arm64-v8a path: | ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_arm64-v8a.apk ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach armeabi-v7a APK file uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_armeabi-v7a path: | ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_armeabi-v7a.apk ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach x86_64 APK file uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_x86_64 path: | ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_x86_64.apk ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach x86 APK file uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_x86 path: | ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_x86.apk ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach sha256sums file uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_sha256sums path: | ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_sha256sums ${{ env.APK_DIR_PATH }}/output-metadata.json ================================================ FILE: .github/workflows/dependency-submission.yml ================================================ name: Automatic Dependency Submission on: push: branches: [ 'master' ] workflow_dispatch: permissions: contents: write jobs: dependency-submission: runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v6 - name: Setup Java uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: 17 - name: Generate and submit dependency graph uses: gradle/actions/dependency-submission@v5 ================================================ FILE: .github/workflows/gradle-wrapper-validation.yml ================================================ name: "Validate Gradle Wrapper" on: push: branches: - master - android-10 pull_request: branches: - master - android-10 jobs: validation: name: "Validation" runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: gradle/actions/wrapper-validation@5 ================================================ FILE: .github/workflows/run_tests.yml ================================================ name: Unit tests on: push: branches: - master - android-10 pull_request: branches: - master - android-10 jobs: testing: runs-on: ubuntu-latest steps: - name: Clone repository uses: actions/checkout@v6 - name: Setup java 17 uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' - name: Execute tests run: | ./gradlew test ================================================ FILE: .github/workflows/trigger_library_builds_on_jitpack.yml ================================================ name: Trigger Termux Library Builds on Jitpack on: release: types: - published jobs: trigger-termux-library-builds: runs-on: ubuntu-latest steps: - name: Set vars run: echo "TERMUX_LIB_VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV # Do not include "v" prefix - name: Echo release run: echo "Triggering termux library builds on jitpack for '$TERMUX_LIB_VERSION' release after waiting for 3 mins" - name: Trigger termux library builds on jitpack run: | sleep 180 # It will take some time for the new tag to be detected by Jitpack curl --max-time 600 --no-progress-meter "https://jitpack.io/com/termux/termux-app/terminal-emulator/$TERMUX_LIB_VERSION/terminal-emulator-$TERMUX_LIB_VERSION.pom" curl --max-time 600 --no-progress-meter "https://jitpack.io/com/termux/termux-app/terminal-view/$TERMUX_LIB_VERSION/terminal-view-$TERMUX_LIB_VERSION.pom" curl --max-time 600 --no-progress-meter "https://jitpack.io/com/termux/termux-app/termux-shared/$TERMUX_LIB_VERSION/termux-shared-$TERMUX_LIB_VERSION.pom" ================================================ FILE: .gitignore ================================================ # From https://gist.github.com/iainconnor/8605514 # with the addition of the /captures below. /captures # Built application files build/ release/ *.apk *.so .externalNativeBuild .cxx *.zip # Local configuration file (sdk path, etc) local.properties # Gradle generated files .gradle/ # Signing files .signing/ # Intellij .idea/ *.iml # Vim *.swo *.swp # OS-specific files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes .swp ehthumbs.db Thumbs.db ================================================ FILE: LICENSE.md ================================================ The `termux/termux-app` repository is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license. ### Exceptions - [Terminal Emulator for Android](https://github.com/jackpal/Android-Terminal-Emulator) code is used which is released under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) license. Check [`terminal-view`](terminal-view) and [`terminal-emulator`](terminal-emulator) libraries. - Check [`termux-shared/LICENSE.md`](termux-shared/LICENSE.md) for `termux-shared` library related exceptions. ================================================ FILE: README.md ================================================ # Termux application [![Build status](https://github.com/termux/termux-app/workflows/Build/badge.svg)](https://github.com/termux/termux-app/actions) [![Testing status](https://github.com/termux/termux-app/workflows/Unit%20tests/badge.svg)](https://github.com/termux/termux-app/actions) [![Join the chat at https://gitter.im/termux/termux](https://badges.gitter.im/termux/termux.svg)](https://gitter.im/termux/termux) [![Join the Termux discord server](https://img.shields.io/discord/641256914684084234.svg?label=&logo=discord&logoColor=ffffff&color=5865F2)](https://discord.gg/HXpF69X) [![Termux library releases at Jitpack](https://jitpack.io/v/termux/termux-app.svg)](https://jitpack.io/#termux/termux-app) [Termux](https://termux.dev) is an Android terminal application and Linux environment. Note that this repository is for the app itself (the user interface and the terminal emulation). For the packages installable inside the app, see [termux/termux-packages](https://github.com/termux/termux-packages). Quick how-to about Termux package management is available at [Package Management](https://github.com/termux/termux-packages/wiki/Package-Management). It also has info on how to fix **`repository is under maintenance or down`** errors when running `apt` or `pkg` commands. **We are looking for Termux Android application maintainers.** *** **NOTICE: Termux may be unstable on Android 12+.** Android OS will kill any (phantom) processes greater than 32 (limit is for all apps combined) and also kill any processes using excessive CPU. You may get `[Process completed (signal 9) - press Enter]` message in the terminal without actually exiting the shell process yourself. Check the related issue [#2366](https://github.com/termux/termux-app/issues/2366), [issue tracker](https://issuetracker.google.com/u/1/issues/205156966), [phantom cached and empty processes docs](https://github.com/agnostic-apollo/Android-Docs/blob/master/en/docs/apps/processes/phantom-cached-and-empty-processes.md) and [this TLDR comment](https://github.com/termux/termux-app/issues/2366#issuecomment-1237468220) on how to disable trimming of phantom and excessive cpu usage processes. A proper docs page will be added later. An option to disable the killing should be available in Android 12L or 13, so upgrade at your own risk if you are on Android 11, specially if you are not rooted. *** ## Contents - [Termux App and Plugins](#termux-app-and-plugins) - [Installation](#installation) - [Uninstallation](#uninstallation) - [Important Links](#important-links) - [Debugging](#debugging) - [For Maintainers and Contributors](#for-maintainers-and-contributors) - [Forking](#forking) - [Sponsors and Funders](#sponsors-and-funders) ## ## Termux App and Plugins The core [Termux](https://github.com/termux/termux-app) app comes with the following optional plugin apps. - [Termux:API](https://github.com/termux/termux-api) - [Termux:Boot](https://github.com/termux/termux-boot) - [Termux:Float](https://github.com/termux/termux-float) - [Termux:Styling](https://github.com/termux/termux-styling) - [Termux:Tasker](https://github.com/termux/termux-tasker) - [Termux:Widget](https://github.com/termux/termux-widget) ## ## Installation Latest version is `v0.118.3`. **NOTICE: It is highly recommended that you update to `v0.118.0` or higher ASAP for various bug fixes, including a critical world-readable vulnerability reported [here](https://termux.github.io/general/2022/02/15/termux-apps-vulnerability-disclosures.html). See [below](#google-play-store-experimental-branch) for information regarding Termux on Google Play.** Termux can be obtained through various sources listed below for **only** Android `>= 7` with full support for apps and packages. Support for both app and packages was dropped for Android `5` and `6` on [2020-01-01](https://www.reddit.com/r/termux/comments/dnzdbs/end_of_android56_support_on_20200101/) at `v0.83`, however it was re-added just for the app *without any support for package updates* on [2022-05-24](https://github.com/termux/termux-app/pull/2740) via the [GitHub](#github) sources. Check [here](https://github.com/termux/termux-app/wiki/Termux-on-android-5-or-6) for the details. The APK files of different sources are signed with different signature keys. The `Termux` app and all its plugins use the same [`sharedUserId`](https://developer.android.com/guide/topics/manifest/manifest-element) `com.termux` and so all their APKs installed on a device must have been signed with the same signature key to work together and so they must all be installed from the same source. Do not attempt to mix them together, i.e do not try to install an app or plugin from `F-Droid` and another one from a different source like `GitHub`. Android Package Manager will also normally not allow installation of APKs with different signatures and you will get errors on installation like `App not installed`, `Failed to install due to an unknown error`, `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, `INSTALL_FAILED_SHARED_USER_INCOMPATIBLE`, `signatures do not match previously installed version`, etc. This restriction can be bypassed with root or with custom roms. If you wish to install from a different source, then you must **uninstall any and all existing Termux or its plugin app APKs** from your device first, then install all new APKs from the same new source. Check [Uninstallation](#uninstallation) section for details. You may also want to consider [Backing up Termux](https://wiki.termux.dev/wiki/Backing_up_Termux) before the uninstallation so that you can restore it after re-installing from Termux different source. In the following paragraphs, *"bootstrap"* refers to the minimal packages that are shipped with the `termux-app` itself to start a working shell environment. Its zips are built and released [here](https://github.com/termux/termux-packages/releases). ### F-Droid Termux application can be obtained from `F-Droid` from [here](https://f-droid.org/en/packages/com.termux/). You **do not** need to download the `F-Droid` app (via the `Download F-Droid` link) to install Termux. You can download the Termux APK directly from the site by clicking the `Download APK` link at the bottom of each version section. It usually takes a few days (or even a week or more) for updates to be available on `F-Droid` once an update has been released on `GitHub`. The `F-Droid` releases are built and published by `F-Droid` once they [detect](https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.termux.yml) a new `GitHub` release. The Termux maintainers **do not** have any control over the building and publishing of the Termux apps on `F-Droid`. Moreover, the Termux maintainers also do not have access to the APK signing keys of `F-Droid` releases, so we cannot release an APK ourselves on `GitHub` that would be compatible with `F-Droid` releases. The `F-Droid` app often may not notify you of updates and you will manually have to do a pull down swipe action in the `Updates` tab of the app for it to check updates. Make sure battery optimizations are disabled for the app, check https://dontkillmyapp.com/ for details on how to do that. Only a universal APK is released, which will work on all supported architectures. The APK and bootstrap installation size will be `~180MB`. `F-Droid` does [not support](https://github.com/termux/termux-app/pull/1904) architecture specific APKs. ### GitHub Termux application can be obtained on `GitHub` either from [`GitHub Releases`](https://github.com/termux/termux-app/releases) for version `>= 0.118.0` or from [`GitHub Build Action`](https://github.com/termux/termux-app/actions/workflows/debug_build.yml?query=branch%3Amaster+event%3Apush) workflows. **For android `>= 7`, only install `apt-android-7` variants. For android `5` and `6`, only install `apt-android-5` variants.** The APKs for `GitHub Releases` will be listed under `Assets` drop-down of a release. These are automatically attached when a new version is released. The APKs for `GitHub Build` action workflows will be listed under `Artifacts` section of a workflow run. These are created for each commit/push done to the repository and can be used by users who don't want to wait for releases and want to try out the latest features immediately or want to test their pull requests. Note that for action workflows, you need to be [**logged into a `GitHub` account**](https://github.com/login) for the `Artifacts` links to be enabled/clickable. If you are using the [`GitHub` app](https://github.com/mobile), then make sure to open workflow link in a browser like Chrome or Firefox that has your GitHub account logged in since the in-app browser may not be logged in. The APKs for both of these are [`debuggable`](https://developer.android.com/studio/debug) and are compatible with each other but they are not compatible with other sources. Both universal and architecture specific APKs are released. The APK and bootstrap installation size will be `~180MB` if using universal and `~120MB` if using architecture specific. Check [here](https://github.com/termux/termux-app/issues/2153) for details. **Security warning**: APK files on GitHub are signed with a test key that has been [shared with community](https://github.com/termux/termux-app/blob/master/app/testkey_untrusted.jks). This IS NOT an official developer key and everyone can use it to generate releases for own testing. Be very careful when using Termux GitHub builds obtained elsewhere except https://github.com/termux/termux-app. Everyone is able to use it to forge a malicious Termux update installable over the GitHub build. Think twice about installing Termux builds distributed via Telegram or other social media. If your device get caught by malware, we will not be able to help you. The [test key](https://github.com/termux/termux-app/blob/master/app/testkey_untrusted.jks) shall not be used to impersonate @termux and can't be used for this anyway. This key is not trusted by us and it is quite easy to detect its use in user generated content.
Keystore information ``` Alias name: alias Creation date: Oct 4, 2019 Entry type: PrivateKeyEntry Certificate chain length: 1 Certificate[1]: Owner: CN=APK Signer, OU=Earth, O=Earth Issuer: CN=APK Signer, OU=Earth, O=Earth Serial number: 29be297b Valid from: Wed Sep 04 02:03:24 EEST 2019 until: Tue Oct 26 02:03:24 EEST 2049 Certificate fingerprints: SHA1: 51:79:55:EA:BF:69:FC:05:7C:41:C7:D3:79:DB:BC:EF:20:AD:85:F2 SHA256: B6:DA:01:48:0E:EF:D5:FB:F2:CD:37:71:B8:D1:02:1E:C7:91:30:4B:DD:6C:4B:F4:1D:3F:AA:BA:D4:8E:E5:E1 Signature algorithm name: SHA1withRSA (disabled) Subject Public Key Algorithm: 2048-bit RSA key Version: 3 ```
### Google Play Store **(Experimental branch)** There is currently a build of Termux available on Google Play for Android 11+ devices, with extensive adjustments in order to pass policy requirements there. This is under development and has missing functionality and bugs (see [here](https://github.com/termux-play-store/) for status updates) compared to the stable F-Droid build, which is why most users who can should still use F-Droid or GitHub build as mentioned above. Currently, Google Play will try to update installations away from F-Droid ones. Updating will still fail as [sharedUserId](https://developer.android.com/guide/topics/manifest/manifest-element#uid) has been removed. A planned 0.118.1 F-Droid release will fix this by setting a higher version code than used for the PlayStore app. Meanwhile, to prevent Google Play from attempting to download and then fail to install the Google Play releases over existing installations, you can open the Termux apps pages on Google Play and then click on the 3 dots options button in the top right and then disable the Enable auto update toggle. However, the Termux apps updates will still show in the PlayStore app updates list. If you want to help out with testing the Google Play build (or cannot install Termux from other sources), be aware that it's built from a separate repository (https://github.com/termux-play-store/) - be sure to report issues [there](https://github.com/termux-play-store/termux-issues/issues/new/choose), as any issues encountered might very well be specific to that repository. ## Uninstallation Uninstallation may be required if a user doesn't want Termux installed in their device anymore or is switching to a different [install source](#installation). You may also want to consider [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) before the uninstallation. To uninstall Termux completely, you must uninstall **any and all existing Termux or its plugin app APKs** listed in [Termux App and Plugins](#termux-app-and-plugins). Go to `Android Settings` -> `Applications` and then look for those apps. You can also use the search feature if it’s available on your device and search `termux` in the applications list. Even if you think you have not installed any of the plugins, it's strongly suggested to go through the application list in Android settings and double-check. ## ## Important Links ### Community All community links are available [here](https://wiki.termux.com/wiki/Community). The main ones are the following. - [Termux Reddit community](https://reddit.com/r/termux) - [Termux User Matrix Channel](https://matrix.to/#/#termux_termux:gitter.im) ([Gitter](https://gitter.im/termux/termux)) - [Termux Dev Matrix Channel](https://matrix.to/#/#termux_dev:gitter.im) ([Gitter](https://gitter.im/termux/dev)) - [Termux X (Twitter)](https://twitter.com/termuxdevs) - [Termux Support Email](mailto:support@termux.dev) ### Wikis - [Termux Wiki](https://wiki.termux.com/wiki/) - [Termux App Wiki](https://github.com/termux/termux-app/wiki) - [Termux Packages Wiki](https://github.com/termux/termux-packages/wiki) ### Miscellaneous - [FAQ](https://wiki.termux.com/wiki/FAQ) - [Termux File System Layout](https://github.com/termux/termux-packages/wiki/Termux-file-system-layout) - [Differences From Linux](https://wiki.termux.com/wiki/Differences_from_Linux) - [Package Management](https://wiki.termux.com/wiki/Package_Management) - [Remote Access](https://wiki.termux.com/wiki/Remote_Access) - [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) - [Terminal Settings](https://wiki.termux.com/wiki/Terminal_Settings) - [Touch Keyboard](https://wiki.termux.com/wiki/Touch_Keyboard) - [Android Storage and Sharing Data with Other Apps](https://wiki.termux.com/wiki/Internal_and_external_storage) - [Android APIs](https://wiki.termux.com/wiki/Termux:API) - [Moved Termux Packages Hosting From Bintray to IPFS](https://github.com/termux/termux-packages/issues/6348) - [Running Commands in Termux From Other Apps via `RUN_COMMAND` intent](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent) - [Termux and Android 10](https://github.com/termux/termux-packages/wiki/Termux-and-Android-10) ### Terminal
### Terminal resources - [XTerm control sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) - [vt100.net](https://vt100.net/) - [Terminal codes (ANSI and terminfo equivalents)](https://wiki.bash-hackers.org/scripting/terminalcodes) ### Terminal emulators - VTE (libvte): Terminal emulator widget for GTK+, mainly used in gnome-terminal. [Source](https://github.com/GNOME/vte), [Open Issues](https://bugzilla.gnome.org/buglist.cgi?quicksearch=product%3A%22vte%22+), and [All (including closed) issues](https://bugzilla.gnome.org/buglist.cgi?bug_status=RESOLVED&bug_status=VERIFIED&chfield=resolution&chfieldfrom=-2000d&chfieldvalue=FIXED&product=vte&resolution=FIXED). - iTerm 2: OS X terminal application. [Source](https://github.com/gnachman/iTerm2), [Issues](https://gitlab.com/gnachman/iterm2/issues) and [Documentation](https://iterm2.com/documentation.html) (which includes [iTerm2 proprietary escape codes](https://iterm2.com/documentation-escape-codes.html)). - Konsole: KDE terminal application. [Source](https://projects.kde.org/projects/kde/applications/konsole/repository), in particular [tests](https://projects.kde.org/projects/kde/applications/konsole/repository/revisions/master/show/tests), [Bugs](https://bugs.kde.org/buglist.cgi?bug_severity=critical&bug_severity=grave&bug_severity=major&bug_severity=crash&bug_severity=normal&bug_severity=minor&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&product=konsole) and [Wishes](https://bugs.kde.org/buglist.cgi?bug_severity=wishlist&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&product=konsole). - hterm: JavaScript terminal implementation from Chromium. [Source](https://github.com/chromium/hterm), including [tests](https://github.com/chromium/hterm/blob/master/js/hterm_vt_tests.js), and [Google group](https://groups.google.com/a/chromium.org/forum/#!forum/chromium-hterm). - xterm: The grandfather of terminal emulators. [Source](https://invisible-island.net/datafiles/release/xterm.tar.gz). - Connectbot: Android SSH client. [Source](https://github.com/connectbot/connectbot) - Android Terminal Emulator: Android terminal app which Termux terminal handling is based on. Inactive. [Source](https://github.com/jackpal/Android-Terminal-Emulator).
## ### Debugging You can help debug problems of the `Termux` app and its plugins by setting appropriate `logcat` `Log Level` in `Termux` app settings -> `` -> `Debugging` -> `Log Level` (Requires `Termux` app version `>= 0.118.0`). The `Log Level` defaults to `Normal` and log level `Verbose` currently logs additional information. Its best to revert log level to `Normal` after you have finished debugging since private data may otherwise be passed to `logcat` during normal operation and moreover, additional logging increases execution time. The plugin apps **do not execute the commands themselves** but send execution intents to `Termux` app, which has its own log level which can be set in `Termux` app settings -> `Termux` -> `Debugging` -> `Log Level`. So you must set log level for both `Termux` and the respective plugin app settings to get all the info. Once log levels have been set, you can run the `logcat` command in `Termux` app terminal to view the logs in realtime (`Ctrl+c` to stop) or use `logcat -d > logcat.txt` to take a dump of the log. You can also view the logs from a PC over `ADB`. For more information, check official android `logcat` guide [here](https://developer.android.com/studio/command-line/logcat). Moreover, users can generate termux files `stat` info and `logcat` dump automatically too with terminal's long hold options menu `More` -> `Report Issue` option and selecting `YES` in the prompt shown to add debug info. This can be helpful for reporting and debugging other issues. If the report generated is too large, then `Save To File` option in context menu (3 dots on top right) of `ReportActivity` can be used and the file viewed/shared instead. Users must post complete report (optionally without sensitive info) when reporting issues. Issues opened with **(partial) screenshots of error reports** instead of text will likely be automatically closed/deleted. ##### Log Levels - `Off` - Log nothing. - `Normal` - Start logging error, warn and info messages and stacktraces. - `Debug` - Start logging debug messages. - `Verbose` - Start logging verbose messages. ## ## For Maintainers and Contributors The [termux-shared](termux-shared) library was added in [`v0.109`](https://github.com/termux/termux-app/releases/tag/v0.109). It defines shared constants and utils of the Termux app and its plugins. It was created to allow for the removal of all hardcoded paths in the Termux app. Some of the termux plugins are using this as well and rest will in future. If you are contributing code that is using a constant or a util that may be shared, then define it in `termux-shared` library if it currently doesn't exist and reference it from there. Update the relevant changelogs as well. Pull requests using hardcoded values **will/should not** be accepted. Termux app and plugin specific classes must be added under `com.termux.shared.termux` package and general classes outside it. The [`termux-shared` `LICENSE`](termux-shared/LICENSE.md) must also be checked and updated if necessary when contributing code. The licenses of any external library or code must be honoured. The main Termux constants are defined by [`TermuxConstants`](https://github.com/termux/termux-app/blob/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java) class. It also contains information on how to fork Termux or build it with your own package name. Changing the package name will require building the bootstrap zip packages and other packages with the new `$PREFIX`, check [Building Packages](https://github.com/termux/termux-packages/wiki/Building-packages) for more info. Check [Termux Libraries](https://github.com/termux/termux-app/wiki/Termux-Libraries) for how to import termux libraries in plugin apps and [Forking and Local Development](https://github.com/termux/termux-app/wiki/Termux-Libraries#forking-and-local-development) for how to update termux libraries for plugins. The `versionName` in `build.gradle` files of Termux and its plugin apps must follow the [semantic version `2.0.0` spec](https://semver.org/spec/v2.0.0.html) in the format `major.minor.patch(-prerelease)(+buildmetadata)`. When bumping `versionName` in `build.gradle` files and when creating a tag for new releases on GitHub, make sure to include the patch number as well, like `v0.1.0` instead of just `v0.1`. The `build.gradle` files and `attach_debug_apks_to_release` workflow validates the version as well and the build/attachment will fail if `versionName` does not follow the spec. ### Commit Messages Guidelines Commit messages **must** use the [Conventional Commits](https://www.conventionalcommits.org) spec so that chagelogs as per the [Keep a Changelog](https://github.com/olivierlacan/keep-a-changelog) spec can automatically be generated by the [`create-conventional-changelog`](https://github.com/termux/create-conventional-changelog) script, check its repo for further details on the spec. **The first letter for `type` and `description` must be capital and description should be in the present tense.** The space after the colon `:` is necessary. For a breaking change, add an exclamation mark `!` before the colon `:`, so that it is highlighted in the chagelog automatically. ``` [optional scope]: [optional body] [optional footer(s)] ``` **Only the `types` listed below must be used exactly as they are used in the changelog headings.** For example, `Added: Add foo`, `Added|Fixed: Add foo and fix bar`, `Changed!: Change baz as a breaking change`, etc. You can optionally add a scope as well, like `Fixed(terminal): Fix some bug`. **Do not use anything else as type, like `add` instead of `Added`, etc.** - **Added** for new features. - **Changed** for changes in existing functionality. - **Deprecated** for soon-to-be removed features. - **Removed** for now removed features. - **Fixed** for any bug fixes. - **Security** in case of vulnerabilities. ## ## Forking - Check [`TermuxConstants`](https://github.com/termux/termux-app/blob/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java) javadocs for instructions on what changes to make in the app to change package name. - You also need to recompile bootstrap zip for the new package name. Check [building bootstrap](https://github.com/termux/termux-packages/wiki/For-maintainers#build-bootstrap-archives), [here](https://github.com/termux/termux-app/issues/1983) and [here](https://github.com/termux/termux-app/issues/2081#issuecomment-865280111). - Currently, not all plugins use `TermuxConstants` from `termux-shared` library and have hardcoded `com.termux` values and will need to be manually patched. - If forking termux plugins, check [Forking and Local Development](https://github.com/termux/termux-app/wiki/Termux-Libraries#forking-and-local-development) for info on how to use termux libraries for plugins. ## ## Sponsors and Funders [GitHub Accelerator](https://github.com) *[GitHub Accelerator](https://github.com/accelerator) ([1](https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next))*   [GitHub Secure Open Source Fund](https://github.com) *[GitHub Secure Open Source Fund](https://resources.github.com/github-secure-open-source-fund) ([1](https://github.blog/open-source/maintainers/securing-the-supply-chain-at-scale-starting-with-71-important-open-source-projects), [2](https://termux.dev/en/posts/general/2025/08/11/termux-selected-for-github-secure-open-source-fund-session-2.html))*   [NLnet NGI Mobifree](https://nlnet.nl/mobifree) *[NLnet NGI Mobifree](https://nlnet.nl/mobifree) ([1](https://nlnet.nl/news/2024/20241111-NGI-Mobifree-grants.html), [2](https://termux.dev/en/posts/general/2024/11/11/termux-selected-for-nlnet-ngi-mobifree-grant.html))*   [Cloudflare](https://www.cloudflare.com) *[Cloudflare](https://www.cloudflare.com) ([1](https://packages-cf.termux.dev))*   [Warp](https://www.warp.dev/?utm_source=github&utm_medium=readme&utm_campaign=termux) [*Warp, built for coding with multiple AI agents*](https://www.warp.dev/?utm_source=github&utm_medium=readme&utm_campaign=termux) ================================================ FILE: SECURITY.md ================================================ Check https://termux.dev/security for info on Termux security policies and how to report vulnerabilities. ================================================ FILE: app/build.gradle ================================================ plugins { id "com.android.application" } ext { // The packageVariant defines the bootstrap variant that will be included in the app APK. // This must be supported by com.termux.shared.termux.TermuxBootstrap.PackageVariant or app will // crash at startup. // Bootstrap of a different variant must not be manually installed by the user after app installation // by replacing $PREFIX since app code is dependant on the variant used to build the APK. // Currently supported values are: [ "apt-android-7" "apt-android-5" ] packageVariant = System.getenv("TERMUX_PACKAGE_VARIANT") ?: "apt-android-7" // Default: "apt-android-7" } android { namespace "com.termux" compileSdkVersion project.properties.compileSdkVersion.toInteger() ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion def appVersionName = System.getenv("TERMUX_APP_VERSION_NAME") ?: "" def apkVersionTag = System.getenv("TERMUX_APK_VERSION_TAG") ?: "" def splitAPKsForDebugBuilds = System.getenv("TERMUX_SPLIT_APKS_FOR_DEBUG_BUILDS") ?: "1" def splitAPKsForReleaseBuilds = System.getenv("TERMUX_SPLIT_APKS_FOR_RELEASE_BUILDS") ?: "0" // F-Droid does not support split APKs #1904 dependencies { implementation "androidx.annotation:annotation:1.9.0" implementation "androidx.core:core:1.13.1" implementation "androidx.drawerlayout:drawerlayout:1.2.0" implementation "androidx.preference:preference:1.2.1" implementation "androidx.viewpager:viewpager:1.0.0" implementation "com.google.android.material:material:1.12.0" implementation "com.google.guava:guava:24.1-jre" implementation "io.noties.markwon:core:$markwonVersion" implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" implementation "io.noties.markwon:linkify:$markwonVersion" implementation "io.noties.markwon:recycler:$markwonVersion" implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' implementation project(":terminal-view") implementation project(":termux-shared") } defaultConfig { minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() versionCode 118 versionName "0.118.0" if (appVersionName) versionName = appVersionName validateVersionName(versionName) buildConfigField "String", "TERMUX_PACKAGE_VARIANT", "\"" + project.ext.packageVariant + "\"" // Used by TermuxApplication class manifestPlaceholders.TERMUX_PACKAGE_NAME = "com.termux" manifestPlaceholders.TERMUX_APP_NAME = "Termux" manifestPlaceholders.TERMUX_API_APP_NAME = "Termux:API" manifestPlaceholders.TERMUX_BOOT_APP_NAME = "Termux:Boot" manifestPlaceholders.TERMUX_FLOAT_APP_NAME = "Termux:Float" manifestPlaceholders.TERMUX_STYLING_APP_NAME = "Termux:Styling" manifestPlaceholders.TERMUX_TASKER_APP_NAME = "Termux:Tasker" manifestPlaceholders.TERMUX_WIDGET_APP_NAME = "Termux:Widget" externalNativeBuild { ndkBuild { cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections" } } splits { abi { enable ((gradle.startParameter.taskNames.any { it.contains("Debug") } && splitAPKsForDebugBuilds == "1") || (gradle.startParameter.taskNames.any { it.contains("Release") } && splitAPKsForReleaseBuilds == "1")) reset () include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' universalApk true } } } signingConfigs { debug { storeFile file('testkey_untrusted.jks') keyAlias 'alias' storePassword 'xrj45yWGLbsO7W0v' keyPassword 'xrj45yWGLbsO7W0v' } } buildTypes { release { minifyEnabled true shrinkResources false // Reproducible builds proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } debug { signingConfig signingConfigs.debug } } compileOptions { // Flag to enable support for the new language APIs coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } externalNativeBuild { ndkBuild { path "src/main/cpp/Android.mk" } } lint { disable 'ProtectedPermissions' } testOptions { unitTests { includeAndroidResources = true } } packagingOptions { jniLibs { useLegacyPackaging true } } applicationVariants.all { variant -> variant.outputs.all { output -> if (variant.buildType.name == "debug") { def abi = output.getFilter(com.android.build.OutputFile.ABI) outputFileName = new File("termux-app_" + (apkVersionTag ? apkVersionTag : project.ext.packageVariant + "-" + "debug") + "_" + (abi ? abi : "universal") + ".apk") } else if (variant.buildType.name == "release") { def abi = output.getFilter(com.android.build.OutputFile.ABI) outputFileName = new File("termux-app_" + (apkVersionTag ? apkVersionTag : project.ext.packageVariant + "-" + "release") + "_" + (abi ? abi : "universal") + ".apk") } } } buildFeatures { buildConfig true } } dependencies { testImplementation "junit:junit:4.13.2" testImplementation "org.robolectric:robolectric:4.10" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" } task versionName { doLast { print android.defaultConfig.versionName } } def validateVersionName(String versionName) { // https://semver.org/spec/v2.0.0.html#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string // ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ if (!java.util.regex.Pattern.matches("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?\$", versionName)) throw new GradleException("The versionName '" + versionName + "' is not a valid version as per semantic version '2.0.0' spec in the format 'major.minor.patch(-prerelease)(+buildmetadata)'. https://semver.org/spec/v2.0.0.html.") } def downloadBootstrap(String arch, String expectedChecksum, String version) { def digest = java.security.MessageDigest.getInstance("SHA-256") def localUrl = "src/main/cpp/bootstrap-" + arch + ".zip" def file = new File(projectDir, localUrl) if (file.exists()) { def buffer = new byte[8192] def input = new FileInputStream(file) while (true) { def readBytes = input.read(buffer) if (readBytes < 0) break digest.update(buffer, 0, readBytes) } def checksum = new BigInteger(1, digest.digest()).toString(16) while (checksum.length() < 64) { checksum = "0" + checksum } if (checksum == expectedChecksum) { return } else { logger.quiet("Deleting old local file with wrong hash: " + localUrl + ": expected: " + expectedChecksum + ", actual: " + checksum) file.delete() } } def remoteUrl = "https://github.com/termux/termux-packages/releases/download/bootstrap-" + version + "/bootstrap-" + arch + ".zip" logger.quiet("Downloading " + remoteUrl + " ...") file.parentFile.mkdirs() def out = new BufferedOutputStream(new FileOutputStream(file)) def connection = new URL(remoteUrl).openConnection() connection.setInstanceFollowRedirects(true) def digestStream = new java.security.DigestInputStream(connection.inputStream, digest) out << digestStream out.close() def checksum = new BigInteger(1, digest.digest()).toString(16) while (checksum.length() < 64) { checksum = "0" + checksum } if (checksum != expectedChecksum) { file.delete() throw new GradleException("Wrong checksum for " + remoteUrl + ": expected: " + expectedChecksum + ", actual: " + checksum) } } clean { doLast { def tree = fileTree(new File(projectDir, 'src/main/cpp')) tree.include 'bootstrap-*.zip' tree.each { it.delete() } } } task downloadBootstraps() { doLast { def packageVariant = project.ext.packageVariant if (packageVariant == "apt-android-7") { def version = "2026.02.12-r1" + "%2B" + "apt.android-7" downloadBootstrap("aarch64", "ea2aeba8819e517db711f8c32369e89e7c52cee73e07930ff91185e1ab93f4f3", version) downloadBootstrap("arm", "a38f4d3b2f735f83be2bf54eff463e86dc32a3e2f9f861c1557c4378d249c018", version) downloadBootstrap("i686", "f5bc0b025b9f3b420b5fcaeefc064f888f5f22a0d6fd7090f4aac0c33eb3555b", version) downloadBootstrap("x86_64", "b7fd0f2e3a4de534be3144f9f91acc768630fc463eaf134ab2e64c545e834f7a", version) } else if (packageVariant == "apt-android-5") { def version = "2022.04.28-r6" + "+" + packageVariant downloadBootstrap("aarch64", "913609d439415c828c5640be1b0561467e539cb1c7080662decaaca2fb4820e7", version) downloadBootstrap("arm", "26bfb45304c946170db69108e5eb6e3641aad751406ce106c80df80cad2eccf8", version) downloadBootstrap("i686", "46dcfeb5eef67ba765498db9fe4c50dc4690805139aa0dd141a9d8ee0693cd27", version) downloadBootstrap("x86_64", "615b590679ee6cd885b7fd2ff9473c845e920f9b422f790bb158c63fe42b8481", version) } else { throw new GradleException("Unsupported TERMUX_PACKAGE_VARIANT \"" + packageVariant + "\"") } } } afterEvaluate { android.applicationVariants.all { variant -> variant.javaCompileProvider.get().dependsOn(downloadBootstraps) } } ================================================ FILE: app/proguard-rules.pro ================================================ # 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 ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/cpp/Android.mk ================================================ 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) ================================================ FILE: app/src/main/cpp/termux-bootstrap-zip.S ================================================ .global blob .global blob_size .section .rodata blob: #if defined __i686__ .incbin "bootstrap-i686.zip" #elif defined __x86_64__ .incbin "bootstrap-x86_64.zip" #elif defined __aarch64__ .incbin "bootstrap-aarch64.zip" #elif defined __arm__ .incbin "bootstrap-arm.zip" #else # error Unsupported arch #endif 1: blob_size: .int 1b - blob ================================================ FILE: app/src/main/cpp/termux-bootstrap.c ================================================ #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; } ================================================ FILE: app/src/main/java/com/termux/app/RunCommandService.java ================================================ package com.termux.app; import android.app.Notification; import android.app.NotificationManager; import android.app.Service; import android.content.Intent; import android.net.Uri; import android.os.Binder; import android.os.Build; import android.os.IBinder; import com.termux.R; import com.termux.shared.data.DataUtils; import com.termux.shared.data.IntentUtils; import com.termux.shared.termux.plugins.TermuxPluginUtils; import com.termux.shared.termux.file.TermuxFileUtils; import com.termux.shared.file.filesystem.FileType; import com.termux.shared.errors.Errno; import com.termux.shared.errors.Error; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import com.termux.shared.file.FileUtils; 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; /** * A service that receives {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent from third party apps and * plugins that contains info on command execution and forwards the extras to {@link TermuxService} * for the actual execution. * * Check https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent for more info. */ public class RunCommandService extends Service { private static final String LOG_TAG = "RunCommandService"; class LocalBinder extends Binder { public final RunCommandService service = RunCommandService.this; } private final IBinder mBinder = new RunCommandService.LocalBinder(); @Override public IBinder onBind(Intent intent) { return mBinder; } @Override public void onCreate() { Logger.logVerbose(LOG_TAG, "onCreate"); runStartForeground(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { Logger.logDebug(LOG_TAG, "onStartCommand"); if (intent == null) return Service.START_NOT_STICKY; // Run again in case service is already started and onCreate() is not called runStartForeground(); Logger.logVerboseExtended(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); ExecutionCommand executionCommand = new ExecutionCommand(); executionCommand.pluginAPIHelp = this.getString(R.string.error_run_command_service_api_help, RUN_COMMAND_SERVICE.RUN_COMMAND_API_HELP_URL); Error error; String errmsg; // If invalid action passed, then just return if (!RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND.equals(intent.getAction())) { errmsg = this.getString(R.string.error_run_command_service_invalid_intent_action, intent.getAction()); executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); return stopService(); } String executableExtra = executionCommand.executable = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH, null); executionCommand.arguments = IntentUtils.getStringArrayExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS, null); /* * If intent was sent with `am` command, then normal comma characters may have been replaced * with alternate characters if a normal comma existed in an argument itself to prevent it * splitting into multiple arguments by `am` command. * If `tudo` or `sudo` are used, then simply using their `-r` and `--comma-alternative` command * options can be used without passing the below extras, but native supports is helpful if * they are not being used. * https://github.com/agnostic-apollo/tudo#passing-arguments-using-run_command-intent * https://android.googlesource.com/platform/frameworks/base/+/21bdaf1/cmds/am/src/com/android/commands/am/Am.java#572 */ boolean replaceCommaAlternativeCharsInArguments = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS, false); if (replaceCommaAlternativeCharsInArguments) { String commaAlternativeCharsInArguments = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS, null); if (commaAlternativeCharsInArguments == null) commaAlternativeCharsInArguments = TermuxConstants.COMMA_ALTERNATIVE; // Replace any commaAlternativeCharsInArguments characters with normal commas DataUtils.replaceSubStringsInStringArrayItems(executionCommand.arguments, commaAlternativeCharsInArguments, TermuxConstants.COMMA_NORMAL); } executionCommand.stdin = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_STDIN, null); executionCommand.workingDirectory = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_WORKDIR, null); // If EXTRA_RUNNER is passed, use that, otherwise check EXTRA_BACKGROUND and default to Runner.TERMINAL_SESSION executionCommand.runner = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RUNNER, (intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, false) ? Runner.APP_SHELL.getName() : Runner.TERMINAL_SESSION.getName())); if (Runner.runnerOf(executionCommand.runner) == null) { errmsg = this.getString(R.string.error_run_command_service_invalid_execution_command_runner, executionCommand.runner); executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); return stopService(); } executionCommand.backgroundCustomLogLevel = IntentUtils.getIntegerExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, null); executionCommand.sessionAction = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_SESSION_ACTION); executionCommand.shellName = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_SHELL_NAME, null); executionCommand.shellCreateMode = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_SHELL_CREATE_MODE, null); executionCommand.commandLabel = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_LABEL, "RUN_COMMAND Execution Intent Command"); executionCommand.commandDescription = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_DESCRIPTION, null); executionCommand.commandHelp = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_HELP, null); executionCommand.isPluginExecutionCommand = true; executionCommand.resultConfig.resultPendingIntent = intent.getParcelableExtra(RUN_COMMAND_SERVICE.EXTRA_PENDING_INTENT); executionCommand.resultConfig.resultDirectoryPath = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_DIRECTORY, null); if (executionCommand.resultConfig.resultDirectoryPath != null) { executionCommand.resultConfig.resultSingleFile = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_RESULT_SINGLE_FILE, false); executionCommand.resultConfig.resultFileBasename = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_BASENAME, null); executionCommand.resultConfig.resultFileOutputFormat = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_OUTPUT_FORMAT, null); executionCommand.resultConfig.resultFileErrorFormat = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_ERROR_FORMAT, null); executionCommand.resultConfig.resultFilesSuffix = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILES_SUFFIX, null); } // If "allow-external-apps" property to not set to "true", then just return // We enable force notifications if "allow-external-apps" policy is violated so that the // user knows someone tried to run a command in termux context, since it may be malicious // app or imported (tasker) plugin project and not the user himself. If a pending intent is // also sent, then its creator is also logged and shown. errmsg = TermuxPluginUtils.checkIfAllowExternalAppsPolicyIsViolated(this, LOG_TAG); if (errmsg != null) { executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, true); return stopService(); } // If executable is null or empty, then exit here instead of getting canonical path which would expand to "/" if (executionCommand.executable == null || executionCommand.executable.isEmpty()) { errmsg = this.getString(R.string.error_run_command_service_mandatory_extra_missing, RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); return stopService(); } // Get canonical path of executable executionCommand.executable = TermuxFileUtils.getCanonicalPath(executionCommand.executable, null, true); // If executable is not a regular file, or is not readable or executable, then just return // Setting of missing read and execute permissions is not done error = FileUtils.validateRegularFileExistenceAndPermissions("executable", executionCommand.executable, null, FileUtils.APP_EXECUTABLE_FILE_PERMISSIONS, true, true, false); if (error != null) { executionCommand.setStateFailed(error); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); return stopService(); } // If workingDirectory is not null or empty if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) { // Get canonical path of workingDirectory executionCommand.workingDirectory = TermuxFileUtils.getCanonicalPath(executionCommand.workingDirectory, null, true); // If workingDirectory 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 workingDirectory is // under allowed termux working directory paths. // We try to set execute permissions, but ignore if they are missing, since only read and write permissions are required // for working directories. error = TermuxFileUtils.validateDirectoryFileExistenceAndPermissions("working", executionCommand.workingDirectory, true, true, true, false, true); if (error != null) { executionCommand.setStateFailed(error); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); return stopService(); } } // If the executable passed as the extra was an applet for coreutils/busybox, then we must // use it instead of the canonical path above since otherwise arguments would be passed to // coreutils/busybox instead and command would fail. Broken symlinks would already have been // validated so it should be fine to use it. executableExtra = TermuxFileUtils.getExpandedTermuxPath(executableExtra); if (FileUtils.getFileType(executableExtra, false) == FileType.SYMLINK) { Logger.logVerbose(LOG_TAG, "The executableExtra path \"" + executableExtra + "\" is a symlink so using it instead of the canonical path \"" + executionCommand.executable + "\""); executionCommand.executable = executableExtra; } executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(executionCommand.executable).build(); Logger.logVerboseExtended(LOG_TAG, executionCommand.toString()); // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE Intent execIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executionCommand.executableUri); execIntent.setClass(this, TermuxService.class); execIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, executionCommand.arguments); execIntent.putExtra(TERMUX_SERVICE.EXTRA_STDIN, executionCommand.stdin); if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) execIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, executionCommand.workingDirectory); execIntent.putExtra(TERMUX_SERVICE.EXTRA_RUNNER, executionCommand.runner); execIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, DataUtils.getStringFromInteger(executionCommand.backgroundCustomLogLevel, null)); execIntent.putExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION, executionCommand.sessionAction); execIntent.putExtra(TERMUX_SERVICE.EXTRA_SHELL_NAME, executionCommand.shellName); execIntent.putExtra(TERMUX_SERVICE.EXTRA_SHELL_CREATE_MODE, executionCommand.shellCreateMode); execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_LABEL, executionCommand.commandLabel); execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION, executionCommand.commandDescription); execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_HELP, executionCommand.commandHelp); execIntent.putExtra(TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP, executionCommand.pluginAPIHelp); execIntent.putExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT, executionCommand.resultConfig.resultPendingIntent); execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_DIRECTORY, executionCommand.resultConfig.resultDirectoryPath); if (executionCommand.resultConfig.resultDirectoryPath != null) { execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_SINGLE_FILE, executionCommand.resultConfig.resultSingleFile); execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILE_BASENAME, executionCommand.resultConfig.resultFileBasename); execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILE_OUTPUT_FORMAT, executionCommand.resultConfig.resultFileOutputFormat); execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILE_ERROR_FORMAT, executionCommand.resultConfig.resultFileErrorFormat); execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILES_SUFFIX, executionCommand.resultConfig.resultFilesSuffix); } // Start TERMUX_SERVICE and pass it execution intent if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { this.startForegroundService(execIntent); } else { this.startService(execIntent); } return stopService(); } private int stopService() { runStopForeground(); return Service.START_NOT_STICKY; } private void runStartForeground() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { setupNotificationChannel(); startForeground(TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_ID, buildNotification()); } } private void runStopForeground() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { stopForeground(true); } } private Notification buildNotification() { // Build the notification Notification.Builder builder = NotificationUtils.geNotificationBuilder(this, TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_LOW, TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME, null, null, null, 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); return builder.build(); } private void setupNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID, TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); } } ================================================ FILE: app/src/main/java/com/termux/app/TermuxActivity.java ================================================ package com.termux.app; import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.ActivityNotFoundException; 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.net.Uri; 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.widget.EditText; import android.widget.ImageButton; import android.widget.ListView; import android.widget.RelativeLayout; import android.widget.Toast; import com.termux.R; import com.termux.app.api.file.FileReceiverActivity; import com.termux.app.terminal.TermuxActivityRootView; import com.termux.app.terminal.TermuxTerminalSessionActivityClient; import com.termux.app.terminal.io.TermuxTerminalExtraKeys; import com.termux.shared.activities.ReportActivity; import com.termux.shared.activity.ActivityUtils; import com.termux.shared.activity.media.AppCompatActivityUtils; import com.termux.shared.data.IntentUtils; import com.termux.shared.android.PermissionUtils; import com.termux.shared.data.DataUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; import com.termux.app.activities.HelpActivity; import com.termux.app.activities.SettingsActivity; import com.termux.shared.termux.crash.TermuxCrashUtils; import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; import com.termux.app.terminal.TermuxSessionsListViewController; import com.termux.app.terminal.io.TerminalToolbarViewPager; import com.termux.app.terminal.TermuxTerminalViewClient; import com.termux.shared.termux.extrakeys.ExtraKeysView; import com.termux.shared.termux.interact.TextInputDialogUtils; import com.termux.shared.logger.Logger; import com.termux.shared.termux.TermuxUtils; import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; import com.termux.shared.termux.theme.TermuxThemeUtils; import com.termux.shared.theme.NightMode; 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 androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.drawerlayout.widget.DrawerLayout; import androidx.viewpager.widget.ViewPager; 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 final class TermuxActivity extends AppCompatActivity implements ServiceConnection { /** * 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 TerminalView} shown in {@link TermuxActivity} that displays the terminal. */ TerminalView mTerminalView; /** * 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 root view of the {@link TermuxActivity}. */ TermuxActivityRootView mTermuxActivityRootView; /** * The space at the bottom of {@link @mTermuxActivityRootView} of the {@link TermuxActivity}. */ View mTermuxActivityBottomSpaceView; /** * 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_USERNAME = 11; private static final int CONTEXT_MENU_AUTOFILL_PASSWORD = 2; private static final int CONTEXT_MENU_RESET_TERMINAL_ID = 3; private static final int CONTEXT_MENU_KILL_PROCESS_ID = 4; private static final int CONTEXT_MENU_STYLING_ID = 5; 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"; @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); setContentView(R.layout.activity_termux); // 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(); mTermuxActivityRootView = findViewById(R.id.activity_termux_root_view); mTermuxActivityRootView.setActivity(this); mTermuxActivityBottomSpaceView = findViewById(R.id.activity_termux_bottom_space_view); mTermuxActivityRootView.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); setSettingsButtonView(); setNewSessionButtonView(); setToggleKeyboardView(); registerForContextMenu(mTerminalView); 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; } // Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux // app has been opened. TermuxUtils.sendTermuxOpenedBroadcast(this); } @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; 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()); // Set activity night mode. If NightMode.SYSTEM is set, then android will automatically // trigger recreation of activity when uiMode/dark mode configuration is changed so that // day or night theme takes affect. AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true); } private void setMargins() { RelativeLayout relativeLayout = findViewById(R.id.activity_termux_root_relative_layout); 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 mTerminalView = findViewById(R.id.terminal_view); mTerminalView.setTerminalViewClient(mTermuxTerminalViewClient); if (mTermuxTerminalViewClient != null) mTermuxTerminalViewClient.onCreate(); if (mTermuxTerminalSessionActivityClient != null) mTermuxTerminalSessionActivityClient.onCreate(); } private void setTermuxSessionsListView() { ListView termuxSessionsListView = findViewById(R.id.terminal_sessions_list); mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions()); termuxSessionsListView.setAdapter(mTermuxSessionListViewController); termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); } private void setTerminalToolbarView(Bundle savedInstanceState) { mTermuxTerminalExtraKeys = new TermuxTerminalExtraKeys(this, mTerminalView, 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 setSettingsButtonView() { ImageButton settingsButton = findViewById(R.id.settings_button); settingsButton.setOnClickListener(v -> { ActivityUtils.startActivity(this, new Intent(this, SettingsActivity.class)); }); } private void setNewSessionButtonView() { View newSessionButton = findViewById(R.id.new_session_button); newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionActivityClient.addNewSession(false, null)); 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() { findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { mTermuxTerminalViewClient.onToggleSoftKeyboardRequest(); getDrawer().closeDrawers(); }); findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { toggleTerminalToolbar(); return true; }); } @SuppressLint("RtlHardcoded") @Override public void onBackPressed() { 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 autoFillEnabled = mTerminalView.isAutoFillEnabled(); menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.action_select_url); menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.action_share_transcript); if (!DataUtils.isNullOrEmpty(mTerminalView.getStoredSelectedText())) menu.add(Menu.NONE, CONTEXT_MENU_SHARE_SELECTED_TEXT, Menu.NONE, R.string.action_share_selected_text); if (autoFillEnabled) menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_USERNAME, Menu.NONE, R.string.action_autofill_username); if (autoFillEnabled) menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_PASSWORD, Menu.NONE, R.string.action_autofill_password); menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.action_reset_terminal); menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.action_kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning()); menu.add(Menu.NONE, CONTEXT_MENU_STYLING_ID, Menu.NONE, R.string.action_style_terminal); 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_HELP_ID, Menu.NONE, R.string.action_open_help); 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) { mTerminalView.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_USERNAME: mTerminalView.requestAutoFillUsername(); return true; case CONTEXT_MENU_AUTOFILL_PASSWORD: mTerminalView.requestAutoFillPassword(); 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_STYLING_ID: showStylingDialog(); return true; case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON: toggleKeepScreenOn(); return true; case CONTEXT_MENU_HELP_ID: ActivityUtils.startActivity(this, new Intent(this, HelpActivity.class)); return true; case CONTEXT_MENU_SETTINGS_ID: ActivityUtils.startActivity(this, new Intent(this, SettingsActivity.class)); 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 mTerminalView.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 showStylingDialog() { Intent stylingIntent = new Intent(); stylingIntent.setClassName(TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, TermuxConstants.TERMUX_STYLING_APP.TERMUX_STYLING_ACTIVITY_NAME); try { startActivity(stylingIntent); } catch (ActivityNotFoundException | IllegalArgumentException e) { // The startActivity() call is not documented to throw IllegalArgumentException. // However, crash reporting shows that it sometimes does, so catch it here. new AlertDialog.Builder(this).setMessage(getString(R.string.error_styling_not_installed)) .setPositiveButton(R.string.action_styling_install, (dialog, which) -> ActivityUtils.startActivity(this, new Intent(Intent.ACTION_VIEW, Uri.parse(TermuxConstants.TERMUX_STYLING_FDROID_PACKAGE_URL)))) .setNegativeButton(android.R.string.cancel, null).show(); } } private void toggleKeepScreenOn() { if (mTerminalView.getKeepScreenOn()) { mTerminalView.setKeepScreenOn(false); mPreferences.setKeepScreenOn(false); } else { mTerminalView.setKeepScreenOn(true); mPreferences.setKeepScreenOn(true); } } /** * 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() { @Override public void run() { // 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 mTermuxActivityRootView; } public View getTermuxActivityBottomSpaceView() { return mTermuxActivityBottomSpaceView; } 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 (ViewPager) findViewById(R.id.terminal_toolbar_view_pager); } 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 mTerminalView; } public TermuxTerminalViewClient getTermuxTerminalViewClient() { return mTermuxTerminalViewClient; } public TermuxTerminalSessionActivityClient getTermuxTerminalSessionClient() { return mTermuxTerminalSessionActivityClient; } @Nullable public TerminalSession getCurrentSession() { if (mTerminalView != null) return mTerminalView.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; } } ================================================ FILE: app/src/main/java/com/termux/app/TermuxApplication.java ================================================ package com.termux.app; import android.app.Application; import android.content.Context; import com.termux.BuildConfig; import com.termux.shared.errors.Error; import com.termux.shared.logger.Logger; import com.termux.shared.termux.TermuxBootstrap; 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.command.environment.TermuxShellEnvironment; import com.termux.shared.termux.shell.am.TermuxAmSocketServer; import com.termux.shared.termux.shell.TermuxShellManager; import com.termux.shared.termux.theme.TermuxThemeUtils; public class TermuxApplication extends Application { 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"); // Set TermuxBootstrap.TERMUX_APP_PACKAGE_MANAGER and TermuxBootstrap.TERMUX_APP_PACKAGE_VARIANT TermuxBootstrap.setTermuxPackageManagerAndVariant(BuildConfig.TERMUX_PACKAGE_VARIANT); // 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()); } } ================================================ FILE: app/src/main/java/com/termux/app/TermuxInstaller.java ================================================ 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. */ 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !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(); } ================================================ FILE: app/src/main/java/com/termux/app/TermuxOpenReceiver.java ================================================ package com.termux.app; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.webkit.MimeTypeMap; import com.termux.shared.termux.plugins.TermuxPluginUtils; import com.termux.shared.data.DataUtils; import com.termux.shared.data.IntentUtils; import com.termux.shared.net.uri.UriUtils; import com.termux.shared.logger.Logger; import com.termux.shared.net.uri.UriScheme; import com.termux.shared.termux.TermuxConstants; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import androidx.annotation.NonNull; public class TermuxOpenReceiver extends BroadcastReceiver { private static final String LOG_TAG = "TermuxOpenReceiver"; @Override public void onReceive(Context context, Intent intent) { final Uri data = intent.getData(); if (data == null) { Logger.logError(LOG_TAG, "Called without intent data"); return; } Logger.logVerbose(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); Logger.logVerbose(LOG_TAG, "uri: \"" + data + "\", path: \"" + data.getPath() + "\", fragment: \"" + data.getFragment() + "\""); final String contentTypeExtra = intent.getStringExtra("content-type"); final boolean useChooser = intent.getBooleanExtra("chooser", false); final String intentAction = intent.getAction() == null ? Intent.ACTION_VIEW : intent.getAction(); switch (intentAction) { case Intent.ACTION_SEND: case Intent.ACTION_VIEW: // Ok. break; default: Logger.logError(LOG_TAG, "Invalid action '" + intentAction + "', using 'view'"); break; } String scheme = data.getScheme(); if (scheme != null && !UriScheme.SCHEME_FILE.equals(scheme)) { Intent urlIntent = new Intent(intentAction, data); if (intentAction.equals(Intent.ACTION_SEND)) { urlIntent.putExtra(Intent.EXTRA_TEXT, data.toString()); urlIntent.setData(null); } else if (contentTypeExtra != null) { urlIntent.setDataAndType(data, contentTypeExtra); } urlIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(urlIntent); } catch (ActivityNotFoundException e) { Logger.logError(LOG_TAG, "No app handles the url " + data); } return; } // Get full path including fragment (anything after last "#") String filePath = UriUtils.getUriFilePathWithFragment(data); if (DataUtils.isNullOrEmpty(filePath)) { Logger.logError(LOG_TAG, "filePath is null or empty"); return; } final File fileToShare = new File(filePath); if (!(fileToShare.isFile() && fileToShare.canRead())) { Logger.logError(LOG_TAG, "Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); return; } Intent sendIntent = new Intent(); sendIntent.setAction(intentAction); sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); String contentTypeToUse; if (contentTypeExtra == null) { String fileName = fileToShare.getName(); int lastDotIndex = fileName.lastIndexOf('.'); String fileExtension = fileName.substring(lastDotIndex + 1); MimeTypeMap mimeTypes = MimeTypeMap.getSingleton(); // Lower casing makes it work with e.g. "JPG": contentTypeToUse = mimeTypes.getMimeTypeFromExtension(fileExtension.toLowerCase()); if (contentTypeToUse == null) contentTypeToUse = "application/octet-stream"; } else { contentTypeToUse = contentTypeExtra; } // Do not create Uri with Uri.parse() and use Uri.Builder().path(), check UriUtils.getUriFilePath(). Uri uriToShare = UriUtils.getContentUri(TermuxConstants.TERMUX_FILE_SHARE_URI_AUTHORITY, fileToShare.getAbsolutePath()); if (Intent.ACTION_SEND.equals(intentAction)) { sendIntent.putExtra(Intent.EXTRA_STREAM, uriToShare); sendIntent.setType(contentTypeToUse); } else { sendIntent.setDataAndType(uriToShare, contentTypeToUse); } if (useChooser) { sendIntent = Intent.createChooser(sendIntent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } try { context.startActivity(sendIntent); } catch (ActivityNotFoundException e) { Logger.logError(LOG_TAG, "No app handles the url " + data); } } public static class ContentProvider extends android.content.ContentProvider { private static final String LOG_TAG = "TermuxContentProvider"; @Override public boolean onCreate() { return true; } @Override public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { File file = new File(uri.getPath()); if (projection == null) { projection = new String[]{ MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns._ID }; } Object[] row = new Object[projection.length]; for (int i = 0; i < projection.length; i++) { String column = projection[i]; Object value; switch (column) { case MediaStore.MediaColumns.DISPLAY_NAME: value = file.getName(); break; case MediaStore.MediaColumns.SIZE: value = (int) file.length(); break; case MediaStore.MediaColumns._ID: value = 1; break; default: value = null; } row[i] = value; } MatrixCursor cursor = new MatrixCursor(projection); cursor.addRow(row); return cursor; } @Override public String getType(@NonNull Uri uri) { String path = uri.getLastPathSegment(); int extIndex = path.lastIndexOf('.') + 1; if (extIndex > 0) { MimeTypeMap mimeMap = MimeTypeMap.getSingleton(); String ext = path.substring(extIndex).toLowerCase(); return mimeMap.getMimeTypeFromExtension(ext); } return null; } @Override public Uri insert(@NonNull Uri uri, ContentValues values) { return null; } @Override public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { return 0; } @Override public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; } @Override public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { File file = new File(uri.getPath()); try { String path = file.getCanonicalPath(); String callingPackageName = getCallingPackage(); Logger.logDebug(LOG_TAG, "Open file request received from " + callingPackageName + " for \"" + path + "\" with mode \"" + mode + "\""); String storagePath = Environment.getExternalStorageDirectory().getCanonicalPath(); // See https://support.google.com/faqs/answer/7496913: if (!(path.startsWith(TermuxConstants.TERMUX_FILES_DIR_PATH) || path.startsWith(storagePath))) { throw new IllegalArgumentException("Invalid path: " + path); } // If TermuxConstants.PROP_ALLOW_EXTERNAL_APPS property to not set to "true", then throw exception String errmsg = TermuxPluginUtils.checkIfAllowExternalAppsPolicyIsViolated(getContext(), LOG_TAG); if (errmsg != null) { throw new IllegalArgumentException(errmsg); } // **DO NOT** allow these files to be modified by ContentProvider exposed to external // apps, since they may silently modify the values for security properties like // TermuxConstants.PROP_ALLOW_EXTERNAL_APPS set by users without their explicit consent. if (TermuxConstants.TERMUX_PROPERTIES_FILE_PATHS_LIST.contains(path) || TermuxConstants.TERMUX_FLOAT_PROPERTIES_FILE_PATHS_LIST.contains(path)) { mode = "r"; } } catch (IOException e) { throw new IllegalArgumentException(e); } return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)); } } } ================================================ FILE: app/src/main/java/com/termux/app/TermuxService.java ================================================ 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.NonNull; import androidx.annotation.Nullable; import com.termux.R; import com.termux.app.event.SystemEventReceiver; import com.termux.app.terminal.TermuxTerminalSessionActivityClient; import com.termux.app.terminal.TermuxTerminalSessionServiceClient; import com.termux.shared.termux.plugins.TermuxPluginUtils; import com.termux.shared.data.IntentUtils; import com.termux.shared.net.uri.UriUtils; import com.termux.shared.errors.Errno; import com.termux.shared.shell.ShellUtils; import com.termux.shared.shell.command.runner.app.AppShell; import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; import com.termux.shared.termux.shell.TermuxShellUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; import com.termux.shared.termux.shell.TermuxShellManager; import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; import com.termux.shared.logger.Logger; import com.termux.shared.notification.NotificationUtils; import com.termux.shared.android.PermissionUtils; import com.termux.shared.data.DataUtils; import com.termux.shared.shell.command.ExecutionCommand; import com.termux.shared.shell.command.ExecutionCommand.Runner; import com.termux.shared.shell.command.ExecutionCommand.ShellCreateMode; 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(); SystemEventReceiver.registerPackageUpdateEvents(this); } @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; case TERMUX_SERVICE.ACTION_SERVICE_EXECUTE: Logger.logDebug(LOG_TAG, "ACTION_SERVICE_EXECUTE intent received"); actionServiceExecute(intent); 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); SystemEventReceiver.unregisterPackageUpdateEvents(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() + ", PendingPluginExecutionCommands=" + mShellManager.mPendingPluginExecutionCommands.size()); List termuxSessions = new ArrayList<>(mShellManager.mTermuxSessions); List termuxTasks = new ArrayList<>(mShellManager.mTermuxTasks); List pendingPluginExecutionCommands = new ArrayList<>(mShellManager.mPendingPluginExecutionCommands); 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)); } for (int i = 0; i < pendingPluginExecutionCommands.size(); i++) { ExecutionCommand executionCommand = pendingPluginExecutionCommands.get(i); if (!executionCommand.shouldNotProcessResults() && executionCommand.isPluginExecutionCommandWithPendingResult()) { if (executionCommand.setStateFailed(Errno.ERRNO_CANCELLED.getCode(), this.getString(com.termux.shared.R.string.error_execution_cancelled))) { TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); } } } } /** 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"); } /** Process {@link TERMUX_SERVICE#ACTION_SERVICE_EXECUTE} intent to execute a shell command in * a foreground TermuxSession or in a background TermuxTask. */ private void actionServiceExecute(Intent intent) { if (intent == null) { Logger.logError(LOG_TAG, "Ignoring null intent to actionServiceExecute"); return; } ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId()); executionCommand.executableUri = intent.getData(); executionCommand.isPluginExecutionCommand = true; // If EXTRA_RUNNER is passed, use that, otherwise check EXTRA_BACKGROUND and default to Runner.TERMINAL_SESSION executionCommand.runner = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RUNNER, (intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, false) ? Runner.APP_SHELL.getName() : Runner.TERMINAL_SESSION.getName())); if (Runner.runnerOf(executionCommand.runner) == null) { String errmsg = this.getString(R.string.error_termux_service_invalid_execution_command_runner, executionCommand.runner); executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); return; } if (executionCommand.executableUri != null) { Logger.logVerbose(LOG_TAG, "uri: \"" + executionCommand.executableUri + "\", path: \"" + executionCommand.executableUri.getPath() + "\", fragment: \"" + executionCommand.executableUri.getFragment() + "\""); // Get full path including fragment (anything after last "#") executionCommand.executable = UriUtils.getUriFilePathWithFragment(executionCommand.executableUri); executionCommand.arguments = IntentUtils.getStringArrayExtraIfSet(intent, TERMUX_SERVICE.EXTRA_ARGUMENTS, null); if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) executionCommand.stdin = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_STDIN, null); executionCommand.backgroundCustomLogLevel = IntentUtils.getIntegerExtraIfSet(intent, TERMUX_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, null); } executionCommand.workingDirectory = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_WORKDIR, null); executionCommand.isFailsafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); executionCommand.sessionAction = intent.getStringExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION); executionCommand.shellName = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_SHELL_NAME, null); executionCommand.shellCreateMode = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_SHELL_CREATE_MODE, null); executionCommand.commandLabel = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_COMMAND_LABEL, "Execution Intent Command"); executionCommand.commandDescription = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION, null); executionCommand.commandHelp = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_COMMAND_HELP, null); executionCommand.pluginAPIHelp = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP, null); executionCommand.resultConfig.resultPendingIntent = intent.getParcelableExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT); executionCommand.resultConfig.resultDirectoryPath = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_DIRECTORY, null); if (executionCommand.resultConfig.resultDirectoryPath != null) { executionCommand.resultConfig.resultSingleFile = intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_RESULT_SINGLE_FILE, false); executionCommand.resultConfig.resultFileBasename = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILE_BASENAME, null); executionCommand.resultConfig.resultFileOutputFormat = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILE_OUTPUT_FORMAT, null); executionCommand.resultConfig.resultFileErrorFormat = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILE_ERROR_FORMAT, null); executionCommand.resultConfig.resultFilesSuffix = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILES_SUFFIX, null); } if (executionCommand.shellCreateMode == null) executionCommand.shellCreateMode = ShellCreateMode.ALWAYS.getMode(); // Add the execution command to pending plugin execution commands list mShellManager.mPendingPluginExecutionCommands.add(executionCommand); if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) executeTermuxTaskCommand(executionCommand); else if (Runner.TERMINAL_SESSION.equalsRunner(executionCommand.runner)) executeTermuxSessionCommand(executionCommand); else { String errmsg = getString(R.string.error_termux_service_unsupported_execution_command_runner, executionCommand.runner); executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); } } /** Execute a shell command in background TermuxTask. */ private void executeTermuxTaskCommand(ExecutionCommand executionCommand) { if (executionCommand == null) return; Logger.logDebug(LOG_TAG, "Executing background \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command"); // Transform executable path to shell/session name, e.g. "/bin/do-something.sh" => "do-something.sh". if (executionCommand.shellName == null && executionCommand.executable != null) executionCommand.shellName = ShellUtils.getExecutableBasename(executionCommand.executable); AppShell newTermuxTask = null; ShellCreateMode shellCreateMode = processShellCreateMode(executionCommand); if (shellCreateMode == null) return; if (ShellCreateMode.NO_SHELL_WITH_NAME.equals(shellCreateMode)) { newTermuxTask = getTermuxTaskForShellName(executionCommand.shellName); if (newTermuxTask != null) Logger.logVerbose(LOG_TAG, "Existing TermuxTask with \"" + executionCommand.shellName + "\" shell name found for shell create mode \"" + shellCreateMode.getMode() + "\""); else Logger.logVerbose(LOG_TAG, "No existing TermuxTask with \"" + executionCommand.shellName + "\" shell name found for shell create mode \"" + shellCreateMode.getMode() + "\""); } if (newTermuxTask == null) newTermuxTask = createTermuxTask(executionCommand); } /** 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); // Remove the execution command from the pending plugin execution commands list since it has // now been processed if (executionCommand.isPluginExecutionCommand) mShellManager.mPendingPluginExecutionCommands.remove(executionCommand); 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(); }); } /** Execute a shell command in a foreground {@link TermuxSession}. */ private void executeTermuxSessionCommand(ExecutionCommand executionCommand) { if (executionCommand == null) return; Logger.logDebug(LOG_TAG, "Executing foreground \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); // Transform executable path to shell/session name, e.g. "/bin/do-something.sh" => "do-something.sh". if (executionCommand.shellName == null && executionCommand.executable != null) executionCommand.shellName = ShellUtils.getExecutableBasename(executionCommand.executable); TermuxSession newTermuxSession = null; ShellCreateMode shellCreateMode = processShellCreateMode(executionCommand); if (shellCreateMode == null) return; if (ShellCreateMode.NO_SHELL_WITH_NAME.equals(shellCreateMode)) { newTermuxSession = getTermuxSessionForShellName(executionCommand.shellName); if (newTermuxSession != null) Logger.logVerbose(LOG_TAG, "Existing TermuxSession with \"" + executionCommand.shellName + "\" shell name found for shell create mode \"" + shellCreateMode.getMode() + "\""); else Logger.logVerbose(LOG_TAG, "No existing TermuxSession with \"" + executionCommand.shellName + "\" shell name found for shell create mode \"" + shellCreateMode.getMode() + "\""); } if (newTermuxSession == null) newTermuxSession = createTermuxSession(executionCommand); if (newTermuxSession == null) return; handleSessionAction(DataUtils.getIntFromString(executionCommand.sessionAction, TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY), newTermuxSession.getTerminalSession()); } /** * 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); // Remove the execution command from the pending plugin execution commands list since it has // now been processed if (executionCommand.isPluginExecutionCommand) mShellManager.mPendingPluginExecutionCommands.remove(executionCommand); // 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(); } private ShellCreateMode processShellCreateMode(@NonNull ExecutionCommand executionCommand) { if (ShellCreateMode.ALWAYS.equalsMode(executionCommand.shellCreateMode)) return ShellCreateMode.ALWAYS; // Default else if (ShellCreateMode.NO_SHELL_WITH_NAME.equalsMode(executionCommand.shellCreateMode)) if (DataUtils.isNullOrEmpty(executionCommand.shellName)) { TermuxPluginUtils.setAndProcessPluginExecutionCommandError(this, LOG_TAG, executionCommand, false, getString(R.string.error_termux_service_execution_command_shell_name_unset, executionCommand.shellCreateMode)); return null; } else { return ShellCreateMode.NO_SHELL_WITH_NAME; } else { TermuxPluginUtils.setAndProcessPluginExecutionCommandError(this, LOG_TAG, executionCommand, false, getString(R.string.error_termux_service_unsupported_execution_command_shell_create_mode, executionCommand.shellCreateMode)); return null; } } /** Process session action for new session. */ private void handleSessionAction(int sessionAction, TerminalSession newTerminalSession) { Logger.logDebug(LOG_TAG, "Processing sessionAction \"" + sessionAction + "\" for session \"" + newTerminalSession.mSessionName + "\""); switch (sessionAction) { case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY: setCurrentStoredTerminalSession(newTerminalSession); if (mTermuxTerminalSessionActivityClient != null) mTermuxTerminalSessionActivityClient.setCurrentSession(newTerminalSession); startTermuxActivity(); break; case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY: if (getTermuxSessionsSize() == 1) setCurrentStoredTerminalSession(newTerminalSession); startTermuxActivity(); break; case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY: setCurrentStoredTerminalSession(newTerminalSession); if (mTermuxTerminalSessionActivityClient != null) mTermuxTerminalSessionActivityClient.setCurrentSession(newTerminalSession); break; case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY: if (getTermuxSessionsSize() == 1) setCurrentStoredTerminalSession(newTerminalSession); break; default: Logger.logError(LOG_TAG, "Invalid sessionAction: \"" + sessionAction + "\". Force using default sessionAction."); handleSessionAction(TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY, newTerminalSession); break; } } /** Launch the {@link }TermuxActivity} to bring it to foreground. */ private void startTermuxActivity() { // For android >= 10, apps require Display over other apps permission to start foreground activities // from background (services). If it is not granted, then TermuxSessions that are started will // show in Termux notification but will not run until user manually clicks the notification. if (PermissionUtils.validateDisplayOverOtherAppsPermissionForPostAndroid10(this, true)) { TermuxActivity.startTermuxActivity(this); } else { TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(this); if (preferences == null) return; if (preferences.arePluginErrorNotificationsEnabled(false)) Logger.showToast(this, this.getString(R.string.error_display_over_other_apps_permission_not_granted_to_start_terminal), true); } } /** 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()); } } private void setCurrentStoredTerminalSession(TerminalSession terminalSession) { if (terminalSession == null) return; // Make the newly created session the current one to be displayed TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(this); if (preferences == null) return; preferences.setCurrentSession(terminalSession.mHandle); } 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; } } ================================================ FILE: app/src/main/java/com/termux/app/activities/HelpActivity.java ================================================ package com.termux.app.activities; import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.view.ViewGroup; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.ProgressBar; import android.widget.RelativeLayout; import androidx.appcompat.app.AppCompatActivity; import com.termux.shared.termux.TermuxConstants; /** Basic embedded browser for viewing help pages. */ public final class HelpActivity extends AppCompatActivity { WebView mWebView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); final RelativeLayout progressLayout = new RelativeLayout(this); RelativeLayout.LayoutParams lParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); lParams.addRule(RelativeLayout.CENTER_IN_PARENT); ProgressBar progressBar = new ProgressBar(this); progressBar.setIndeterminate(true); progressBar.setLayoutParams(lParams); progressLayout.addView(progressBar); mWebView = new WebView(this); WebSettings settings = mWebView.getSettings(); settings.setCacheMode(WebSettings.LOAD_NO_CACHE); setContentView(progressLayout); mWebView.clearCache(true); mWebView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { if (url.equals(TermuxConstants.TERMUX_WIKI_URL) || url.startsWith(TermuxConstants.TERMUX_WIKI_URL + "/")) { // Inline help. setContentView(progressLayout); return false; } try { startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); } catch (ActivityNotFoundException e) { // Android TV does not have a system browser. setContentView(progressLayout); return false; } return true; } @Override public void onPageFinished(WebView view, String url) { setContentView(mWebView); } }); mWebView.loadUrl(TermuxConstants.TERMUX_WIKI_URL); } @Override public void onBackPressed() { if (mWebView.canGoBack()) { mWebView.goBack(); } else { super.onBackPressed(); } } } ================================================ FILE: app/src/main/java/com/termux/app/activities/SettingsActivity.java ================================================ package com.termux.app.activities; import android.content.Context; import android.os.Bundle; import android.os.Environment; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import com.termux.R; import com.termux.shared.activities.ReportActivity; import com.termux.shared.file.FileUtils; import com.termux.shared.models.ReportInfo; import com.termux.app.models.UserAction; import com.termux.shared.interact.ShareUtils; import com.termux.shared.android.PackageUtils; import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; import com.termux.shared.termux.settings.preferences.TermuxFloatAppSharedPreferences; import com.termux.shared.termux.settings.preferences.TermuxTaskerAppSharedPreferences; import com.termux.shared.termux.settings.preferences.TermuxWidgetAppSharedPreferences; import com.termux.shared.android.AndroidUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxUtils; import com.termux.shared.activity.media.AppCompatActivityUtils; import com.termux.shared.theme.NightMode; public class SettingsActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true); setContentView(R.layout.activity_settings); if (savedInstanceState == null) { getSupportFragmentManager() .beginTransaction() .replace(R.id.settings, new RootPreferencesFragment()) .commit(); } AppCompatActivityUtils.setToolbar(this, com.termux.shared.R.id.toolbar); AppCompatActivityUtils.setShowBackButtonInActionBar(this, true); } @Override public boolean onSupportNavigateUp() { onBackPressed(); return true; } public static class RootPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; setPreferencesFromResource(R.xml.root_preferences, rootKey); new Thread() { @Override public void run() { configureTermuxAPIPreference(context); configureTermuxFloatPreference(context); configureTermuxTaskerPreference(context); configureTermuxWidgetPreference(context); configureAboutPreference(context); configureDonatePreference(context); } }.start(); } private void configureTermuxAPIPreference(@NonNull Context context) { Preference termuxAPIPreference = findPreference("termux_api"); if (termuxAPIPreference != null) { TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context, false); // If failed to get app preferences, then likely app is not installed, so do not show its preference termuxAPIPreference.setVisible(preferences != null); } } private void configureTermuxFloatPreference(@NonNull Context context) { Preference termuxFloatPreference = findPreference("termux_float"); if (termuxFloatPreference != null) { TermuxFloatAppSharedPreferences preferences = TermuxFloatAppSharedPreferences.build(context, false); // If failed to get app preferences, then likely app is not installed, so do not show its preference termuxFloatPreference.setVisible(preferences != null); } } private void configureTermuxTaskerPreference(@NonNull Context context) { Preference termuxTaskerPreference = findPreference("termux_tasker"); if (termuxTaskerPreference != null) { TermuxTaskerAppSharedPreferences preferences = TermuxTaskerAppSharedPreferences.build(context, false); // If failed to get app preferences, then likely app is not installed, so do not show its preference termuxTaskerPreference.setVisible(preferences != null); } } private void configureTermuxWidgetPreference(@NonNull Context context) { Preference termuxWidgetPreference = findPreference("termux_widget"); if (termuxWidgetPreference != null) { TermuxWidgetAppSharedPreferences preferences = TermuxWidgetAppSharedPreferences.build(context, false); // If failed to get app preferences, then likely app is not installed, so do not show its preference termuxWidgetPreference.setVisible(preferences != null); } } private void configureAboutPreference(@NonNull Context context) { Preference aboutPreference = findPreference("about"); if (aboutPreference != null) { aboutPreference.setOnPreferenceClickListener(preference -> { new Thread() { @Override public void run() { String title = "About"; StringBuilder aboutString = new StringBuilder(); aboutString.append(TermuxUtils.getAppInfoMarkdownString(context, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES)); aboutString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context, true)); aboutString.append("\n\n").append(TermuxUtils.getImportantLinksMarkdownString(context)); String userActionName = UserAction.ABOUT.getName(); ReportInfo reportInfo = new ReportInfo(userActionName, TermuxConstants.TERMUX_APP.TERMUX_SETTINGS_ACTIVITY_NAME, title); reportInfo.setReportString(aboutString.toString()); reportInfo.setReportSaveFileLabelAndPath(userActionName, Environment.getExternalStorageDirectory() + "/" + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)); ReportActivity.startReportActivity(context, reportInfo); } }.start(); return true; }); } } private void configureDonatePreference(@NonNull Context context) { Preference donatePreference = findPreference("donate"); if (donatePreference != null) { String signingCertificateSHA256Digest = PackageUtils.getSigningCertificateSHA256DigestForPackage(context); if (signingCertificateSHA256Digest != null) { // If APK is a Google Playstore release, then do not show the donation link // since Termux isn't exempted from the playstore policy donation links restriction // Check Fund solicitations: https://pay.google.com/intl/en_in/about/policy/ String apkRelease = TermuxUtils.getAPKRelease(signingCertificateSHA256Digest); if (apkRelease == null || apkRelease.equals(TermuxConstants.APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST)) { donatePreference.setVisible(false); return; } else { donatePreference.setVisible(true); } } donatePreference.setOnPreferenceClickListener(preference -> { ShareUtils.openUrl(context, TermuxConstants.TERMUX_DONATE_URL); return true; }); } } } } ================================================ FILE: app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java ================================================ 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) { if (sharedText == null || sharedText.isEmpty()) return false; 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(); } } ================================================ FILE: app/src/main/java/com/termux/app/event/SystemEventReceiver.java ================================================ package com.termux.app.event; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.data.IntentUtils; import com.termux.shared.logger.Logger; import com.termux.shared.termux.TermuxUtils; import com.termux.shared.termux.file.TermuxFileUtils; import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; import com.termux.shared.termux.shell.TermuxShellManager; public class SystemEventReceiver extends BroadcastReceiver { private static SystemEventReceiver mInstance; private static final String LOG_TAG = "SystemEventReceiver"; public static synchronized SystemEventReceiver getInstance() { if (mInstance == null) { mInstance = new SystemEventReceiver(); } return mInstance; } @Override public void onReceive(@NonNull Context context, @Nullable Intent intent) { if (intent == null) return; Logger.logDebug(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); String action = intent.getAction(); if (action == null) return; switch (action) { case Intent.ACTION_BOOT_COMPLETED: onActionBootCompleted(context, intent); break; case Intent.ACTION_PACKAGE_ADDED: case Intent.ACTION_PACKAGE_REMOVED: case Intent.ACTION_PACKAGE_REPLACED: onActionPackageUpdated(context, intent); break; default: Logger.logError(LOG_TAG, "Invalid action \"" + action + "\" passed to " + LOG_TAG); } } public synchronized void onActionBootCompleted(@NonNull Context context, @NonNull Intent intent) { TermuxShellManager.onActionBootCompleted(context, intent); } public synchronized void onActionPackageUpdated(@NonNull Context context, @NonNull Intent intent) { Uri data = intent.getData(); if (data != null && TermuxUtils.isUriDataForTermuxPluginPackage(data)) { Logger.logDebug(LOG_TAG, intent.getAction().replaceAll("^android.intent.action.", "") + " event received for \"" + data.toString().replaceAll("^package:", "") + "\""); if (TermuxFileUtils.isTermuxFilesDirectoryAccessible(context, false, false) == null) TermuxShellEnvironment.writeEnvironmentToFile(context); } } /** * Register {@link SystemEventReceiver} to listen to {@link Intent#ACTION_PACKAGE_ADDED}, * {@link Intent#ACTION_PACKAGE_REMOVED} and {@link Intent#ACTION_PACKAGE_REPLACED} broadcasts. * They must be registered dynamically and cannot be registered implicitly in * the AndroidManifest.xml due to Android 8+ restrictions. * * https://developer.android.com/guide/components/broadcast-exceptions */ public synchronized static void registerPackageUpdateEvents(@NonNull Context context) { IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(Intent.ACTION_PACKAGE_ADDED); intentFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); intentFilter.addAction(Intent.ACTION_PACKAGE_REPLACED); intentFilter.addDataScheme("package"); context.registerReceiver(getInstance(), intentFilter); } public synchronized static void unregisterPackageUpdateEvents(@NonNull Context context) { context.unregisterReceiver(getInstance()); } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java ================================================ package com.termux.app.fragments.settings; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; @Keep public class TermuxAPIPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TermuxAPIPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_api_preferences, rootKey); } } class TermuxAPIPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxAPIAppSharedPreferences mPreferences; private static TermuxAPIPreferencesDataStore mInstance; private TermuxAPIPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxAPIAppSharedPreferences.build(context, true); } public static synchronized TermuxAPIPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TermuxAPIPreferencesDataStore(context); } return mInstance; } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java ================================================ package com.termux.app.fragments.settings; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxFloatAppSharedPreferences; @Keep public class TermuxFloatPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TermuxFloatPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_float_preferences, rootKey); } } class TermuxFloatPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxFloatAppSharedPreferences mPreferences; private static TermuxFloatPreferencesDataStore mInstance; private TermuxFloatPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxFloatAppSharedPreferences.build(context, true); } public static synchronized TermuxFloatPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TermuxFloatPreferencesDataStore(context); } return mInstance; } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java ================================================ package com.termux.app.fragments.settings; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; @Keep public class TermuxPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TermuxPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_preferences, rootKey); } } class TermuxPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxAppSharedPreferences mPreferences; private static TermuxPreferencesDataStore mInstance; private TermuxPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxAppSharedPreferences.build(context, true); } public static synchronized TermuxPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TermuxPreferencesDataStore(context); } return mInstance; } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java ================================================ package com.termux.app.fragments.settings; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxTaskerAppSharedPreferences; @Keep public class TermuxTaskerPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TermuxTaskerPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_tasker_preferences, rootKey); } } class TermuxTaskerPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxTaskerAppSharedPreferences mPreferences; private static TermuxTaskerPreferencesDataStore mInstance; private TermuxTaskerPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxTaskerAppSharedPreferences.build(context, true); } public static synchronized TermuxTaskerPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TermuxTaskerPreferencesDataStore(context); } return mInstance; } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java ================================================ package com.termux.app.fragments.settings; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxWidgetAppSharedPreferences; @Keep public class TermuxWidgetPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TermuxWidgetPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_widget_preferences, rootKey); } } class TermuxWidgetPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxWidgetAppSharedPreferences mPreferences; private static TermuxWidgetPreferencesDataStore mInstance; private TermuxWidgetPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxWidgetAppSharedPreferences.build(context, true); } public static synchronized TermuxWidgetPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TermuxWidgetPreferencesDataStore(context); } return mInstance; } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; import com.termux.shared.logger.Logger; @Keep public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_debugging_preferences, rootKey); configureLoggingPreferences(context); } private void configureLoggingPreferences(@NonNull Context context) { PreferenceCategory loggingCategory = findPreference("logging"); if (loggingCategory == null) return; ListPreference logLevelListPreference = findPreference("log_level"); if (logLevelListPreference != null) { TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context, true); if (preferences == null) return; setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel()); loggingCategory.addPreference(logLevelListPreference); } } public static ListPreference setLogLevelListPreferenceData(ListPreference logLevelListPreference, Context context, int logLevel) { if (logLevelListPreference == null) logLevelListPreference = new ListPreference(context); CharSequence[] logLevels = Logger.getLogLevelsArray(); CharSequence[] logLevelLabels = Logger.getLogLevelLabelsArray(context, logLevels, true); logLevelListPreference.setEntryValues(logLevels); logLevelListPreference.setEntries(logLevelLabels); logLevelListPreference.setValue(String.valueOf(logLevel)); logLevelListPreference.setDefaultValue(Logger.DEFAULT_LOG_LEVEL); return logLevelListPreference; } } class DebuggingPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxAppSharedPreferences mPreferences; private static DebuggingPreferencesDataStore mInstance; private DebuggingPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxAppSharedPreferences.build(context, true); } public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new DebuggingPreferencesDataStore(context); } return mInstance; } @Override @Nullable public String getString(String key, @Nullable String defValue) { if (mPreferences == null) return null; if (key == null) return null; switch (key) { case "log_level": return String.valueOf(mPreferences.getLogLevel()); default: return null; } } @Override public void putString(String key, @Nullable String value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "log_level": if (value != null) { mPreferences.setLogLevel(mContext, Integer.parseInt(value)); } break; default: break; } } @Override public void putBoolean(String key, boolean value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "terminal_view_key_logging_enabled": mPreferences.setTerminalViewKeyLoggingEnabled(value); break; case "plugin_error_notifications_enabled": mPreferences.setPluginErrorNotificationsEnabled(value); break; case "crash_report_notifications_enabled": mPreferences.setCrashReportNotificationsEnabled(value); break; default: break; } } @Override public boolean getBoolean(String key, boolean defValue) { if (mPreferences == null) return false; switch (key) { case "terminal_view_key_logging_enabled": return mPreferences.isTerminalViewKeyLoggingEnabled(); case "plugin_error_notifications_enabled": return mPreferences.arePluginErrorNotificationsEnabled(false); case "crash_report_notifications_enabled": return mPreferences.areCrashReportNotificationsEnabled(false); default: return false; } } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; @Keep public class TerminalIOPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TerminalIOPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_terminal_io_preferences, rootKey); } } class TerminalIOPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxAppSharedPreferences mPreferences; private static TerminalIOPreferencesDataStore mInstance; private TerminalIOPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxAppSharedPreferences.build(context, true); } public static synchronized TerminalIOPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TerminalIOPreferencesDataStore(context); } return mInstance; } @Override public void putBoolean(String key, boolean value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "soft_keyboard_enabled": mPreferences.setSoftKeyboardEnabled(value); break; case "soft_keyboard_enabled_only_if_no_hardware": mPreferences.setSoftKeyboardEnabledOnlyIfNoHardware(value); break; default: break; } } @Override public boolean getBoolean(String key, boolean defValue) { if (mPreferences == null) return false; switch (key) { case "soft_keyboard_enabled": return mPreferences.isSoftKeyboardEnabled(); case "soft_keyboard_enabled_only_if_no_hardware": return mPreferences.isSoftKeyboardEnabledOnlyIfNoHardware(); default: return false; } } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences; @Keep public class TerminalViewPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(TerminalViewPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_terminal_view_preferences, rootKey); } } class TerminalViewPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxAppSharedPreferences mPreferences; private static TerminalViewPreferencesDataStore mInstance; private TerminalViewPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxAppSharedPreferences.build(context, true); } public static synchronized TerminalViewPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new TerminalViewPreferencesDataStore(context); } return mInstance; } @Override public void putBoolean(String key, boolean value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "terminal_margin_adjustment": mPreferences.setTerminalMarginAdjustment(value); break; default: break; } } @Override public boolean getBoolean(String key, boolean defValue) { if (mPreferences == null) return false; switch (key) { case "terminal_margin_adjustment": return mPreferences.isTerminalMarginAdjustmentEnabled(); default: return false; } } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux_api; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; @Keep public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_api_debugging_preferences, rootKey); configureLoggingPreferences(context); } private void configureLoggingPreferences(@NonNull Context context) { PreferenceCategory loggingCategory = findPreference("logging"); if (loggingCategory == null) return; ListPreference logLevelListPreference = findPreference("log_level"); if (logLevelListPreference != null) { TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context, true); if (preferences == null) return; com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); loggingCategory.addPreference(logLevelListPreference); } } } class DebuggingPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxAPIAppSharedPreferences mPreferences; private static DebuggingPreferencesDataStore mInstance; private DebuggingPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxAPIAppSharedPreferences.build(context, true); } public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new DebuggingPreferencesDataStore(context); } return mInstance; } @Override @Nullable public String getString(String key, @Nullable String defValue) { if (mPreferences == null) return null; if (key == null) return null; switch (key) { case "log_level": return String.valueOf(mPreferences.getLogLevel(true)); default: return null; } } @Override public void putString(String key, @Nullable String value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "log_level": if (value != null) { mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); } break; default: break; } } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux_float; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxFloatAppSharedPreferences; @Keep public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_float_debugging_preferences, rootKey); configureLoggingPreferences(context); } private void configureLoggingPreferences(@NonNull Context context) { PreferenceCategory loggingCategory = findPreference("logging"); if (loggingCategory == null) return; ListPreference logLevelListPreference = findPreference("log_level"); if (logLevelListPreference != null) { TermuxFloatAppSharedPreferences preferences = TermuxFloatAppSharedPreferences.build(context, true); if (preferences == null) return; com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); loggingCategory.addPreference(logLevelListPreference); } } } class DebuggingPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxFloatAppSharedPreferences mPreferences; private static DebuggingPreferencesDataStore mInstance; private DebuggingPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxFloatAppSharedPreferences.build(context, true); } public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new DebuggingPreferencesDataStore(context); } return mInstance; } @Override @Nullable public String getString(String key, @Nullable String defValue) { if (mPreferences == null) return null; if (key == null) return null; switch (key) { case "log_level": return String.valueOf(mPreferences.getLogLevel(true)); default: return null; } } @Override public void putString(String key, @Nullable String value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "log_level": if (value != null) { mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); } break; default: break; } } @Override public void putBoolean(String key, boolean value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "terminal_view_key_logging_enabled": mPreferences.setTerminalViewKeyLoggingEnabled(value, true); break; default: break; } } @Override public boolean getBoolean(String key, boolean defValue) { if (mPreferences == null) return false; switch (key) { case "terminal_view_key_logging_enabled": return mPreferences.isTerminalViewKeyLoggingEnabled(true); default: return false; } } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux_tasker; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxTaskerAppSharedPreferences; @Keep public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_tasker_debugging_preferences, rootKey); configureLoggingPreferences(context); } private void configureLoggingPreferences(@NonNull Context context) { PreferenceCategory loggingCategory = findPreference("logging"); if (loggingCategory == null) return; ListPreference logLevelListPreference = findPreference("log_level"); if (logLevelListPreference != null) { TermuxTaskerAppSharedPreferences preferences = TermuxTaskerAppSharedPreferences.build(context, true); if (preferences == null) return; com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); loggingCategory.addPreference(logLevelListPreference); } } } class DebuggingPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxTaskerAppSharedPreferences mPreferences; private static DebuggingPreferencesDataStore mInstance; private DebuggingPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxTaskerAppSharedPreferences.build(context, true); } public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new DebuggingPreferencesDataStore(context); } return mInstance; } @Override @Nullable public String getString(String key, @Nullable String defValue) { if (mPreferences == null) return null; if (key == null) return null; switch (key) { case "log_level": return String.valueOf(mPreferences.getLogLevel(true)); default: return null; } } @Override public void putString(String key, @Nullable String value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "log_level": if (value != null) { mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); } break; default: break; } } } ================================================ FILE: app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java ================================================ package com.termux.app.fragments.settings.termux_widget; import android.content.Context; import android.os.Bundle; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.termux.R; import com.termux.shared.termux.settings.preferences.TermuxWidgetAppSharedPreferences; @Keep public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Context context = getContext(); if (context == null) return; PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); setPreferencesFromResource(R.xml.termux_widget_debugging_preferences, rootKey); configureLoggingPreferences(context); } private void configureLoggingPreferences(@NonNull Context context) { PreferenceCategory loggingCategory = findPreference("logging"); if (loggingCategory == null) return; ListPreference logLevelListPreference = findPreference("log_level"); if (logLevelListPreference != null) { TermuxWidgetAppSharedPreferences preferences = TermuxWidgetAppSharedPreferences.build(context, true); if (preferences == null) return; com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); loggingCategory.addPreference(logLevelListPreference); } } } class DebuggingPreferencesDataStore extends PreferenceDataStore { private final Context mContext; private final TermuxWidgetAppSharedPreferences mPreferences; private static DebuggingPreferencesDataStore mInstance; private DebuggingPreferencesDataStore(Context context) { mContext = context; mPreferences = TermuxWidgetAppSharedPreferences.build(context, true); } public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { if (mInstance == null) { mInstance = new DebuggingPreferencesDataStore(context); } return mInstance; } @Override @Nullable public String getString(String key, @Nullable String defValue) { if (mPreferences == null) return null; if (key == null) return null; switch (key) { case "log_level": return String.valueOf(mPreferences.getLogLevel(true)); default: return null; } } @Override public void putString(String key, @Nullable String value) { if (mPreferences == null) return; if (key == null) return; switch (key) { case "log_level": if (value != null) { mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); } break; default: break; } } } ================================================ FILE: app/src/main/java/com/termux/app/models/UserAction.java ================================================ package com.termux.app.models; public enum UserAction { ABOUT("about"), REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript"); private final String name; UserAction(final String name) { this.name = name; } public String getName() { return name; } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java ================================================ package com.termux.app.terminal; import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Typeface; import android.media.AudioAttributes; import android.media.SoundPool; import android.text.TextUtils; import android.widget.ListView; import 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, com.termux.shared.R.raw.bell, 1); } catch (Exception e){ // Catch java.lang.RuntimeException: Unable to resume activity {com.termux/com.termux.app.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 sessionName) { 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(); String workingDirectory; 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]); } } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java ================================================ package com.termux.app.terminal; import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.media.AudioManager; import android.os.Environment; import android.text.TextUtils; 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 android.widget.Toast; 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(com.termux.shared.R.string.action_yes), (dialog, which) -> reportIssueFromTranscript(transcriptText, true), mActivity.getString(com.termux.shared.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); } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java ================================================ 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(); } } } } ================================================ FILE: app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java ================================================ package com.termux.filepicker; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.database.MatrixCursor; import android.graphics.Point; import android.os.CancellationSignal; import android.os.ParcelFileDescriptor; import android.provider.DocumentsContract.Document; import android.provider.DocumentsContract.Root; import android.provider.DocumentsProvider; import android.webkit.MimeTypeMap; import com.termux.R; import com.termux.shared.termux.TermuxConstants; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.util.Collections; import java.util.LinkedList; /** * A document provider for the Storage Access Framework which exposes the files in the * $HOME/ directory to other apps. *

* Note that this replaces providing an activity matching the ACTION_GET_CONTENT intent: *

* "A document provider and ACTION_GET_CONTENT should be considered mutually exclusive. If you * support both of them simultaneously, your app will appear twice in the system picker UI, * offering two different ways of accessing your stored data. This would be confusing for users." * - http://developer.android.com/guide/topics/providers/document-provider.html#43 */ public class TermuxDocumentsProvider extends DocumentsProvider { private static final String ALL_MIME_TYPES = "*/*"; private static final File BASE_DIR = TermuxConstants.TERMUX_HOME_DIR; // The default columns to return information about a root if no specific // columns are requested in a query. private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{ Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE, Root.COLUMN_SUMMARY, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES }; // The default columns to return information about a document if no specific // columns are requested in a query. private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{ Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE }; @Override public Cursor queryRoots(String[] projection) { final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); final String applicationName = getContext().getString(R.string.application_name); final MatrixCursor.RowBuilder row = result.newRow(); row.add(Root.COLUMN_ROOT_ID, getDocIdForFile(BASE_DIR)); row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(BASE_DIR)); row.add(Root.COLUMN_SUMMARY, null); row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH | Root.FLAG_SUPPORTS_IS_CHILD); row.add(Root.COLUMN_TITLE, applicationName); row.add(Root.COLUMN_MIME_TYPES, ALL_MIME_TYPES); row.add(Root.COLUMN_AVAILABLE_BYTES, BASE_DIR.getFreeSpace()); row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher); return result; } @Override public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); includeFile(result, documentId, null); return result; } @Override public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException { final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); final File parent = getFileForDocId(parentDocumentId); for (File file : parent.listFiles()) { includeFile(result, null, file); } return result; } @Override public ParcelFileDescriptor openDocument(final String documentId, String mode, CancellationSignal signal) throws FileNotFoundException { final File file = getFileForDocId(documentId); final int accessMode = ParcelFileDescriptor.parseMode(mode); return ParcelFileDescriptor.open(file, accessMode); } @Override public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException { final File file = getFileForDocId(documentId); final ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); return new AssetFileDescriptor(pfd, 0, file.length()); } @Override public boolean onCreate() { return true; } @Override public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException { File newFile = new File(parentDocumentId, displayName); int noConflictId = 2; while (newFile.exists()) { newFile = new File(parentDocumentId, displayName + " (" + noConflictId++ + ")"); } try { boolean succeeded; if (Document.MIME_TYPE_DIR.equals(mimeType)) { succeeded = newFile.mkdir(); } else { succeeded = newFile.createNewFile(); } if (!succeeded) { throw new FileNotFoundException("Failed to create document with id " + newFile.getPath()); } } catch (IOException e) { throw new FileNotFoundException("Failed to create document with id " + newFile.getPath()); } return newFile.getPath(); } @Override public void deleteDocument(String documentId) throws FileNotFoundException { File file = getFileForDocId(documentId); if (!file.delete()) { throw new FileNotFoundException("Failed to delete document with id " + documentId); } } @Override public String getDocumentType(String documentId) throws FileNotFoundException { File file = getFileForDocId(documentId); return getMimeType(file); } @Override public Cursor querySearchDocuments(String rootId, String query, String[] projection) throws FileNotFoundException { final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); final File parent = getFileForDocId(rootId); // This example implementation searches file names for the query and doesn't rank search // results, so we can stop as soon as we find a sufficient number of matches. Other // implementations might rank results and use other data about files, rather than the file // name, to produce a match. final LinkedList pending = new LinkedList<>(); pending.add(parent); final int MAX_SEARCH_RESULTS = 50; while (!pending.isEmpty() && result.getCount() < MAX_SEARCH_RESULTS) { final File file = pending.removeFirst(); // Avoid directories outside the $HOME directory linked with symlinks (to avoid e.g. search // through the whole SD card). boolean isInsideHome; try { isInsideHome = file.getCanonicalPath().startsWith(TermuxConstants.TERMUX_HOME_DIR_PATH); } catch (IOException e) { isInsideHome = true; } if (isInsideHome) { if (file.isDirectory()) { Collections.addAll(pending, file.listFiles()); } else { if (file.getName().toLowerCase().contains(query)) { includeFile(result, null, file); } } } } return result; } @Override public boolean isChildDocument(String parentDocumentId, String documentId) { return documentId.startsWith(parentDocumentId); } /** * Get the document id given a file. This document id must be consistent across time as other * applications may save the ID and use it to reference documents later. *

* The reverse of @{link #getFileForDocId}. */ private static String getDocIdForFile(File file) { return file.getAbsolutePath(); } /** * Get the file given a document id (the reverse of {@link #getDocIdForFile(File)}). */ private static File getFileForDocId(String docId) throws FileNotFoundException { final File f = new File(docId); if (!f.exists()) throw new FileNotFoundException(f.getAbsolutePath() + " not found"); return f; } private static String getMimeType(File file) { if (file.isDirectory()) { return Document.MIME_TYPE_DIR; } else { final String name = file.getName(); final int lastDot = name.lastIndexOf('.'); if (lastDot >= 0) { final String extension = name.substring(lastDot + 1).toLowerCase(); final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); if (mime != null) return mime; } return "application/octet-stream"; } } /** * Add a representation of a file to a cursor. * * @param result the cursor to modify * @param docId the document ID representing the desired file (may be null if given file) * @param file the File object representing the desired file (may be null if given docID) */ private void includeFile(MatrixCursor result, String docId, File file) throws FileNotFoundException { if (docId == null) { docId = getDocIdForFile(file); } else { file = getFileForDocId(docId); } int flags = 0; if (file.isDirectory()) { if (file.canWrite()) flags |= Document.FLAG_DIR_SUPPORTS_CREATE; } else if (file.canWrite()) { flags |= Document.FLAG_SUPPORTS_WRITE; } if (file.getParentFile().canWrite()) flags |= Document.FLAG_SUPPORTS_DELETE; final String displayName = file.getName(); final String mimeType = getMimeType(file); if (mimeType.startsWith("image/")) flags |= Document.FLAG_SUPPORTS_THUMBNAIL; final MatrixCursor.RowBuilder row = result.newRow(); row.add(Document.COLUMN_DOCUMENT_ID, docId); row.add(Document.COLUMN_DISPLAY_NAME, displayName); row.add(Document.COLUMN_SIZE, file.length()); row.add(Document.COLUMN_MIME_TYPE, mimeType); row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); row.add(Document.COLUMN_FLAGS, flags); row.add(Document.COLUMN_ICON, R.mipmap.ic_launcher); } } ================================================ FILE: app/src/main/res/drawable/current_session.xml ================================================ ================================================ FILE: app/src/main/res/drawable/current_session_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_new_session.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_service_notification.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/session_background_black_selected.xml ================================================ ================================================ FILE: app/src/main/res/drawable/session_background_selected.xml ================================================ ================================================ FILE: app/src/main/res/drawable/session_ripple.xml ================================================ ================================================ FILE: app/src/main/res/drawable/session_ripple_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/terminal_scroll_shape.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_termux.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_terminal_sessions_list.xml ================================================ ================================================ FILE: app/src/main/res/layout/preference_markdown_text.xml ================================================ ================================================ FILE: app/src/main/res/layout/view_terminal_toolbar_extra_keys.xml ================================================ ================================================ FILE: app/src/main/res/layout/view_terminal_toolbar_text_input.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/attrs.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ ================================================ FILE: app/src/main/res/values/strings.xml ================================================ ]> &TERMUX_APP_NAME; &TERMUX_APP_NAME; user Run commands in &TERMUX_APP_NAME; environment execute arbitrary commands within &TERMUX_APP_NAME; environment and access files Installing bootstrap packages… Unable to install bootstrap &TERMUX_APP_NAME; was unable to install the bootstrap packages. Abort Try again &TERMUX_APP_NAME; can only be run as the primary user. \nBootstrap binaries compiled for &TERMUX_APP_NAME; have hardcoded $PREFIX path and cannot be installed under any path other than:\n%1$s. &TERMUX_APP_NAME; cannot be installed on portable/external/removable sd card on your device. \nBootstrap binaries compiled for &TERMUX_APP_NAME; have hardcoded $PREFIX path and cannot be installed under any path other than:\n%1$s. New session Failsafe Max terminals reached Close down existing ones before creating new. Set session name Set New named session Create Keyboard Enabling Terminal Toolbar Disabling Terminal Toolbar Select URL Click URL to copy or long press to open No URL found in the terminal. URL copied to clipboard Share transcript Terminal transcript Send transcript to: Share selected text Terminal Text Send selected text to: Autofill username Autofill password Reset Terminal reset Kill process (%d) Really kill this session? Style Keep screen on Help Settings Report Issue Generating Report Add termux debug info to report? The &TERMUX_STYLING_APP_NAME; Plugin App is not installed. Install Exit Acquire wakelock Release wakelock &TERMUX_APP_NAME; requires \"Display over other apps\" permission to start terminal sessions from background on Android >= 10. Grants it from Settings -> Apps -> &TERMUX_APP_NAME; -> Advanced Invalid execution command runner to TermuxService: `%1$s` Unsupported execution command runner to TermuxService: `%1$s` Unsupported execution command shell create mode to TermuxService: `%1$s` Shell name not set but `%1$s` shell create mode passed Invalid intent action to RunCommandService: `%1$s` Invalid execution command runner to RunCommandService: `%1$s` Mandatory extra missing to RunCommandService: \"%1$s\" Visit %1$s for more info on RUN_COMMAND Intent usage. Save file in ~/downloads/ Edit Open directory Failed to start TermuxService. Check logcat for exception message. Failed to start TermuxService while app is in background due to android bg restrictions. &TERMUX_APP_NAME; Settings &TERMUX_APP_NAME; Preferences for &TERMUX_APP_NAME; app Debugging Preferences for debugging Logging Log Level Terminal View Key Logging Logs will not have entries for terminal view keys. (Default) Logcat logs will have entries for terminal view keys. These are very verbose and should be disabled under normal circumstances or will cause performance issues. Plugin Error Notifications Disable flashes and notifications for plugin errors. Show flashes and notifications for plugin errors. (Default) Crash Report Notifications Disable notifications for crash reports. Show notifications for crash reports. (Default) Terminal I/O Preferences for terminal I/O Keyboard Soft Keyboard Enabled Soft keyboard will be disabled. Soft keyboard will be enabled. (Default) Soft Keyboard Only If No Hardware Soft keyboard will be enabled even if hardware keyboard is connected. (Default) Soft keyboard will be enabled only if no hardware keyboard is connected. Terminal View Preferences for terminal view View Terminal Margin Adjustment Terminal margin adjustment will be disabled. Terminal margin adjustment will be enabled. It should be enabled to try to fix the issue where soft keyboard covers part of extra keys/terminal view. If it causes screen flickering on your devices, then disable it. (Default) &TERMUX_API_APP_NAME; Preferences for &TERMUX_API_APP_NAME; app &TERMUX_FLOAT_APP_NAME; Preferences for &TERMUX_FLOAT_APP_NAME; app &TERMUX_TASKER_APP_NAME; Preferences for &TERMUX_TASKER_APP_NAME; app &TERMUX_WIDGET_APP_NAME; Preferences for &TERMUX_WIDGET_APP_NAME; app About Donate ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: app/src/main/res/xml/root_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/shortcuts.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_api_debugging_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_api_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_debugging_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_float_debugging_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_float_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_tasker_debugging_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_tasker_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_terminal_io_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_terminal_view_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_widget_debugging_preferences.xml ================================================ ================================================ FILE: app/src/main/res/xml/termux_widget_preferences.xml ================================================ ================================================ FILE: app/src/test/java/com/termux/app/TermuxActivityTest.java ================================================ 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"); } } ================================================ FILE: app/src/test/java/com/termux/app/api/file/FileReceiverActivityTest.java ================================================ 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"); invalidUrls.add(""); invalidUrls.add(null); for (String url : invalidUrls) { Assert.assertFalse(FileReceiverActivity.isSharedTextAnUrl(url)); } } } ================================================ FILE: art/copy-to-other-apps.sh ================================================ #!/bin/sh set -e -u for APP in api boot styling tasker widget; do APPDIR=../../termux-$APP for file in ic_foreground ic_launcher; do cp ../app/src/main/res/drawable/$file.xml \ $APPDIR/app/src/main/res/drawable/$file.xml done cp ../app/src/main/res/drawable-anydpi-v26/ic_launcher.xml \ $APPDIR/app/src/main/res/drawable-anydpi-v26/$file.xml done ================================================ FILE: art/generate-big-icon.sh ================================================ #!/bin/sh set -e -u echo "Generating ~/termux-icons/ic_launcher.png..." mkdir -p ~/termux-icons/ vector2svg ../app/src/main/res/drawable/ic_launcher.xml ~/termux-icons/ic_launcher.svg sed -i "" 's/viewBox="0 0 108 108"/viewBox="18 18 72 72"/' ~/termux-icons/ic_launcher.svg SIZE=512 rsvg-convert \ -w $SIZE \ -h $SIZE \ -o ~/termux-icons/ic_launcher_$SIZE.png \ ~/termux-icons/ic_launcher.svg rsvg-convert \ -b black \ -w $SIZE \ -h $SIZE \ -o ~/termux-icons/ic_launcher_square_$SIZE.png \ ~/termux-icons/ic_launcher.svg ================================================ FILE: art/generate-feature-graphic.sh ================================================ #!/bin/bash echo "Generating feature graphics to ~/termux-icons/termux-feature-graphic.png..." mkdir -p ~/termux-icons/ rsvg-convert feature-graphic.svg > ~/termux-icons/feature-graphic.png ================================================ FILE: art/generate-launcher-images.sh ================================================ #!/bin/sh for DENSITY in mdpi hdpi xhdpi xxhdpi xxxhdpi; do case $DENSITY in mdpi) SIZE=48;; hdpi) SIZE=72;; xhdpi) SIZE=96;; xxhdpi) SIZE=144;; xxxhdpi) SIZE=192;; esac FOLDER=../app/src/main/res/mipmap-$DENSITY mkdir -p $FOLDER for FILE in ic_launcher ic_launcher_round; do PNG=$FOLDER/$FILE.png rsvg-convert -w $SIZE -h $SIZE $FILE.svg > $PNG zopflipng -y $PNG $PNG done done ================================================ FILE: art/generate-tv-banner.sh ================================================ #!/bin/bash echo "Generating feature graphics to ~/termux-icons/termux-feature-graphic.png..." mkdir -p ~/termux-icons/ # The Android TV banner on google play (1280x720) has same aspect ratio # as the banner in the app (320x180). rsvg-convert -w 1280 -h 720 tv-banner.svg > ~/termux-icons/tv-banner.png rsvg-convert -w 320 -h 180 tv-banner.svg > ../app/src/main/res/drawable/banner.png ================================================ FILE: build.gradle ================================================ buildscript { repositories { mavenCentral() google() } dependencies { classpath "com.android.tools.build:gradle:8.13.2" } } allprojects { repositories { google() mavenCentral() maven { url "https://jitpack.io" } } } ================================================ FILE: docs/en/index.md ================================================ --- page_ref: /docs/apps/termux/index.html --- # Termux App Docs Welcome to documentation for the [Termux App]. ## [Termux App]: https://github.com/termux/termux-app ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ Termux is a terminal emulator application enhanced with a large set of command line utilities ported to Android OS. The main goal is to bring a Linux command line experience to users of mobile devices with no rooting or other special setup required. * Enjoy the Bash and Zsh shells. * Edit files with nano and vim. * Access servers over SSH. * Compile C/C++ code with clang. * Use the Python console as a pocket calculator. * Check out projects with Git and Subversion. * Run text-based games with frotz. At first start a small base system is being configured. The GNU Bash, Coreutils, Findutils and other core utilities are available out-of-box. Additionally, we provide more than 1000 other packages installable by using the 'pkg' utility which currently is a frontend for the 'apt' package manager. All provided software has been patched and compiled with Android NDK to provide max compatibility with Android OS. To learn more about application usage tips and tricks, long-press anywhere on the terminal and select the Help menu option to access Termux Wiki. This resource is also accessible directly in a web browser: https://wiki.termux.com/wiki/Main_Page. ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ Terminal emulator app with a large set of command line utilities ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ ## Project-wide Gradle settings. # # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true org.gradle.jvmargs=-Xmx2048M android.useAndroidX=true minSdkVersion=21 targetSdkVersion=28 ndkVersion=29.0.14206865 compileSdkVersion=36 markwonVersion=4.6.2 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # 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 # # https://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. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: jitpack.yml ================================================ jdk: - openjdk17 env: JITPACK_NDK_VERSION: "29.0.14206865" ================================================ FILE: settings.gradle ================================================ include ':app', ':termux-shared', ':terminal-emulator', ':terminal-view' ================================================ FILE: terminal-emulator/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'maven-publish' android { namespace "com.termux.emulator" compileSdkVersion project.properties.compileSdkVersion.toInteger() ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion defaultConfig { minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() externalNativeBuild { ndkBuild { cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections" } } ndk { abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' } } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } externalNativeBuild { ndkBuild { path "src/main/jni/Android.mk" } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } testOptions { unitTests.returnDefaultValues = true } publishing { multipleVariants { withSourcesJar() withJavadocJar() allVariants() } } } tasks.withType(Test) { testLogging { events "started", "passed", "skipped", "failed" } } dependencies { implementation "androidx.annotation:annotation:1.9.0" testImplementation "junit:junit:4.13.2" } task sourceJar(type: Jar) { from android.sourceSets.main.java.srcDirs archiveClassifier = "sources" } afterEvaluate { publishing { publications { // Creates a Maven publication called "release". release(MavenPublication) { from components.default groupId = 'com.termux' artifactId = 'terminal-emulator' version = '0.118.0' artifact(sourceJar) } } } } ================================================ FILE: terminal-emulator/proguard-rules.pro ================================================ # 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 ================================================ FILE: terminal-emulator/src/main/AndroidManifest.xml ================================================ ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java ================================================ 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; } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/JNI.java ================================================ 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, int cellWidth, int cellHeight); /** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */ public static native void setPtyWindowSize(int fd, int rows, int cols, int cellWidth, int cellHeight); /** * 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); } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java ================================================ 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; } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/Logger.java ================================================ 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; } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java ================================================ 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; } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java ================================================ 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; } } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java ================================================ 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 )); } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java ================================================ 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; /** Escape processing: "ESC _" or Application Program Command (APC). */ private static final int ESC_APC = 20; /** Escape processing: "ESC _" or Application Program Command (APC), followed by Escape. */ private static final int ESC_APC_ESCAPE = 21; /** Escape processing: ESC [ */ private static final int ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22; /** Escape processing: ESC [ */ private static final int ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE = 23; /** The number of parameter arguments including colon separated sub-parameters. */ private static final int MAX_ESCAPE_PARAMETERS = 32; /** Needs to be large enough to contain reasonable OSC 52 pastes. */ private static final int MAX_OSC_STRING_LENGTH = 8192; /** 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<>(); /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ private int mCursorRow, mCursorCol; /** The number of character rows and columns in the terminal screen. */ public int mRows, mColumns; /** Size of a terminal cell in pixels. */ private int mCellWidthPixels, mCellHeightPixels; /** The number of terminal transcript rows that can be scrolled back to. */ public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100; public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000; 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 the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */ private int mArgsSubParamsBitSet = 0; /** Holds OSC and device control arguments, which can be strings. */ private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); /** * 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, background and underline colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. * For a 24-bit value the top byte (0xff000000) is set. * *

Note that the underline color is currently parsed but not yet used during rendering. * * @see TextStyle */ int mForeColor, mBackColor, mUnderlineColor; /** Current {@link TextStyle} effect. */ 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, int cellWidthPixels, int cellHeightPixels, Integer transcriptRows, TerminalSessionClient client) { mSession = session; mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows); mAltBuffer = new TerminalBuffer(columns, rows, rows); mClient = client; mRows = rows; mColumns = columns; mCellWidthPixels = cellWidthPixels; mCellHeightPixels = cellHeightPixels; mTabStop = new boolean[mColumns]; reset(); } 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, int cellWidthPixels, int cellHeightPixels) { this.mCellWidthPixels = cellWidthPixels; this.mCellHeightPixels = cellHeightPixels; 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) { // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early. if (mEscapeState == ESC_APC) { doApc(b); return; } else if (mEscapeState == ESC_APC_ESCAPE) { doApcEscape(b); return; } switch (b) { case 0: // Null character (NUL, ^@). Do nothing. break; 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_UNSUPPORTED_PARAMETER_BYTE: case ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE: doCsiUnsupportedParameterOrIntermediateByte(b); break; case ESC_CSI_EXCLAMATION: if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). reset(); } 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); } } } /** * When in {@link #ESC_APC} (APC, Application Program Command) sequence. */ private void doApc(int b) { if (b == 27) { continueSequence(ESC_APC_ESCAPE); } // Eat APC sequences silently for now. } /** * When in {@link #ESC_APC} (APC, Application Program Command) sequence. */ private void doApcEscape(int b) { if (b == '\\') { // A String Terminator (ST), ending the APC escape sequence. finishSequence(); } else { // The Escape character was not the start of a String Terminator (ST), // but instead just data inside of the APC escape sequence. continueSequence(ESC_APC); } } private int nextTabStop(int numTabs) { for (int i = mCursorCol + 1; i < mColumns; i++) if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); return mRightMargin - 1; } /** * Process byte while in the {@link #ESC_CSI_UNSUPPORTED_PARAMETER_BYTE} or * {@link #ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE} escape state. * * Parse unsupported parameter, intermediate and final bytes but ignore them. * * > For Control Sequence Introducer, ... the ESC [ is followed by * > - any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?), * > - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), * > - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~). * * - https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands * - https://invisible-island.net/xterm/ecma-48-parameter-format.html#section5.4 */ private void doCsiUnsupportedParameterOrIntermediateByte(int b) { if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) { // Supported `0–9:;>?` or unsupported `<=` parameter byte after an // initial unsupported parameter byte in `doCsi()`, or a sequential parameter byte. continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); } else if (b >= 0x20 && b <= 0x2F) { // Optional intermediate byte `!"#$%&'()*+,-./` after parameter or intermediate byte. continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE); } else if (b >= 0x40 && b <= 0x7E) { // Final byte `@A–Z[\]^_`a–z{|}~` after parameter or intermediate byte. // Calling `unknownSequence()` would log an error with only a final byte, so ignore it for now. finishSequence(); } else { unknownSequence(b); } } /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ private void doCsiQuestionMark(int b) { switch (b) { 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); mArgsSubParamsBitSet = 0; } 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(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1); blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin); } 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); 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; case '_': // APC - Application Program Command. continueSequence(ESC_APC); 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(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll); blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll); } else { // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. unimplementedSequence(b); } 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 parameter byte continueSequence(ESC_CSI_QUESTIONMARK); break; case '>': // "Esc [ >" -- start of a private parameter byte continueSequence(ESC_CSI_BIGGERTHAN); break; case '<': // "Esc [ <" -- start of a private parameter byte case '=': // "Esc [ =" -- start of a private parameter byte continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); break; case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). setCursorColRespectingOriginMode(getArg0(1) - 1); break; 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 mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels)); break; case 16: // Report xterm character cell size in pixels. Result is CSI 6 ; height ; width t mSession.write(String.format(Locale.US, "\033[6;%d;%dt", mCellHeightPixels, mCellWidthPixels)); break; case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); 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++) { // Skip leading sub parameters: if ((mArgsSubParamsBitSet & (1 << i)) != 0) { continue; } int code = getArg(i, 0, false); 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) { if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet & (1 << (i + 1))) != 0)) { // Sub parameter, see https://sw.kovidgoyal.net/kitty/underlines/ i++; if (mArgs[i] == 0) { // No underline. mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; } else { // Different variations of underlines: https://sw.kovidgoyal.net/kitty/underlines/ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; } } else { mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; } } else if (code == 5) { mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; } else if (code == 7) { 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 || code == 58) { // Extended set foreground(38)/background(48)/underline(58) color. // This is followed by either "2;$R;$G;$B" to set a 24-bit color or // "5;$INDEX" to set an indexed color. if (i + 2 > mArgIndex) continue; 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 = getArg(i + 2, 0, false); int green = getArg(i + 3, 0, false); int blue = getArg(i + 4, 0, false); if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); } else { int argbColor = 0xff_00_00_00 | (red << 16) | (green << 8) | blue; switch (code) { case 38: mForeColor = argbColor; break; case 48: mBackColor = argbColor; break; case 58: mUnderlineColor = argbColor; break; } } i += 4; // "2;P_r;P_g;P_r" } } else if (firstArg == 5) { int color = getArg(i + 2, 0, false); i += 2; // "5;P_s" if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { switch (code) { case 38: mForeColor = color; break; case 48: mBackColor = color; break; case 58: mUnderlineColor = color; break; } } else { if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color); } } 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 == 59) { // Set default underline color. mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND; } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). mForeColor = code - 90 + 8; } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). 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++; long currentStyle = getStyle(); if (mLeftMargin != 0 || mRightMargin != mColumns) { // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); // .. and blank bottom row between margins: mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', currentStyle); } else { mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle); } } /** * Process the next ASCII character of a parameter. * *

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

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

All parameters are unsigned, positive decimal integers, with the most significant * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999 * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value * or omitted parameter indicates a default value for the sequence. For most * sequences, the default value is 1. * *

References: * VT510 Video Terminal Programmer Information: Control Sequences * alacritty/vte: Implement colon separated CSI parameters * */ private void parseArg(int b) { if (b >= '0' && b <= '9') { if (mArgIndex < mArgs.length) { int oldValue = mArgs[mArgIndex]; int thisDigit = b - '0'; int value; if (oldValue >= 0) { value = oldValue * 10 + thisDigit; } else { value = thisDigit; } if (value > 9999) value = 9999; mArgs[mArgIndex] = value; } continueSequence(mEscapeState); } else if (b == ';' || b == ':') { if (mArgIndex + 1 < mArgs.length) { mArgIndex++; if (b == ':') { mArgsSubParamsBitSet |= 1 << mArgIndex; } } else { logError("Too many parameters when in state: " + mEscapeState); } continueSequence(mEscapeState); } else { unknownSequence(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 + "}]"; } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java ================================================ 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(); } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java ================================================ 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; /** * Max combining characters that can exist in a column, that are separate from the base character * itself. Any additional combining characters will be ignored and not added to the column. * * There does not seem to be limit in unicode standard for max number of combination characters * that can be combined but such characters are primarily under 10. * * "Section 3.6 Combination" of unicode standard contains combining characters info. * - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf * - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges * - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to * * UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters. * > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage. * > While it would have been feasible to chose a smaller number, this value provides a very wide margin, * > yet is well within the buffer size limits of practical implementations. * - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format * - https://stackoverflow.com/a/11983435/14686958 * * We choose the value 15 because it should be enough for terminal based applications and keep * the memory usage low for a terminal row, won't affect performance or cause terminal to * lag or hang, and will keep malicious applications from causing harm. The value can be * increased if ever needed for legitimate applications. */ private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15; /** 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 chars 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) { int oldEndOfColumnIndex = findStartOfColumn(columnToSet + oldCodePointDisplayWidth); oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex; } else { // Last character. oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex; } // If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters. if (newIsCombining) { int combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn); if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN) return; } // 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: 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, oldNextColumnIndex); 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]; } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java ================================================ 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, 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(64 * 1024); /** * 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, 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, int cellWidthPixels, int cellHeightPixels) { if (mEmulator == null) { initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels); } else { JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns, cellWidthPixels, cellHeightPixels); mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels); } } /** 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, int cellWidthPixels, int cellHeightPixels) { mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient); int[] processId = new int[1]; mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns, cellWidthPixels, cellHeightPixels); 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[64 * 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); } } } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java ================================================ 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); } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java ================================================ 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); } } ================================================ FILE: terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java ================================================ 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); } /** * The zero width characters count like combining characters in the `chars` array from start * index to end index (exclusive). */ public static int zeroWidthCharsCount(char[] chars, int start, int end) { if (start < 0 || start >= chars.length) return 0; int count = 0; for (int i = start; i < end && i < chars.length;) { if (Character.isHighSurrogate(chars[i])) { if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) { count++; } i += 2; } else { if (width(chars[i]) <= 0) { count++; } i++; } } return count; } } ================================================ FILE: terminal-emulator/src/main/jni/Android.mk ================================================ LOCAL_PATH:= $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE:= libtermux LOCAL_SRC_FILES:= termux.c include $(BUILD_SHARED_LIBRARY) ================================================ FILE: terminal-emulator/src/main/jni/termux.c ================================================ #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, jint cell_width, jint cell_height) { int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC); if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx"); #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, .ws_xpixel = (unsigned short) (columns * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height)}; 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, jint cell_width, jint cell_height) { 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, cell_width, cell_height); (*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, jint cell_width, jint cell_height) { struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols, .ws_xpixel = (unsigned short) (cols * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height) }; ioctl(fd, TIOCSWINSZ, &sz); } 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); } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java ================================================ package com.termux.terminal; public class ApcTest extends TerminalTestCase { public void testApcConsumed() { // At time of writing this is part of what yazi sends for probing for kitty graphics protocol support: // https://github.com/sxyazi/yazi/blob/0cdaff98d0b3723caff63eebf1974e7907a43a2c/yazi-adapter/src/emulator.rs#L129 // This should not result in anything being written to the screen: If kitty graphics protocol support // is implemented it should instead result in an error code on stdin, and if not it should be consumed // silently just as xterm does. See https://sw.kovidgoyal.net/kitty/graphics-protocol/. withTerminalSized(2, 2) .enterString("\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\") .assertLinesAre(" ", " "); // It is ok for the APC content to be non printable characters: withTerminalSized(12, 2) .enterString("hello \033_some\023\033_\\apc#end\033\\ world") .assertLinesAre("hello world", " "); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java ================================================ 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)); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java ================================================ package com.termux.terminal; import java.util.List; /** "\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()); } public void testReportPixelSize() { int columns = 3; int rows = 3; withTerminalSized(columns, rows); int cellWidth = TerminalTest.INITIAL_CELL_WIDTH_PIXELS; int cellHeight = TerminalTest.INITIAL_CELL_HEIGHT_PIXELS; assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t"); assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t"); columns = 23; rows = 33; resize(columns, rows); assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t"); assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t"); cellWidth = 8; cellHeight = 18; mTerminal.resize(columns, rows, cellWidth, cellHeight); assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t"); assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t"); } /** * See Colored and styled underlines: * *
     *  [4:0m  # no underline
     *  [4:1m  # straight underline
     *  [4:2m  # double underline
     *  [4:3m  # curly underline
     *  [4:4m  # dotted underline
     *  [4:5m  # dashed underline
     *  [4m    # straight underline (for backwards compat)
     *  [24m   # no underline (for backwards compat)
     * 
*

* We currently parse the variants, but map them to normal/no underlines as appropriate */ public void testUnderlineVariants() { for (String suffix : List.of("", ":1", ":2", ":3", ":4", ":5")) { for (String stop : List.of("24", "4:0")) { withTerminalSized(3, 3); enterString("\033[4" + suffix + "m").assertLinesAre(" ", " ", " "); assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect); enterString("\033[4;1m").assertLinesAre(" ", " ", " "); assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect); enterString("\033[" + stop + "m").assertLinesAre(" ", " ", " "); assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD, mTerminal.mEffect); } } } public void testManyParameters() { StringBuilder b = new StringBuilder("\033["); for (int i = 0; i < 30; i++) { b.append("0;"); } b.append("4:2"); // This clearing of underline should be ignored as the parameters pass the threshold for too many parameters: b.append("4:0m"); withTerminalSized(3, 3) .enterString(b.toString()) .assertLinesAre(" ", " ", " "); assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java ================================================ 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)); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/DecSetTest.java ================================================ 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 ", " "); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java ================================================ 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"); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java ================================================ 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"); resize(cols, 2); assertHistoryStartsWith("777", "666", "555"); 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"); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java ================================================ 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)); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java ================================================ 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\\"); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java ================================================ 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)); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java ================================================ 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 ", " "); resize(cols, 2); assertLinesAre("999 ", " "); resize(cols, 5); assertLinesAre("996 ", "997 ", "998 ", "999 ", " "); 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 ", " ", " ", " "); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java ================================================ 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)); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java ================================================ 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 testRiRespectsLeftMargin() { // Reverse Index (RI), ${ESC}M, should respect horizontal margins: withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033M").assertLinesAre("A D", " BC ", " "); } public void testSdRespectsLeftMargin() { // Scroll Down (SD), ${CSI}${N}T, should respect horizontal margins: withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033[2T").assertLinesAre("A D", " ", " BC "); } public void testBackwardIndex() { // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 7. // Without margins: 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" ); } /** * See reported issue. */ public void testClearingWhenScrollingWithMargins() { int newForeground = 2; int newBackground = 3; int size = 3; TerminalTestCase terminal = withTerminalSized(size, size) // Enable horizontal margin and set left margin to 1: .enterString("\033[?69h\033[2s") // Set foreground and background color: .enterString("\033[" + (30 + newForeground) + ";" + (40 + newBackground) + "m") // Enter newlines to scroll down: .enterString("\r\n\r\n\r\n\r\n\r\n"); for (int row = 0; row < size; row++) { for (int col = 0; col < size; col++) { // The first column (outside of the scrolling area, due to us setting a left scroll // margin of 1) should be unmodified, the others should use the current style: int expectedForeground = col == 0 ? TextStyle.COLOR_INDEX_FOREGROUND : newForeground; int expectedBackground = col == 0 ? TextStyle.COLOR_INDEX_BACKGROUND : newBackground; terminal.assertForegroundColorAt(row, col, expectedForeground); terminal.assertBackgroundColorAt(row, col, expectedBackground); } } } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java ================================================ 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)]); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java ================================================ 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++) { 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() { selectGraphicsTestRun(';'); selectGraphicsTestRun(':'); } public void selectGraphicsTestRun(char separator) { 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".replace(';', separator)); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); enterString("\033[31m\033[0m"); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); enterString("\033[31m\033[0;m".replace(';', separator)); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); enterString("\033[31;;m"); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); enterString("\033[31::m"); assertEquals(1, mTerminal.mForeColor); enterString("\033[31;m"); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); enterString("\033[31:m"); assertEquals(1, mTerminal.mForeColor); enterString("\033[31;;41m"); assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor); assertEquals(1, mTerminal.mBackColor); enterString("\033[0m"); assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); // 256 colors: enterString("\033[38;5;119m".replace(';', separator)); assertEquals(119, mTerminal.mForeColor); assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); enterString("\033[48;5;129m".replace(';', separator)); assertEquals(119, mTerminal.mForeColor); assertEquals(129, mTerminal.mBackColor); // Invalid parameter: enterString("\033[48;8;129m".replace(';', separator)); assertEquals(119, mTerminal.mForeColor); assertEquals(129, mTerminal.mBackColor); // Multiple parameters at once: enterString("\033[38;5;178".replace(';', separator) + ";" + "48;5;179m".replace(';', separator)); assertEquals(178, mTerminal.mForeColor); assertEquals(179, mTerminal.mBackColor); // Omitted parameter means zero: enterString("\033[38;5;m".replace(';', separator)); assertEquals(0, mTerminal.mForeColor); assertEquals(179, mTerminal.mBackColor); enterString("\033[48;5;m".replace(';', separator)); assertEquals(0, mTerminal.mForeColor); assertEquals(0, mTerminal.mBackColor); // 24 bit colors: enterString(("\033[0m")); // Reset fg and bg colors. enterString("\033[38;2;255;127;2m".replace(';', separator)); int expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2; assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); enterString("\033[48;2;1;2;254m".replace(';', separator)); 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".replace(';', separator) + ";" + "48;2;1;2;254m".replace(';', separator)); assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(expectedBackground, mTerminal.mBackColor); // 24 bit colors, invalid input: enterString("\033[38;2;300;127;2;48;2;1;300;254m".replace(';', separator)); assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(expectedBackground, mTerminal.mBackColor); // 24 bit colors, omitted parameter means zero: enterString("\033[38;2;255;127;m".replace(';', separator)); expectedForeground = 0xff000000 | (255 << 16) | (127 << 8); assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(expectedBackground, mTerminal.mBackColor); enterString("\033[38;2;123;;77m".replace(';', separator)); expectedForeground = 0xff000000 | (123 << 16) | 77; assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(expectedBackground, mTerminal.mBackColor); // 24 bit colors, extra sub-parameters are skipped: expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2; enterString("\033[0;38:2:255:127:2:48:2:1:2:254m"); assertEquals(expectedForeground, mTerminal.mForeColor); assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor); } public void testBackgroundColorErase() { 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", " "); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java ================================================ 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 final int INITIAL_CELL_WIDTH_PIXELS = 13; public static final int INITIAL_CELL_HEIGHT_PIXELS = 15; public static class MockTerminalOutput extends TerminalOutput { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); public final List titleChanges = new ArrayList<>(); public final List clipboardPuts = new ArrayList<>(); 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, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS, 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, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS); 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 void assertBackgroundColorAt(int externalRow, int column, int color) { long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column); assertEquals(color, TextStyle.decodeBackColor(style)); } public TerminalTestCase assertColor(int colorIndex, int expected) { int actual = mTerminal.mColors.mCurrentColors[colorIndex]; if (expected != actual) { fail("Color index=" + colorIndex + ", expected=" + Integer.toHexString(expected) + ", was=" + Integer.toHexString(actual)); } return this; } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/TextStyleTest.java ================================================ 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); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java ================================================ 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", " ", " "); } } ================================================ FILE: terminal-emulator/src/test/java/com/termux/terminal/WcWidthTest.java ================================================ 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). } } ================================================ FILE: terminal-view/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'maven-publish' android { namespace "com.termux.view" compileSdkVersion project.properties.compileSdkVersion.toInteger() dependencies { implementation "androidx.annotation:annotation:1.9.0" api project(":terminal-emulator") } defaultConfig { minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } publishing { multipleVariants { withSourcesJar() withJavadocJar() allVariants() } } } dependencies { testImplementation "junit:junit:4.13.2" } task sourceJar(type: Jar) { from android.sourceSets.main.java.srcDirs archiveClassifier = "sources" } afterEvaluate { publishing { publications { // Creates a Maven publication called "release". release(MavenPublication) { from components.default groupId = 'com.termux' artifactId = 'terminal-view' version = '0.118.0' artifact(sourceJar) } } } } ================================================ FILE: terminal-view/proguard-rules.pro ================================================ # 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 ================================================ FILE: terminal-view/src/main/AndroidManifest.xml ================================================ ================================================ FILE: terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java ================================================ 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(); } } ================================================ FILE: terminal-view/src/main/java/com/termux/view/TerminalRenderer.java ================================================ 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.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); } if (savedMatrix) canvas.restore(); } public float getFontWidth() { return mFontWidth; } public int getFontLineSpacing() { return mFontLineSpacing; } } ================================================ FILE: terminal-view/src/main/java/com/termux/view/TerminalView.java ================================================ 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.AutofillManager; import android.view.autofill.AutofillValue; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.Scroller; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.termux.terminal.KeyHandler; 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; /** * The current AutoFill type returned for {@link View#getAutofillType()} by {@link #getAutofillType()}. * * The default is {@link #AUTOFILL_TYPE_NONE} so that AutoFill UI, like toolbar above keyboard * is not shown automatically, like on Activity starts/View create. This value should be updated * to required value, like {@link #AUTOFILL_TYPE_TEXT} before calling * {@link AutofillManager#requestAutofill(View)} so that AutoFill UI shows. The updated value * set will automatically be restored to {@link #AUTOFILL_TYPE_NONE} in * {@link #autofill(AutofillValue)} so that AutoFill UI isn't shown anymore by calling * {@link #resetAutoFill()}. */ @RequiresApi(api = Build.VERSION_CODES.O) private int mAutoFillType = AUTOFILL_TYPE_NONE; /** * The current AutoFill type returned for {@link View#getImportantForAutofill()} by * {@link #getImportantForAutofill()}. * * The default is {@link #IMPORTANT_FOR_AUTOFILL_NO} so that view is not considered important * for AutoFill. This value should be updated to required value, like * {@link #IMPORTANT_FOR_AUTOFILL_YES} before calling {@link AutofillManager#requestAutofill(View)} * so that Android and apps consider the view as important for AutoFill to process the request. * The updated value set will automatically be restored to {@link #IMPORTANT_FOR_AUTOFILL_NO} in * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. */ @RequiresApi(api = Build.VERSION_CODES.O) private int mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; /** * The current AutoFill hints returned for {@link View#getAutofillHints()} ()} by {@link #getAutofillHints()} ()}. * * The default is an empty `string[]`. This value should be updated to required value. The * updated value set will automatically be restored an empty `string[]` in * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. */ private String[] mAutoFillHints = new String[0]; private final boolean mAccessibilityEnabled; /** 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) { cancelRequestAutoFill(); 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; } else if (keyCode == KeyEvent.KEYCODE_LANGUAGE_SWITCH) { return super.onKeyDown(keyCode, event); } 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 ? -mEmulator.mRows : mEmulator.mRows); 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, (int) mRenderer.getFontWidth(), mRenderer.getFontLineSpacing()); 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()); } resetAutoFill(); } @RequiresApi(api = Build.VERSION_CODES.O) @Override public int getAutofillType() { return mAutoFillType; } @RequiresApi(api = Build.VERSION_CODES.O) @Override public String[] getAutofillHints() { return mAutoFillHints; } @RequiresApi(api = Build.VERSION_CODES.O) @Override public AutofillValue getAutofillValue() { return AutofillValue.forText(""); } @RequiresApi(api = Build.VERSION_CODES.O) @Override public int getImportantForAutofill() { return mAutoFillImportance; } @RequiresApi(api = Build.VERSION_CODES.O) private synchronized void resetAutoFill() { // Restore none type so that AutoFill UI isn't shown anymore. mAutoFillType = AUTOFILL_TYPE_NONE; mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; mAutoFillHints = new String[0]; } public AutofillManager getAutoFillManagerService() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null; try { Context context = getContext(); if (context == null) return null; return context.getSystemService(AutofillManager.class); } catch (Exception e) { mClient.logStackTraceWithMessage(LOG_TAG, "Failed to get AutofillManager service", e); return null; } } public boolean isAutoFillEnabled() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false; try { AutofillManager autofillManager = getAutoFillManagerService(); return autofillManager != null && autofillManager.isEnabled(); } catch (Exception e) { mClient.logStackTraceWithMessage(LOG_TAG, "Failed to check if Autofill is enabled", e); return false; } } public synchronized void requestAutoFillUsername() { requestAutoFill( Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_USERNAME} : null); } public synchronized void requestAutoFillPassword() { requestAutoFill( Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_PASSWORD} : null); } public synchronized void requestAutoFill(String[] autoFillHints) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; if (autoFillHints == null || autoFillHints.length < 1) return; try { AutofillManager autofillManager = getAutoFillManagerService(); if (autofillManager != null && autofillManager.isEnabled()) { // Update type that will be returned by `getAutofillType()` so that AutoFill UI is shown. mAutoFillType = AUTOFILL_TYPE_TEXT; // Update importance that will be returned by `getImportantForAutofill()` so that // AutoFill considers the view as important. mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_YES; // Update hints that will be returned by `getAutofillHints()` for which to show AutoFill UI. mAutoFillHints = autoFillHints; autofillManager.requestAutofill(this); } } catch (Exception e) { mClient.logStackTraceWithMessage(LOG_TAG, "Failed to request Autofill", e); } } public synchronized void cancelRequestAutoFill() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; if (mAutoFillType == AUTOFILL_TYPE_NONE) return; try { AutofillManager autofillManager = getAutoFillManagerService(); if (autofillManager != null && autofillManager.isEnabled()) { resetAutoFill(); autofillManager.cancel(); } } catch (Exception e) { mClient.logStackTraceWithMessage(LOG_TAG, "Failed to cancel Autofill request", e); } } /** * 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(); } } } } ================================================ FILE: terminal-view/src/main/java/com/termux/view/TerminalViewClient.java ================================================ 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); } ================================================ FILE: terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java ================================================ /* * 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; } } ================================================ FILE: terminal-view/src/main/java/com/termux/view/textselection/CursorController.java ================================================ 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(); } ================================================ FILE: terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java ================================================ 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_text).setShowAsAction(show); menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show); menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more); return true; } @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(); } } ================================================ FILE: terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java ================================================ 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; } } ================================================ FILE: terminal-view/src/main/res/drawable/text_select_handle_left_material.xml ================================================ ================================================ FILE: terminal-view/src/main/res/drawable/text_select_handle_right_material.xml ================================================ ================================================ FILE: terminal-view/src/main/res/values/strings.xml ================================================ Paste Copy More… ================================================ FILE: termux-shared/.gitignore ================================================ /build ================================================ FILE: termux-shared/LICENSE.md ================================================ 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). ## ================================================ FILE: termux-shared/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'maven-publish' android { namespace = "com.termux.shared" compileSdkVersion project.properties.compileSdkVersion.toInteger() ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion dependencies { implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.annotation:annotation:1.9.0" implementation "androidx.core:core:1.13.1" implementation "com.google.android.material:material:1.12.0" implementation "com.google.guava:guava:24.1-jre" implementation "io.noties.markwon:core:$markwonVersion" implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" implementation "io.noties.markwon:linkify:$markwonVersion" implementation "io.noties.markwon:recycler:$markwonVersion" implementation "org.lsposed.hiddenapibypass:hiddenapibypass:6.1" implementation "androidx.window:window:1.1.0" // Do not increment version higher than 2.5 or there // will be runtime exceptions on android < 8 // due to missing classes like java.nio.file.Path. implementation "commons-io:commons-io:2.5" implementation project(":terminal-view") implementation "com.termux:termux-am-library:v2.0.0" } defaultConfig { compileSdkVersion project.properties.compileSdkVersion.toInteger() minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { ndkBuild { cppFlags '' } } } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } compileOptions { // Flag to enable support for the new language APIs coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } externalNativeBuild { ndkBuild { path file('src/main/cpp/Android.mk') } } publishing { multipleVariants { withSourcesJar() withJavadocJar() allVariants() } } } dependencies { testImplementation "junit:junit:4.13.2" androidTestImplementation "androidx.test.ext:junit:1.1.5" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" } task sourceJar(type: Jar) { from android.sourceSets.main.java.srcDirs archiveClassifier = "sources" } afterEvaluate { publishing { publications { // Creates a Maven publication called "release". release(MavenPublication) { from components.default groupId = 'com.termux' artifactId = 'termux-shared' version = '0.118.0' artifact(sourceJar) } } } } ================================================ FILE: termux-shared/proguard-rules.pro ================================================ # 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 ================================================ FILE: termux-shared/src/androidTest/java/com/termux/shared/ExampleInstrumentedTest.java ================================================ 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()); } } ================================================ FILE: termux-shared/src/main/AndroidManifest.xml ================================================ ================================================ FILE: termux-shared/src/main/cpp/Android.mk ================================================ 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) ================================================ FILE: termux-shared/src/main/cpp/Application.mk ================================================ APP_STL := c++_static ================================================ FILE: termux-shared/src/main/cpp/local-socket.cpp ================================================ #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); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java ================================================ package com.termux.shared.activities; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; 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 com.termux.shared.R; import com.termux.shared.activity.media.AppCompatActivityUtils; import com.termux.shared.data.DataUtils; import com.termux.shared.file.FileUtils; import com.termux.shared.file.filesystem.FileType; import com.termux.shared.logger.Logger; import com.termux.shared.errors.Error; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.markdown.MarkdownUtils; import com.termux.shared.interact.ShareUtils; import com.termux.shared.models.ReportInfo; import com.termux.shared.theme.NightMode; import org.commonmark.node.FencedCodeBlock; import org.jetbrains.annotations.NotNull; import io.noties.markwon.Markwon; import io.noties.markwon.recycler.MarkwonAdapter; import io.noties.markwon.recycler.SimpleEntry; /** * 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 AppCompatActivity { 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"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Logger.logVerbose(LOG_TAG, "onCreate"); AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true); setContentView(R.layout.activity_report); 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"); 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)); } } } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java ================================================ 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); } } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java ================================================ 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()); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/UserUtils.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/data/DataUtils.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/errors/Errno.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/errors/Error.java ================================================ 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)); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/FileUtils.java ================================================ package com.termux.shared.file; import android.os.Build; import android.system.Os; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.common.io.RecursiveDeleteOption; import com.termux.shared.file.filesystem.FileType; import com.termux.shared.file.filesystem.FileTypes; import com.termux.shared.data.DataUtils; import com.termux.shared.logger.Logger; import com.termux.shared.errors.Errno; import com.termux.shared.errors.Error; import com.termux.shared.errors.FunctionErrno; import org.apache.commons.io.filefilter.AgeFileFilter; import org.apache.commons.io.filefilter.IOFileFilter; 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.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.regex.Pattern; 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 + "\""); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { /* * Try to use {@link SecureDirectoryStream} if available for safer directory * deletion, it should be available for android >= 8.0 * https://guava.dev/releases/24.1-jre/api/docs/com/google/common/io/MoreFiles.html#deleteRecursively-java.nio.file.Path-com.google.common.io.RecursiveDeleteOption...- * https://github.com/google/guava/issues/365 * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixSecureDirectoryStream.java * * MoreUtils is marked with the @Beta annotation so the API may be removed in * future but has been there for a few years now. * * If an exception is thrown, the exception message might not contain the full errors. * Individual failures get added to suppressed throwables which can be extracted * from the exception object by calling `Throwable[] getSuppressed()`. So just logging * the exception message and stacktrace may not be enough, the suppressed throwables * need to be logged as well, which the Logger class does if they are found in the * exception added to the Error that's returned by this function. * https://github.com/google/guava/blob/v30.1.1/guava/src/com/google/common/io/MoreFiles.java#L775 */ //noinspection UnstableApiUsage com.google.common.io.MoreFiles.deleteRecursively(file.toPath(), RecursiveDeleteOption.ALLOW_INSECURE); } else { if (fileType == FileType.DIRECTORY) { // deleteDirectory() instead of forceDelete() gets the files list first instead of walking directory tree, so seems safer // 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.deleteDirectory(file); } 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.forceDelete(file); } } // 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { /* If an exception is thrown, the exception message might not contain the full errors. * Individual failures get added to suppressed throwables. */ //noinspection UnstableApiUsage com.google.common.io.MoreFiles.deleteDirectoryContents(file.toPath(), RecursiveDeleteOption.ALLOW_INSECURE); } 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.cleanDirectory(new File(filePath)); } } // 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java ================================================ 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); }}; } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java ================================================ /* * 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java ================================================ /* * 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java ================================================ /* * 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 } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java ================================================ /* * 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java ================================================ /* * 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); } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java ================================================ 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() + "\""); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java ================================================ /* * 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; } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java ================================================ 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, androidx.appcompat.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)); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java ================================================ 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); } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/logger/Logger.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java ================================================ package com.termux.shared.models; import androidx.annotation.Keep; 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 { /** * Explicitly define `serialVersionUID` to prevent exceptions on deserialization. * * Like when calling `Bundle.getSerializable()` on Android. * `android.os.BadParcelableException: Parcelable encountered IOException reading a Serializable object` (name = ) * `java.io.InvalidClassException: ; local class incompatible` * * The `@Keep` annotation is necessary to prevent the field from being removed by proguard when * app is compiled, even if its kept during library compilation. * * **See Also:** * - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/version.html#a6678 * - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/class.html#a4100 */ @Keep private static final long serialVersionUID = 1L; /** 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java ================================================ package com.termux.shared.models; import android.graphics.Color; import android.graphics.Typeface; import androidx.annotation.Keep; 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 { /** * Explicitly define `serialVersionUID` to prevent exceptions on deserialization. * * Like when calling `Bundle.getSerializable()` on Android. * `android.os.BadParcelableException: Parcelable encountered IOException reading a Serializable object` (name = ) * `java.io.InvalidClassException: ; local class incompatible` * * The `@Keep` annotation is necessary to prevent the field from being removed by proguard when * app is compiled, even if its kept during library compilation. * * **See Also:** * - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/version.html#a6678 * - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/class.html#a4100 */ @Keep private static final long serialVersionUID = 1L; 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java ================================================ 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); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java ================================================ 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()); } } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java ================================================ 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"); } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java ================================================ 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); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java ================================================ 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(); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java ================================================ 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"; } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java ================================================ 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("/+$", "")); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java ================================================ 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); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java ================================================ /*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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java ================================================ /* * 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java ================================================ 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); } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java ================================================ 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"; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java ================================================ package com.termux.shared.shell.command.environment; import android.content.Context; import androidx.annotation.NonNull; 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.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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java ================================================ 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); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java ================================================ 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()); } } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java ================================================ 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); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java ================================================ package com.termux.shared.termux; import android.content.Context; import androidx.annotation.NonNull; 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 PackageManager TERMUX_APP_PACKAGE_MANAGER; /** The {@link PackageVariant} for the bootstrap in the app APK added in app/build.gradle. */ public static PackageVariant TERMUX_APP_PACKAGE_VARIANT; /** Set {@link #TERMUX_APP_PACKAGE_VARIANT} and {@link #TERMUX_APP_PACKAGE_MANAGER} from {@code packageVariantName} passed. */ public static void setTermuxPackageManagerAndVariant(@Nullable String packageVariantName) { TERMUX_APP_PACKAGE_VARIANT = PackageVariant.variantOf(packageVariantName); if (TERMUX_APP_PACKAGE_VARIANT == null) { throw new RuntimeException("Unsupported TERMUX_APP_PACKAGE_VARIANT \"" + packageVariantName + "\""); } Logger.logVerbose(LOG_TAG, "Set TERMUX_APP_PACKAGE_VARIANT to \"" + TERMUX_APP_PACKAGE_VARIANT + "\""); // Set packageManagerName to substring before first dash "-" in packageVariantName int index = packageVariantName.indexOf('-'); String packageManagerName = (index == -1) ? null : packageVariantName.substring(0, index); TERMUX_APP_PACKAGE_MANAGER = PackageManager.managerOf(packageManagerName); if (TERMUX_APP_PACKAGE_MANAGER == null) { throw new RuntimeException("Unsupported TERMUX_APP_PACKAGE_MANAGER \"" + packageManagerName + "\" with variant \"" + packageVariantName + "\""); } Logger.logVerbose(LOG_TAG, "Set TERMUX_APP_PACKAGE_MANAGER to \"" + TERMUX_APP_PACKAGE_MANAGER + "\""); } /** * Set {@link #TERMUX_APP_PACKAGE_VARIANT} and {@link #TERMUX_APP_PACKAGE_MANAGER} with the * {@link #BUILD_CONFIG_FIELD_TERMUX_PACKAGE_VARIANT} 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 and can be used * by plugin apps. * * @param currentPackageContext The context of current package. */ public static void setTermuxPackageManagerAndVariantFromTermuxApp(@NonNull Context currentPackageContext) { String packageVariantName = getTermuxAppBuildConfigPackageVariantFromTermuxApp(currentPackageContext); if (packageVariantName != null) { TermuxBootstrap.setTermuxPackageManagerAndVariant(packageVariantName); } else { Logger.logError(LOG_TAG, "Failed to set TERMUX_APP_PACKAGE_VARIANT and TERMUX_APP_PACKAGE_MANAGER from the termux app"); } } /** * Get {@link #BUILD_CONFIG_FIELD_TERMUX_PACKAGE_VARIANT} 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. * * @param currentPackageContext The context of current package. * @return Returns the field value, otherwise {@code null} if an exception was raised or failed * to get termux app package context. */ public static String getTermuxAppBuildConfigPackageVariantFromTermuxApp(@NonNull Context currentPackageContext) { try { return (String) TermuxUtils.getTermuxAppAPKBuildConfigClassField(currentPackageContext, BUILD_CONFIG_FIELD_TERMUX_PACKAGE_VARIANT); } catch (Exception e) { Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + BUILD_CONFIG_FIELD_TERMUX_PACKAGE_VARIANT + "\" value from \"" + TERMUX_APP.BUILD_CONFIG_CLASS_NAME + "\" class", e); return null; } } /** Is {@link PackageManager#APT} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */ public static boolean isAppPackageManagerAPT() { return PackageManager.APT.equals(TERMUX_APP_PACKAGE_MANAGER); } ///** 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 PackageVariant.APT_ANDROID_7.equals(TERMUX_APP_PACKAGE_VARIANT); } /** Is {@link PackageVariant#APT_ANDROID_5} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */ public static boolean isAppPackageVariantAPTAndroid5() { return PackageVariant.APT_ANDROID_5.equals(TERMUX_APP_PACKAGE_VARIANT); } ///** 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java ================================================ package com.termux.shared.termux; import android.annotation.SuppressLint; import android.content.Intent; 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.53.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`. * * - 0.53.0 (2025-01-12) * - Renamed `TERMUX_API`, `TERMUX_STYLING`, `TERMUX_TASKER`, `TERMUX_WIDGET` classes with `_APP` suffix added. * - Added `TERMUX_*_MAIN_ACTIVITY_NAME` and `TERMUX_*_LAUNCHER_ACTIVITY_NAME` constants to each app class. */ /** * 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 = "termux"; // 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" /** F-Droid packages base url */ public static final String FDROID_PACKAGES_BASE_URL = "https://f-droid.org/en/packages"; // Default: "https://f-droid.org/en/packages" /* * Termux and its plugin app and package names and urls. */ /** Termux app name */ public static final String TERMUX_APP_NAME = "Termux"; // Default: "Termux" /** Termux package name */ public static final String TERMUX_PACKAGE_NAME = "com.termux"; // Default: "com.termux" /** Termux GitHub repo name */ public static final String TERMUX_GITHUB_REPO_NAME = "termux-app"; // 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 F-Droid package url */ public static final String TERMUX_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux" /** Termux:API app name */ public static final String TERMUX_API_APP_NAME = "Termux:API"; // Default: "Termux:API" /** Termux:API app package name */ public static final String TERMUX_API_PACKAGE_NAME = TERMUX_PACKAGE_NAME + ".api"; // Default: "com.termux.api" /** Termux:API GitHub repo name */ public static final String TERMUX_API_GITHUB_REPO_NAME = "termux-api"; // Default: "termux-api" /** Termux:API GitHub repo url */ public static final String TERMUX_API_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_API_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-api" /** Termux:API GitHub issues repo url */ public static final String TERMUX_API_GITHUB_ISSUES_REPO_URL = TERMUX_API_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-api/issues" /** Termux:API F-Droid package url */ public static final String TERMUX_API_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_API_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux.api" /** Termux:Boot app name */ public static final String TERMUX_BOOT_APP_NAME = "Termux:Boot"; // Default: "Termux:Boot" /** Termux:Boot app package name */ public static final String TERMUX_BOOT_PACKAGE_NAME = TERMUX_PACKAGE_NAME + ".boot"; // Default: "com.termux.boot" /** Termux:Boot GitHub repo name */ public static final String TERMUX_BOOT_GITHUB_REPO_NAME = "termux-boot"; // Default: "termux-boot" /** Termux:Boot GitHub repo url */ public static final String TERMUX_BOOT_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_BOOT_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-boot" /** Termux:Boot GitHub issues repo url */ public static final String TERMUX_BOOT_GITHUB_ISSUES_REPO_URL = TERMUX_BOOT_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-boot/issues" /** Termux:Boot F-Droid package url */ public static final String TERMUX_BOOT_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_BOOT_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux.boot" /** Termux:Float app name */ public static final String TERMUX_FLOAT_APP_NAME = "Termux:Float"; // Default: "Termux:Float" /** Termux:Float app package name */ public static final String TERMUX_FLOAT_PACKAGE_NAME = TERMUX_PACKAGE_NAME + ".window"; // Default: "com.termux.window" /** Termux:Float GitHub repo name */ public static final String TERMUX_FLOAT_GITHUB_REPO_NAME = "termux-float"; // Default: "termux-float" /** Termux:Float GitHub repo url */ public static final String TERMUX_FLOAT_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_FLOAT_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-float" /** Termux:Float GitHub issues repo url */ public static final String TERMUX_FLOAT_GITHUB_ISSUES_REPO_URL = TERMUX_FLOAT_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-float/issues" /** Termux:Float F-Droid package url */ public static final String TERMUX_FLOAT_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_FLOAT_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux.window" /** Termux:Styling app name */ public static final String TERMUX_STYLING_APP_NAME = "Termux:Styling"; // Default: "Termux:Styling" /** Termux:Styling app package name */ public static final String TERMUX_STYLING_PACKAGE_NAME = TERMUX_PACKAGE_NAME + ".styling"; // Default: "com.termux.styling" /** Termux:Styling GitHub repo name */ public static final String TERMUX_STYLING_GITHUB_REPO_NAME = "termux-styling"; // Default: "termux-styling" /** Termux:Styling GitHub repo url */ public static final String TERMUX_STYLING_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_STYLING_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-styling" /** Termux:Styling GitHub issues repo url */ public static final String TERMUX_STYLING_GITHUB_ISSUES_REPO_URL = TERMUX_STYLING_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-styling/issues" /** Termux:Styling F-Droid package url */ public static final String TERMUX_STYLING_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_STYLING_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux.styling" /** Termux:Tasker app name */ public static final String TERMUX_TASKER_APP_NAME = "Termux:Tasker"; // Default: "Termux:Tasker" /** Termux:Tasker app package name */ public static final String TERMUX_TASKER_PACKAGE_NAME = TERMUX_PACKAGE_NAME + ".tasker"; // Default: "com.termux.tasker" /** Termux:Tasker GitHub repo name */ public static final String TERMUX_TASKER_GITHUB_REPO_NAME = "termux-tasker"; // Default: "termux-tasker" /** Termux:Tasker GitHub repo url */ public static final String TERMUX_TASKER_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_TASKER_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-tasker" /** Termux:Tasker GitHub issues repo url */ public static final String TERMUX_TASKER_GITHUB_ISSUES_REPO_URL = TERMUX_TASKER_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-tasker/issues" /** Termux:Tasker F-Droid package url */ public static final String TERMUX_TASKER_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_TASKER_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux.tasker" /** Termux:Widget app name */ public static final String TERMUX_WIDGET_APP_NAME = "Termux:Widget"; // Default: "Termux:Widget" /** Termux:Widget app package name */ public static final String TERMUX_WIDGET_PACKAGE_NAME = TERMUX_PACKAGE_NAME + ".widget"; // Default: "com.termux.widget" /** Termux:Widget GitHub repo name */ public static final String TERMUX_WIDGET_GITHUB_REPO_NAME = "termux-widget"; // Default: "termux-widget" /** Termux:Widget GitHub repo url */ public static final String TERMUX_WIDGET_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_WIDGET_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-widget" /** Termux:Widget GitHub issues repo url */ public static final String TERMUX_WIDGET_GITHUB_ISSUES_REPO_URL = TERMUX_WIDGET_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-widget/issues" /** Termux:Widget F-Droid package url */ public static final String TERMUX_WIDGET_FDROID_PACKAGE_URL = FDROID_PACKAGES_BASE_URL + "/" + TERMUX_WIDGET_PACKAGE_NAME; // Default: "https://f-droid.org/en/packages/com.termux.widget" /* * Termux plugin apps lists. */ public static final List TERMUX_PLUGIN_APP_NAMES_LIST = Arrays.asList( TERMUX_API_APP_NAME, TERMUX_BOOT_APP_NAME, TERMUX_FLOAT_APP_NAME, TERMUX_STYLING_APP_NAME, TERMUX_TASKER_APP_NAME, TERMUX_WIDGET_APP_NAME); public static final List TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST = Arrays.asList( TERMUX_API_PACKAGE_NAME, TERMUX_BOOT_PACKAGE_NAME, TERMUX_FLOAT_PACKAGE_NAME, TERMUX_STYLING_PACKAGE_NAME, TERMUX_TASKER_PACKAGE_NAME, TERMUX_WIDGET_PACKAGE_NAME); /* * 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 = "termux-packages"; // Default: "termux-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 Site url */ public static final String TERMUX_SITE_URL = "https://termux.dev"; // Default: "https://termux.dev" /** 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 = "support@termux.dev"; // 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 Reddit subreddit */ public static final String TERMUX_REDDIT_SUBREDDIT = "r/termux"; // Default: "r/termux" /** Termux Reddit subreddit url */ public static final String TERMUX_REDDIT_SUBREDDIT_URL = "https://www.reddit.com/r/termux"; // Default: "https://www.reddit.com/r/termux" /** Termux donate url */ public static final String TERMUX_DONATE_URL = TERMUX_SITE_URL + "/donate"; // Default: "https://termux.dev/donate" /* * 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 + "_preferences"; // Default: "com.termux_preferences" /** Termux:API app default SharedPreferences file basename without extension */ public static final String TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_API_PACKAGE_NAME + "_preferences"; // Default: "com.termux.api_preferences" /** Termux:Boot app default SharedPreferences file basename without extension */ public static final String TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_BOOT_PACKAGE_NAME + "_preferences"; // Default: "com.termux.boot_preferences" /** Termux:Float app default SharedPreferences file basename without extension */ public static final String TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_FLOAT_PACKAGE_NAME + "_preferences"; // Default: "com.termux.window_preferences" /** Termux:Styling app default SharedPreferences file basename without extension */ public static final String TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_STYLING_PACKAGE_NAME + "_preferences"; // Default: "com.termux.styling_preferences" /** Termux:Tasker app default SharedPreferences file basename without extension */ public static final String TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_TASKER_PACKAGE_NAME + "_preferences"; // Default: "com.termux.tasker_preferences" /** Termux:Widget app default SharedPreferences file basename without extension */ public static final String TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_WIDGET_PACKAGE_NAME + "_preferences"; // Default: "com.termux.widget_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:Float app properties primary file path */ public static final String TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/termux.float.properties"; // Default: "/data/data/com.termux/files/home/.termux/termux.float.properties" /** Termux:Float app properties primary file */ public static final File TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE = new File(TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE_PATH); /** Termux:Float app properties secondary file path */ public static final String TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE_PATH = TERMUX_CONFIG_HOME_DIR_PATH + "/termux.float.properties"; // Default: "/data/data/com.termux/files/home/.config/termux/termux.float.properties" /** Termux:Float app properties secondary file */ public static final File TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE = new File(TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE_PATH); /** Termux:Float 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_FLOAT_PROPERTIES_FILE_PATHS_LIST = Arrays.asList( TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE_PATH, TERMUX_FLOAT_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 plugin specific paths. */ /** Termux app directory path to store scripts to be run at boot by Termux:Boot */ public static final String TERMUX_BOOT_SCRIPTS_DIR_PATH = TERMUX_DATA_HOME_DIR_PATH + "/boot"; // Default: "/data/data/com.termux/files/home/.termux/boot" /** Termux app directory to store scripts to be run at boot by Termux:Boot */ public static final File TERMUX_BOOT_SCRIPTS_DIR = new File(TERMUX_BOOT_SCRIPTS_DIR_PATH); /** Termux app directory path to store foreground scripts that can be run by the termux launcher * widget provided by Termux:Widget */ public static final String TERMUX_SHORTCUT_SCRIPTS_DIR_PATH = TERMUX_HOME_DIR_PATH + "/.shortcuts"; // Default: "/data/data/com.termux/files/home/.shortcuts" /** Termux app directory to store foreground scripts that can be run by the termux launcher widget provided by Termux:Widget */ public static final File TERMUX_SHORTCUT_SCRIPTS_DIR = new File(TERMUX_SHORTCUT_SCRIPTS_DIR_PATH); /** Termux app directory basename that stores background scripts that can be run by the termux * launcher widget provided by Termux:Widget */ public static final String TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR_BASENAME = "tasks"; // Default: "tasks" /** Termux app directory path to store background scripts that can be run by the termux launcher * widget provided by Termux:Widget */ public static final String TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR_PATH = TERMUX_SHORTCUT_SCRIPTS_DIR_PATH + "/" + TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR_BASENAME; // Default: "/data/data/com.termux/files/home/.shortcuts/tasks" /** Termux app directory to store background scripts that can be run by the termux launcher widget provided by Termux:Widget */ public static final File TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR = new File(TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR_PATH); /** Termux app directory basename that stores icons for the foreground and background scripts * that can be run by the termux launcher widget provided by Termux:Widget */ public static final String TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_BASENAME = "icons"; // Default: "icons" /** Termux app directory path to store icons for the foreground and background scripts that can * be run by the termux launcher widget provided by Termux:Widget */ public static final String TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_PATH = TERMUX_SHORTCUT_SCRIPTS_DIR_PATH + "/" + TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_BASENAME; // Default: "/data/data/com.termux/files/home/.shortcuts/icons" /** Termux app directory to store icons for the foreground and background scripts that can be * run by the termux launcher widget provided by Termux:Widget */ public static final File TERMUX_SHORTCUT_SCRIPT_ICONS_DIR = new File(TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_PATH); /** Termux app directory path to store scripts to be run by 3rd party twofortyfouram locale plugin * host apps like Tasker app via the Termux:Tasker plugin client */ public static final String TERMUX_TASKER_SCRIPTS_DIR_PATH = TERMUX_DATA_HOME_DIR_PATH + "/tasker"; // Default: "/data/data/com.termux/files/home/.termux/tasker" /** Termux app directory to store scripts to be run by 3rd party twofortyfouram locale plugin host apps like Tasker app via the Termux:Tasker plugin client */ public static final File TERMUX_TASKER_SCRIPTS_DIR = new File(TERMUX_TASKER_SCRIPTS_DIR_PATH); /* * 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; /** Termux app notification channel id used by {@link TERMUX_APP.RUN_COMMAND_SERVICE} */ public static final String TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID = "termux_run_command_notification_channel"; /** Termux app notification channel name used by {@link TERMUX_APP.RUN_COMMAND_SERVICE} */ public static final String TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " RunCommandService"; /** Termux app unique notification id used by {@link TERMUX_APP.RUN_COMMAND_SERVICE} */ 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 notification channel id used by {@link TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE} */ public static final String TERMUX_FLOAT_APP_NOTIFICATION_CHANNEL_ID = "termux_float_notification_channel"; /** Termux app notification channel name used by {@link TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE} */ public static final String TERMUX_FLOAT_APP_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_FLOAT_APP_NAME + " App"; /** Termux app unique notification id used by {@link TERMUX_APP.TERMUX_SERVICE} */ public static final int TERMUX_FLOAT_APP_NOTIFICATION_ID = 1339; /* * Termux app and plugins miscellaneous variables. */ /** Android OS permission declared by Termux app in AndroidManifest.xml which can be requested by * 3rd party apps to run various commands in Termux app context */ public static final String PERMISSION_RUN_COMMAND = TERMUX_PACKAGE_NAME + ".permission.RUN_COMMAND"; // Default: "com.termux.permission.RUN_COMMAND" /** 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" } } /** * Termux:API app constants. */ public static final class TERMUX_API_APP { /** Termux:API app main activity name. */ public static final String TERMUX_API_MAIN_ACTIVITY_NAME = TERMUX_API_PACKAGE_NAME + ".activities.TermuxAPIMainActivity"; // Default: "com.termux.api.activities.TermuxAPIMainActivity" /** Termux:API app launcher activity name. This is an `activity-alias` for {@link #TERMUX_API_MAIN_ACTIVITY_NAME} used for launchers with {@link Intent#CATEGORY_LAUNCHER}. */ public static final String TERMUX_API_LAUNCHER_ACTIVITY_NAME = TERMUX_API_PACKAGE_NAME + ".activities.TermuxAPILauncherActivity"; // Default: "com.termux.api.activities.TermuxAPILauncherActivity" } /** * Termux:Boot app constants. */ public static final class TERMUX_BOOT_APP { /** Termux:Boot app main activity name. */ public static final String TERMUX_BOOT_MAIN_ACTIVITY_NAME = TERMUX_BOOT_PACKAGE_NAME + ".activities.TermuxBootMainActivity"; // Default: "com.termux.boot.activities.TermuxBootMainActivity" /** Termux:Boot app launcher activity name. This is an `activity-alias` for {@link #TERMUX_BOOT_MAIN_ACTIVITY_NAME} used for launchers with {@link Intent#CATEGORY_LAUNCHER}. */ public static final String TERMUX_BOOT_LAUNCHER_ACTIVITY_NAME = TERMUX_BOOT_PACKAGE_NAME + ".activities.TermuxBootLauncherActivity"; // Default: "com.termux.boot.activities.TermuxBootLauncherActivity" } /** * Termux:Float app constants. */ public static final class TERMUX_FLOAT_APP { /** Termux:Float app core activity name. */ public static final String TERMUX_FLOAT_ACTIVITY_NAME = TERMUX_FLOAT_PACKAGE_NAME + ".TermuxFloatActivity"; // Default: "com.termux.window.TermuxFloatActivity" /** Termux:Float app core service name. */ public static final String TERMUX_FLOAT_SERVICE_NAME = TERMUX_FLOAT_PACKAGE_NAME + ".TermuxFloatService"; // Default: "com.termux.window.TermuxFloatService" /** * Termux:Float app core service. */ public static final class TERMUX_FLOAT_SERVICE { /** Intent action to stop TERMUX_FLOAT_SERVICE. */ public static final String ACTION_STOP_SERVICE = TERMUX_FLOAT_PACKAGE_NAME + ".ACTION_STOP_SERVICE"; // Default: "com.termux.float.ACTION_STOP_SERVICE" /** Intent action to show float window. */ public static final String ACTION_SHOW = TERMUX_FLOAT_PACKAGE_NAME + ".ACTION_SHOW"; // Default: "com.termux.float.ACTION_SHOW" /** Intent action to hide float window. */ public static final String ACTION_HIDE = TERMUX_FLOAT_PACKAGE_NAME + ".ACTION_HIDE"; // Default: "com.termux.float.ACTION_HIDE" } } /** * Termux:Styling app constants. */ public static final class TERMUX_STYLING_APP { /** Termux:Styling app core activity name. */ public static final String TERMUX_STYLING_ACTIVITY_NAME = TERMUX_STYLING_PACKAGE_NAME + ".TermuxStyleActivity"; // Default: "com.termux.styling.TermuxStyleActivity" /** Termux:Styling app main activity name. */ public static final String TERMUX_STYLING_MAIN_ACTIVITY_NAME = TERMUX_STYLING_PACKAGE_NAME + ".activities.TermuxStylingMainActivity"; // Default: "com.termux.styling.activities.TermuxStylingMainActivity" /** Termux:Styling app launcher activity name. This is an `activity-alias` for {@link #TERMUX_STYLING_MAIN_ACTIVITY_NAME} used for launchers with {@link Intent#CATEGORY_LAUNCHER}. */ public static final String TERMUX_STYLING_LAUNCHER_ACTIVITY_NAME = TERMUX_STYLING_PACKAGE_NAME + ".activities.TermuxStylingLauncherActivity"; // Default: "com.termux.styling.activities.TermuxStylingLauncherActivity" } /** * Termux:Tasker app constants. */ public static final class TERMUX_TASKER_APP { /** Termux:Tasker app main activity name. */ public static final String TERMUX_TASKER_MAIN_ACTIVITY_NAME = TERMUX_TASKER_PACKAGE_NAME + ".activities.TermuxTaskerMainActivity"; // Default: "com.termux.tasker.activities.TermuxTaskerMainActivity" /** Termux:Tasker app launcher activity name. This is an `activity-alias` for {@link #TERMUX_TASKER_MAIN_ACTIVITY_NAME} used for launchers with {@link Intent#CATEGORY_LAUNCHER}. */ public static final String TERMUX_TASKER_LAUNCHER_ACTIVITY_NAME = TERMUX_TASKER_PACKAGE_NAME + ".activities.TermuxTaskerLauncherActivity"; // Default: "com.termux.tasker.activities.TermuxTaskerLauncherActivity" } /** * Termux:Widget app constants. */ public static final class TERMUX_WIDGET_APP { /** Termux:Widget app main activity name. */ public static final String TERMUX_WIDGET_MAIN_ACTIVITY_NAME = TERMUX_WIDGET_PACKAGE_NAME + ".activities.TermuxWidgetMainActivity"; // Default: "com.termux.widget.activities.TermuxWidgetMainActivity" /** Termux:Widget app launcher activity name. This is an `activity-alias` for {@link #TERMUX_WIDGET_MAIN_ACTIVITY_NAME} used for launchers with {@link Intent#CATEGORY_LAUNCHER}. */ public static final String TERMUX_WIDGET_LAUNCHER_ACTIVITY_NAME = TERMUX_WIDGET_PACKAGE_NAME + ".activities.TermuxWidgetLauncherActivity"; // Default: "com.termux.widget.activities.TermuxWidgetLauncherActivity" /** Intent {@code String} extra for the token of the Termux:Widget app shortcuts. */ public static final String EXTRA_TOKEN_NAME = TERMUX_PACKAGE_NAME + ".shortcut.token"; // Default: "com.termux.shortcut.token" /** * Termux:Widget app {@link android.appwidget.AppWidgetProvider} class. */ public static final class TERMUX_WIDGET_PROVIDER { /** Intent action for if an item is clicked in the widget. */ public static final String ACTION_WIDGET_ITEM_CLICKED = TERMUX_WIDGET_PACKAGE_NAME + ".ACTION_WIDGET_ITEM_CLICKED"; // Default: "com.termux.widget.ACTION_WIDGET_ITEM_CLICKED" /** Intent action to refresh files in the widget. */ public static final String ACTION_REFRESH_WIDGET = TERMUX_WIDGET_PACKAGE_NAME + ".ACTION_REFRESH_WIDGET"; // Default: "com.termux.widget.ACTION_REFRESH_WIDGET" /** Intent {@code String} extra for the file clicked for the TERMUX_WIDGET_PROVIDER.ACTION_WIDGET_ITEM_CLICKED intent. */ public static final String EXTRA_FILE_CLICKED = TERMUX_WIDGET_PACKAGE_NAME + ".EXTRA_FILE_CLICKED"; // Default: "com.termux.widget.EXTRA_FILE_CLICKED" } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java ================================================ package com.termux.shared.termux; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; 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.data.DataUtils; import com.termux.shared.file.FileUtils; import com.termux.shared.reflection.ReflectionUtils; import com.termux.shared.shell.command.runner.app.AppShell; import com.termux.shared.termux.file.TermuxFileUtils; 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.android.PackageUtils; import com.termux.shared.termux.TermuxConstants.TERMUX_APP; import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; import org.apache.commons.io.IOUtils; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.util.List; import java.util.regex.Pattern; 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, /** Get info for Termux app and its plugins listed in {@link TermuxConstants#TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST}. */ TERMUX_AND_PLUGIN_PACKAGES, /* Get info for all the Termux app plugins listed in {@link TermuxConstants#TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST}. */ TERMUX_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); } /** * Get the {@link Context} for {@link TermuxConstants#TERMUX_API_PACKAGE_NAME} package. * * @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 getTermuxAPIPackageContext(@NonNull Context context) { return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_API_PACKAGE_NAME); } /** * Get the {@link Context} for {@link TermuxConstants#TERMUX_BOOT_PACKAGE_NAME} package. * * @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 getTermuxBootPackageContext(@NonNull Context context) { return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_BOOT_PACKAGE_NAME); } /** * Get the {@link Context} for {@link TermuxConstants#TERMUX_FLOAT_PACKAGE_NAME} package. * * @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 getTermuxFloatPackageContext(@NonNull Context context) { return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_FLOAT_PACKAGE_NAME); } /** * Get the {@link Context} for {@link TermuxConstants#TERMUX_STYLING_PACKAGE_NAME} package. * * @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 getTermuxStylingPackageContext(@NonNull Context context) { return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_STYLING_PACKAGE_NAME); } /** * Get the {@link Context} for {@link TermuxConstants#TERMUX_TASKER_PACKAGE_NAME} package. * * @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 getTermuxTaskerPackageContext(@NonNull Context context) { return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_TASKER_PACKAGE_NAME); } /** * Get the {@link Context} for {@link TermuxConstants#TERMUX_WIDGET_PACKAGE_NAME} package. * * @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 getTermuxWidgetPackageContext(@NonNull Context context) { return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_WIDGET_PACKAGE_NAME); } /** 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:API app is installed and enabled. This can be used by external apps that don't * share `sharedUserId` with the Termux:API app. * * @param context The context for operations. * @return Returns {@code errmsg} if {@link TermuxConstants#TERMUX_API_PACKAGE_NAME} is not installed * or disabled, otherwise {@code null}. */ public static String isTermuxAPIAppInstalled(@NonNull final Context context) { return PackageUtils.isAppInstalled(context, TermuxConstants.TERMUX_API_APP_NAME, TermuxConstants.TERMUX_API_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; StringBuilder appInfo = new StringBuilder(); switch (appInfoMode) { case TERMUX_PACKAGE: return getAppInfoMarkdownString(currentPackageContext, false); case TERMUX_AND_PLUGIN_PACKAGE: return getAppInfoMarkdownString(currentPackageContext, true); case TERMUX_AND_PLUGIN_PACKAGES: appInfo.append(TermuxUtils.getAppInfoMarkdownString(currentPackageContext, false)); String termuxPluginAppsInfo = TermuxUtils.getTermuxPluginAppsInfoMarkdownString(currentPackageContext); if (termuxPluginAppsInfo != null) appInfo.append("\n\n").append(termuxPluginAppsInfo); return appInfo.toString(); case TERMUX_PLUGIN_PACKAGES: return TermuxUtils.getTermuxPluginAppsInfoMarkdownString(currentPackageContext); case TERMUX_AND_CALLING_PACKAGE: appInfo.append(TermuxUtils.getAppInfoMarkdownString(currentPackageContext, false)); if (!DataUtils.isNullOrEmpty(callingPackageName)) { String callingPackageAppInfo = null; if (TermuxConstants.TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST.contains(callingPackageName)) { Context termuxPluginAppContext = PackageUtils.getContextForPackage(currentPackageContext, callingPackageName); if (termuxPluginAppContext != null) appInfo.append(getAppInfoMarkdownString(termuxPluginAppContext, false)); else callingPackageAppInfo = AndroidUtils.getAppInfoMarkdownString(currentPackageContext, callingPackageName); } else { callingPackageAppInfo = AndroidUtils.getAppInfoMarkdownString(currentPackageContext, callingPackageName); } if (callingPackageAppInfo != null) { ApplicationInfo applicationInfo = PackageUtils.getApplicationInfoForPackage(currentPackageContext, callingPackageName); if (applicationInfo != null) { appInfo.append("\n\n## ").append(PackageUtils.getAppNameForPackage(currentPackageContext, applicationInfo)).append(" App Info\n"); appInfo.append(callingPackageAppInfo); appInfo.append("\n##\n"); } } } return appInfo.toString(); default: return null; } } /** * Get a markdown {@link String} for the apps info of all/any termux plugin apps installed. * * @param currentPackageContext The context of current package. * @return Returns the markdown {@link String}. */ public static String getTermuxPluginAppsInfoMarkdownString(final Context currentPackageContext) { if (currentPackageContext == null) return "null"; StringBuilder markdownString = new StringBuilder(); List termuxPluginAppPackageNamesList = TermuxConstants.TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST; if (termuxPluginAppPackageNamesList != null) { for (int i = 0; i < termuxPluginAppPackageNamesList.size(); i++) { String termuxPluginAppPackageName = termuxPluginAppPackageNamesList.get(i); Context termuxPluginAppContext = PackageUtils.getContextForPackage(currentPackageContext, termuxPluginAppPackageName); // If the package context for the plugin app is not null, then assume its installed and get its info if (termuxPluginAppContext != null) { if (i != 0) markdownString.append("\n\n"); markdownString.append(getAppInfoMarkdownString(termuxPluginAppContext, false)); } } } if (markdownString.toString().isEmpty()) return null; return markdownString.toString(); } /** * 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"; StringBuilder 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### Reddit\n"); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_REDDIT_SUBREDDIT, TermuxConstants.TERMUX_REDDIT_SUBREDDIT_URL)).append(" "); markdownString.append("\n\n### GitHub Issues for Termux apps\n"); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_API_APP_NAME, TermuxConstants.TERMUX_API_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_BOOT_APP_NAME, TermuxConstants.TERMUX_BOOT_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_FLOAT_APP_NAME, TermuxConstants.TERMUX_FLOAT_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_STYLING_APP_NAME, TermuxConstants.TERMUX_STYLING_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_TASKER_APP_NAME, TermuxConstants.TERMUX_TASKER_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_WIDGET_APP_NAME, TermuxConstants.TERMUX_WIDGET_GITHUB_ISSUES_REPO_URL)).append(" "); markdownString.append("\n\n### GitHub Issues for Termux 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_API_APP_NAME, TermuxConstants.TERMUX_API_GITHUB_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_BOOT_APP_NAME, TermuxConstants.TERMUX_BOOT_GITHUB_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_FLOAT_APP_NAME, TermuxConstants.TERMUX_FLOAT_GITHUB_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_STYLING_APP_NAME, TermuxConstants.TERMUX_STYLING_GITHUB_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_TASKER_APP_NAME, TermuxConstants.TERMUX_TASKER_GITHUB_REPO_URL)).append(" "); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_WIDGET_APP_NAME, TermuxConstants.TERMUX_WIDGET_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### Reddit\n"); markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_REDDIT_SUBREDDIT, TermuxConstants.TERMUX_REDDIT_SUBREDDIT_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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java ================================================ 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 com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants; 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java ================================================ 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", "'"); }}; } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysInfo.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButton.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/file/TermuxFileUtils.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/interact/TextInputDialogUtils.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/models/UserAction.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/notification/TermuxNotificationUtils.java ================================================ 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.settings.preferences.TermuxPreferenceConstants; import com.termux.shared.termux.TermuxConstants; 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/plugins/TermuxPluginUtils.java ================================================ 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.termux.settings.preferences.TermuxPreferenceConstants; 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.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_APP; 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAPIAppSharedPreferences.java ================================================ package com.termux.shared.termux.settings.preferences; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.logger.Logger; 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.TermuxUtils; import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_API_APP; import com.termux.shared.termux.TermuxConstants; public class TermuxAPIAppSharedPreferences extends AppSharedPreferences { private static final String LOG_TAG = "TermuxAPIAppSharedPreferences"; private TermuxAPIAppSharedPreferences(@NonNull Context context) { super(context, SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); } /** * Get {@link TermuxAPIAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_API_PACKAGE_NAME}. * @return Returns the {@link TermuxAPIAppSharedPreferences}. This will {@code null} if an exception is raised. */ @Nullable public static TermuxAPIAppSharedPreferences build(@NonNull final Context context) { Context termuxAPIPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_API_PACKAGE_NAME); if (termuxAPIPackageContext == null) return null; else return new TermuxAPIAppSharedPreferences(termuxAPIPackageContext); } /** * Get {@link TermuxAPIAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_API_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 TermuxAPIAppSharedPreferences}. This will {@code null} if an exception is raised. */ public static TermuxAPIAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { Context termuxAPIPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_API_PACKAGE_NAME, exitAppOnError); if (termuxAPIPackageContext == null) return null; else return new TermuxAPIAppSharedPreferences(termuxAPIPackageContext); } public int getLogLevel(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_API_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); else return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_API_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); } public void setLogLevel(Context context, int logLevel, boolean commitToFile) { logLevel = Logger.setLogLevel(context, logLevel); SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_API_APP.KEY_LOG_LEVEL, logLevel, commitToFile); } public int getLastPendingIntentRequestCode() { return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_API_APP.KEY_LAST_PENDING_INTENT_REQUEST_CODE, TERMUX_API_APP.DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE); } public void setLastPendingIntentRequestCode(int lastPendingIntentRequestCode) { SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_API_APP.KEY_LAST_PENDING_INTENT_REQUEST_CODE, lastPendingIntentRequestCode, true); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAppSharedPreferences.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxBootAppSharedPreferences.java ================================================ package com.termux.shared.termux.settings.preferences; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.logger.Logger; 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.TermuxUtils; import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_BOOT_APP; import com.termux.shared.termux.TermuxConstants; public class TermuxBootAppSharedPreferences extends AppSharedPreferences { private static final String LOG_TAG = "TermuxBootAppSharedPreferences"; private TermuxBootAppSharedPreferences(@NonNull Context context) { super(context, SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); } /** * Get {@link TermuxBootAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_BOOT_PACKAGE_NAME}. * @return Returns the {@link TermuxBootAppSharedPreferences}. This will {@code null} if an exception is raised. */ @Nullable public static TermuxBootAppSharedPreferences build(@NonNull final Context context) { Context termuxBootPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_BOOT_PACKAGE_NAME); if (termuxBootPackageContext == null) return null; else return new TermuxBootAppSharedPreferences(termuxBootPackageContext); } /** * Get {@link TermuxBootAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_BOOT_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 TermuxBootAppSharedPreferences}. This will {@code null} if an exception is raised. */ public static TermuxBootAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { Context termuxBootPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_BOOT_PACKAGE_NAME, exitAppOnError); if (termuxBootPackageContext == null) return null; else return new TermuxBootAppSharedPreferences(termuxBootPackageContext); } public int getLogLevel(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_BOOT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); else return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_BOOT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); } public void setLogLevel(Context context, int logLevel, boolean commitToFile) { logLevel = Logger.setLogLevel(context, logLevel); SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_BOOT_APP.KEY_LOG_LEVEL, logLevel, commitToFile); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxFloatAppSharedPreferences.java ================================================ package com.termux.shared.termux.settings.preferences; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.data.DataUtils; import com.termux.shared.logger.Logger; 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.TermuxUtils; import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_FLOAT_APP; import com.termux.shared.termux.TermuxConstants; public class TermuxFloatAppSharedPreferences extends AppSharedPreferences { private int MIN_FONTSIZE; private int MAX_FONTSIZE; private int DEFAULT_FONTSIZE; private static final String LOG_TAG = "TermuxFloatAppSharedPreferences"; private TermuxFloatAppSharedPreferences(@NonNull Context context) { super(context, SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); setFontVariables(context); } /** * Get {@link TermuxFloatAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_FLOAT_PACKAGE_NAME}. * @return Returns the {@link TermuxFloatAppSharedPreferences}. This will {@code null} if an exception is raised. */ @Nullable public static TermuxFloatAppSharedPreferences build(@NonNull final Context context) { Context termuxFloatPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_FLOAT_PACKAGE_NAME); if (termuxFloatPackageContext == null) return null; else return new TermuxFloatAppSharedPreferences(termuxFloatPackageContext); } /** * Get {@link TermuxFloatAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_FLOAT_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 TermuxFloatAppSharedPreferences}. This will {@code null} if an exception is raised. */ public static TermuxFloatAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { Context termuxFloatPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_FLOAT_PACKAGE_NAME, exitAppOnError); if (termuxFloatPackageContext == null) return null; else return new TermuxFloatAppSharedPreferences(termuxFloatPackageContext); } public int getWindowX() { return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_X, 200); } public void setWindowX(int value) { SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_X, value, false); } public int getWindowY() { return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_Y, 200); } public void setWindowY(int value) { SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_Y, value, false); } public int getWindowWidth() { return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_WIDTH, 500); } public void setWindowWidth(int value) { SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_WIDTH, value, false); } public int getWindowHeight() { return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_HEIGHT, 500); } public void setWindowHeight(int value) { SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_HEIGHT, value, false); } public void setFontVariables(Context context) { int[] sizes = TermuxAppSharedPreferences.getDefaultFontSizes(context); DEFAULT_FONTSIZE = sizes[0]; MIN_FONTSIZE = sizes[1]; MAX_FONTSIZE = sizes[2]; } public int getFontSize() { int fontSize = SharedPreferenceUtils.getIntStoredAsString(mSharedPreferences, TERMUX_FLOAT_APP.KEY_FONTSIZE, DEFAULT_FONTSIZE); return DataUtils.clamp(fontSize, MIN_FONTSIZE, MAX_FONTSIZE); } public void setFontSize(int value) { SharedPreferenceUtils.setIntStoredAsString(mSharedPreferences, TERMUX_FLOAT_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 int getLogLevel(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_FLOAT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); else return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); } public void setLogLevel(Context context, int logLevel, boolean commitToFile) { logLevel = Logger.setLogLevel(context, logLevel); SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_LOG_LEVEL, logLevel, commitToFile); } public boolean isTerminalViewKeyLoggingEnabled(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getBoolean(mMultiProcessSharedPreferences, TERMUX_FLOAT_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, TERMUX_FLOAT_APP.DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED); else return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_FLOAT_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, TERMUX_FLOAT_APP.DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED); } public void setTerminalViewKeyLoggingEnabled(boolean value, boolean commitToFile) { SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_FLOAT_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, value, commitToFile); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxPreferenceConstants.java ================================================ 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; } /** * Termux:API app constants. */ public static final class TERMUX_API_APP { /** * Defines the key for current log level. */ public static final String KEY_LOG_LEVEL = "log_level"; /** * Defines the key for last used PendingIntent request code. */ public static final String KEY_LAST_PENDING_INTENT_REQUEST_CODE = "last_pending_intent_request_code"; public static final int DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE = 0; } /** * Termux:Boot app constants. */ public static final class TERMUX_BOOT_APP { /** * Defines the key for current log level. */ public static final String KEY_LOG_LEVEL = "log_level"; } /** * Termux:Float app constants. */ public static final class TERMUX_FLOAT_APP { /** * The float window x coordinate. */ public static final String KEY_WINDOW_X = "window_x"; /** * The float window y coordinate. */ public static final String KEY_WINDOW_Y = "window_y"; /** * The float window width. */ public static final String KEY_WINDOW_WIDTH = "window_width"; /** * The float window height. */ public static final String KEY_WINDOW_HEIGHT = "window_height"; /** * Defines the key for font size of termux terminal view. */ public static final String KEY_FONTSIZE = "fontsize"; /** * Defines the key for current log level. */ public static final String KEY_LOG_LEVEL = "log_level"; /** * 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; } /** * Termux:Styling app constants. */ public static final class TERMUX_STYLING_APP { /** * Defines the key for current log level. */ public static final String KEY_LOG_LEVEL = "log_level"; } /** * Termux:Tasker app constants. */ public static final class TERMUX_TASKER_APP { /** * Defines the key for current log level. */ public static final String KEY_LOG_LEVEL = "log_level"; /** * Defines the key for last used PendingIntent request code. */ public static final String KEY_LAST_PENDING_INTENT_REQUEST_CODE = "last_pending_intent_request_code"; public static final int DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE = 0; } /** * Termux:Widget app constants. */ public static final class TERMUX_WIDGET_APP { /** * Defines the key for current log level. */ public static final String KEY_LOG_LEVEL = "log_level"; /** * Defines the key for current token for shortcuts. */ public static final String KEY_TOKEN = "token"; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxStylingAppSharedPreferences.java ================================================ package com.termux.shared.termux.settings.preferences; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.logger.Logger; 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.TermuxUtils; import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_STYLING_APP; import com.termux.shared.termux.TermuxConstants; public class TermuxStylingAppSharedPreferences extends AppSharedPreferences { private static final String LOG_TAG = "TermuxStylingAppSharedPreferences"; private TermuxStylingAppSharedPreferences(@NonNull Context context) { super(context, SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); } /** * Get {@link TermuxStylingAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_STYLING_PACKAGE_NAME}. * @return Returns the {@link TermuxStylingAppSharedPreferences}. This will {@code null} if an exception is raised. */ @Nullable public static TermuxStylingAppSharedPreferences build(@NonNull final Context context) { Context termuxStylingPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_STYLING_PACKAGE_NAME); if (termuxStylingPackageContext == null) return null; else return new TermuxStylingAppSharedPreferences(termuxStylingPackageContext); } /** * Get {@link TermuxStylingAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_STYLING_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 TermuxStylingAppSharedPreferences}. This will {@code null} if an exception is raised. */ public static TermuxStylingAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { Context termuxStylingPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, exitAppOnError); if (termuxStylingPackageContext == null) return null; else return new TermuxStylingAppSharedPreferences(termuxStylingPackageContext); } public int getLogLevel(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_STYLING_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); else return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_STYLING_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); } public void setLogLevel(Context context, int logLevel, boolean commitToFile) { logLevel = Logger.setLogLevel(context, logLevel); SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_STYLING_APP.KEY_LOG_LEVEL, logLevel, commitToFile); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxTaskerAppSharedPreferences.java ================================================ package com.termux.shared.termux.settings.preferences; import android.content.Context; 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.termux.TermuxUtils; import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_TASKER_APP; import com.termux.shared.logger.Logger; public class TermuxTaskerAppSharedPreferences extends AppSharedPreferences { private static final String LOG_TAG = "TermuxTaskerAppSharedPreferences"; private TermuxTaskerAppSharedPreferences(@NonNull Context context) { super(context, SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); } /** * Get {@link TermuxTaskerAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_TASKER_PACKAGE_NAME}. * @return Returns the {@link TermuxTaskerAppSharedPreferences}. This will {@code null} if an exception is raised. */ @Nullable public static TermuxTaskerAppSharedPreferences build(@NonNull final Context context) { Context termuxTaskerPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_TASKER_PACKAGE_NAME); if (termuxTaskerPackageContext == null) return null; else return new TermuxTaskerAppSharedPreferences(termuxTaskerPackageContext); } /** * Get {@link TermuxTaskerAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_TASKER_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 TermuxTaskerAppSharedPreferences}. This will {@code null} if an exception is raised. */ public static TermuxTaskerAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { Context termuxTaskerPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_TASKER_PACKAGE_NAME, exitAppOnError); if (termuxTaskerPackageContext == null) return null; else return new TermuxTaskerAppSharedPreferences(termuxTaskerPackageContext); } public int getLogLevel(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_TASKER_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); else return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); } public void setLogLevel(Context context, int logLevel, boolean commitToFile) { logLevel = Logger.setLogLevel(context, logLevel); SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LOG_LEVEL, logLevel, commitToFile); } public int getLastPendingIntentRequestCode() { return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LAST_PENDING_INTENT_REQUEST_CODE, TERMUX_TASKER_APP.DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE); } public void setLastPendingIntentRequestCode(int lastPendingIntentRequestCode) { SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LAST_PENDING_INTENT_REQUEST_CODE, lastPendingIntentRequestCode, false); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxWidgetAppSharedPreferences.java ================================================ package com.termux.shared.termux.settings.preferences; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.logger.Logger; 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.TermuxUtils; import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_WIDGET_APP; import com.termux.shared.termux.TermuxConstants; import java.util.UUID; public class TermuxWidgetAppSharedPreferences extends AppSharedPreferences { private static final String LOG_TAG = "TermuxWidgetAppSharedPreferences"; private TermuxWidgetAppSharedPreferences(@NonNull Context context) { super(context, SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION), SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION)); } /** * Get {@link TermuxWidgetAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_WIDGET_PACKAGE_NAME}. * @return Returns the {@link TermuxWidgetAppSharedPreferences}. This will {@code null} if an exception is raised. */ @Nullable public static TermuxWidgetAppSharedPreferences build(@NonNull final Context context) { Context termuxWidgetPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_WIDGET_PACKAGE_NAME); if (termuxWidgetPackageContext == null) return null; else return new TermuxWidgetAppSharedPreferences(termuxWidgetPackageContext); } /** * Get the {@link TermuxWidgetAppSharedPreferences}. * * @param context The {@link Context} to use to get the {@link Context} of the * {@link TermuxConstants#TERMUX_WIDGET_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 TermuxWidgetAppSharedPreferences}. This will {@code null} if an exception is raised. */ public static TermuxWidgetAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) { Context termuxWidgetPackageContext = TermuxUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_WIDGET_PACKAGE_NAME, exitAppOnError); if (termuxWidgetPackageContext == null) return null; else return new TermuxWidgetAppSharedPreferences(termuxWidgetPackageContext); } public static String getGeneratedToken(@NonNull Context context) { TermuxWidgetAppSharedPreferences preferences = TermuxWidgetAppSharedPreferences.build(context, true); if (preferences == null) return null; return preferences.getGeneratedToken(); } public String getGeneratedToken() { String token = SharedPreferenceUtils.getString(mSharedPreferences, TERMUX_WIDGET_APP.KEY_TOKEN, null, true); if (token == null) { token = UUID.randomUUID().toString(); SharedPreferenceUtils.setString(mSharedPreferences, TERMUX_WIDGET_APP.KEY_TOKEN, token, true); } return token; } public int getLogLevel(boolean readFromFile) { if (readFromFile) return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_WIDGET_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); else return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_WIDGET_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL); } public void setLogLevel(Context context, int logLevel, boolean commitToFile) { logLevel = Logger.setLogLevel(context, logLevel); SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_WIDGET_APP.KEY_LOG_LEVEL, logLevel, commitToFile); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxAppSharedProperties.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxPropertyConstants.java ================================================ 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( )); } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/settings/properties/TermuxSharedProperties.java ================================================ 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()); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellManager.java ================================================ 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 pending plugin ExecutionCommands that have yet to be processed by this service. */ public final List mPendingPluginExecutionCommands = 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/TermuxShellUtils.java ================================================ 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); } } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/am/TermuxAmSocketServer.java ================================================ 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; } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxAPIShellEnvironment.java ================================================ package com.termux.shared.termux.shell.command.environment; import android.content.Context; import android.content.pm.PackageInfo; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.termux.shared.android.PackageUtils; import com.termux.shared.shell.command.environment.ShellEnvironmentUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxUtils; import java.util.HashMap; /** * Environment for {@link TermuxConstants#TERMUX_API_PACKAGE_NAME} app. */ public class TermuxAPIShellEnvironment { /** Environment variable prefix for the Termux:API app. */ public static final String TERMUX_API_APP_ENV_PREFIX = TermuxConstants.TERMUX_ENV_PREFIX_ROOT + "_API_APP__"; /** Environment variable for the Termux:API app version. */ public static final String ENV_TERMUX_API_APP__VERSION_NAME = TERMUX_API_APP_ENV_PREFIX + "VERSION_NAME"; /** Get shell environment for Termux:API app. */ @Nullable public static HashMap getEnvironment(@NonNull Context currentPackageContext) { if (TermuxUtils.isTermuxAPIAppInstalled(currentPackageContext) != null) return null; String packageName = TermuxConstants.TERMUX_API_PACKAGE_NAME; PackageInfo packageInfo = PackageUtils.getPackageInfoForPackage(currentPackageContext, packageName); if (packageInfo == null) return null; HashMap environment = new HashMap<>(); ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_TERMUX_API_APP__VERSION_NAME, PackageUtils.getVersionNameForPackage(packageInfo)); return environment; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxAppShellEnvironment.java ================================================ 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)); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellCommandShellEnvironment.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/command/environment/TermuxShellEnvironment.java ================================================ 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); HashMap termuxApiAppEnvironment = TermuxAPIShellEnvironment.getEnvironment(currentPackageContext); if (termuxApiAppEnvironment != null) environment.putAll(termuxApiAppEnvironment); 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/shell/command/runner/terminal/TermuxSession.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalSessionClientBase.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/terminal/TermuxTerminalViewClientBase.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/terminal/io/BellHandler.java ================================================ 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(); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/termux/theme/TermuxThemeUtils.java ================================================ 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); } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/theme/NightMode.java ================================================ 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/theme/ThemeUtils.java ================================================ package com.termux.shared.theme; import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.TypedArray; import androidx.appcompat.app.AppCompatActivity; 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 (context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; } /** 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) { if (NightMode.TRUE.getName().equals(name)) return true; else if (NightMode.FALSE.getName().equals(name)) return false; else if (NightMode.SYSTEM.getName().equals(name)) { return isNightModeEnabled(context); } else { return false; } } /** 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; } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/view/KeyboardUtils.java ================================================ 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; } } } } ================================================ FILE: termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java ================================================ 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.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 androidx.window.layout.WindowMetrics windowMetrics; if (activitySize) windowMetrics = androidx.window.layout.WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context); else windowMetrics = androidx.window.layout.WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(context); 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); } } } ================================================ FILE: termux-shared/src/main/res/drawable/ic_copy.xml ================================================ ================================================ FILE: termux-shared/src/main/res/drawable/ic_error_notification.xml ================================================ ================================================ FILE: termux-shared/src/main/res/drawable/ic_info.xml ================================================ ================================================ FILE: termux-shared/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: termux-shared/src/main/res/drawable/ic_share.xml ================================================ ================================================ FILE: termux-shared/src/main/res/layout/activity_report.xml ================================================ ================================================ FILE: termux-shared/src/main/res/layout/activity_text_io.xml ================================================ ================================================ FILE: termux-shared/src/main/res/layout/dialog_show_message.xml ================================================ ================================================ FILE: termux-shared/src/main/res/layout/markdown_adapter_node_code_block.xml ================================================ ================================================ FILE: termux-shared/src/main/res/layout/markdown_adapter_node_default.xml ================================================ ================================================ FILE: termux-shared/src/main/res/layout/partial_primary_toolbar.xml ================================================ ================================================ FILE: termux-shared/src/main/res/menu/menu_report.xml ================================================ ================================================ FILE: termux-shared/src/main/res/menu/menu_text_io.xml ================================================ ================================================ FILE: termux-shared/src/main/res/raw/apt_info_script.sh ================================================ #!/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" ================================================ FILE: termux-shared/src/main/res/raw/keep.xml ================================================ ================================================ FILE: termux-shared/src/main/res/values/attrs.xml ================================================ ================================================ FILE: termux-shared/src/main/res/values/colors.xml ================================================ #1F000000 #0F000000 #FF000000 #FFFFFFFF #FF0000 #C4001D #EEEEEE #BDBDBD #9E9E9E #424242 #212121 #DC143C #FC143C #0969DA #58A6FF ================================================ FILE: termux-shared/src/main/res/values/dimens.xml ================================================ 16dp 16dp 4dip 8dip 16dip 32dip ================================================ FILE: termux-shared/src/main/res/values/strings.xml ================================================ ]> %1$s Directory Absolute Path: \"%2$s\" The %1$s (%2$s) app is not installed or is disabled. Failed To Get Package Context Failed to get package context for the \"%1$s\" package. This may be because the app package is not installed or it has different APK signature from the current app. Check %1$s for more details. Failed to get %1$s/%2$s component state Failed to enable %1$s/%2$s component Failed to enable %1$s/%2$s component Please grant requested permission(s) Failed to request permissions with request code %1$d: %2$s Attempted to check for permissions that have not been requested in app manifest: %1$s Attempted to ask for permissions that have not been requested in app manifest: %1$s The \"%1$s\" package is targeting targetSdkVersion %2$d and is running on android sdk %3$d but has not set requestLegacyExternalStorage to true in app manifest Requires `DUMP` and `PACKAGE_USAGE_STATS` permission %1$s requires \"Display over other apps\" permission to start activities and services from background on Android >= 10. Grants it from Android Settings -> Apps -> %1$s -> Advanced -> Draw over other apps. The permission name may be different on different devices, like on Xiaomi, its called \"Display pop-up windows while running in the background\", check https://dontkillmyapp.com for device specific issues. %1$s requires `allow-external-apps` property to be set to `true` in `%2$s` file. Report Text **Report Truncated**\n\nReport is too large to view here. Use `Save To File` option from options menu (3-dots on top right) and view it in an external text editor app.\n\n##\n\n Share With Open URL With The storage permission not granted. The %1$s file saved successfully at \"%2$s\" Sending SIGKILL to process on user request or because android is killing the execution service Executable not set \"%1$s\" for shell command Execution has been cancelled since execution service is being killed Failed to execute \"%1$s\" termux session command Failed to execute \"%1$s\" app shell command Exception received while to executing \"%1$s\" termux session command.\nException: %2$s Exception received while to executing \"%1$s\" app shell command.\nException: %2$s If you want to report this issue, then copy its text from the options menu (3-dots on top right) and post an issue on one of the following links. \n\nIf you are posting a Termux app crash report, then please provide details on what you were doing that caused the crash and how to reproduce it, if possible. \n\nIf you are posting an issue on GitHub, then post it in the repository at which the report belongs at. Issues opened or emails sent with **(partial) screenshots** instead of copied text or a file of this report **will likely be automatically closed/deleted**. You may optionally remove any device specific info that you consider private or don\'t want to share or that is not relevant to the issue. \n\nWe do not provide support for any hacking related tools/scripts. Any questions asked about them over email, on GitHub or other official termux community forums **will likely be automatically closed/deleted** and may even result in **temporary or permanent** ban. Check %1$s/wiki/Hacking for details. The &TERMUX_APP_NAME; is required by the %1$s app to run termux commands. The &TERMUX_APP_NAME; app (package context) is not accessible. The &TERMUX_APP_NAME; app $PREFIX directory is not accessible by the %1$s app. This may be because you have not installed or setup &TERMUX_APP_NAME; app or &TERMUX_APP_NAME; app and %1$s app both have different APK signatures because you have managed to install both apps from different sources. It may also be because &TERMUX_APP_NAME; $PREFIX directory \"&TERMUX_PREFIX_DIR_PATH;\" does not exist or does not have read, write and execute permissions. Yes No Copy Share Cancel Save To File The storage permission granted by user on request The storage permission not granted by user on request Enable Launcher Icon Disable Launcher Icon Enabling %1$s app launcher icon Disabling %1$s app launcher icon Launcher Icon Enabled Launcher Icon will be disabled. Launcher Icon will be enabled. (Default) Log Level Off Normal Debug Verbose *Unknown* Logcat log level set to \"%1$s\" ================================================ FILE: termux-shared/src/main/res/values/styles.xml ================================================ ================================================ FILE: termux-shared/src/main/res/values/themes.xml ================================================