diff --git a/.gitattributes b/.gitattributes index f74a80db47..253738449b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ -* text=auto -*.bat eol=crlf -*.sh eol=lf +* text=auto +*.bat text eol=crlf +*.gradle text eol=lf +*.mk text eol=lf +*.sh text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..c7fc532b31 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://termux.dev/donate diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml new file mode 100644 index 0000000000..01104998a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -0,0 +1,44 @@ +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-packages](https://github.com/termux/termux-packages) instead. + + 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 diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml new file mode 100644 index 0000000000..d09d97f81b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -0,0 +1,19 @@ +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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 440cc3fe4f..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve Termux application - ---- - - - -**Problem description** - - -**Steps to reproduce** - - -**Expected behavior** - - -**Additional information** - -* Termux application version: -* Android OS version: -* Device model: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..29c4cebe4c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index c1e99f1048..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: Feature request -about: Suggest a new feature for Termux application - ---- - - - -**Feature description** - - -**Reference implementation** - -Does another app/terminal emulator have this feature? -Provide links to more background information. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..af8a2fdafb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: +- package-ecosystem: github-actions + directory: / + schedule: + interval: daily + commit-message: + # Prefix all commit messages with "Changed: " + prefix: "Changed" diff --git a/.github/workflows/attach_debug_apks_to_release.yml b/.github/workflows/attach_debug_apks_to_release.yml new file mode 100644 index 0000000000..a17b326443 --- /dev/null +++ b/.github/workflows/attach_debug_apks_to_release.yml @@ -0,0 +1,84 @@ +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@v4 + 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 diff --git a/.github/workflows/debug_build.yml b/.github/workflows/debug_build.yml index 47ba0edd86..a1df15ae15 100644 --- a/.github/workflows/debug_build.yml +++ b/.github/workflows/debug_build.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - 'github-releases/**' pull_request: branches: - master @@ -11,14 +12,116 @@ on: 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@v2 - - name: Build - run: | - ./gradlew assembleDebug - - name: Store generated APK file - uses: actions/upload-artifact@v1 - with: - name: termux-app - path: ./app/build/outputs/apk/debug/app-debug.apk + - name: Clone repository + uses: actions/checkout@v4 + + - 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@v4 + 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@v4 + 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@v4 + 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@v4 + 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@v4 + 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@v4 + with: + name: ${{ env.APK_BASENAME_PREFIX }}_sha256sums + path: | + ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}_sha256sums + ${{ env.APK_DIR_PATH }}/output-metadata.json diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 0000000000..9d3978b13b --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,23 @@ +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@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@v4 diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 13dca3db33..b9306a157b 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -4,14 +4,16 @@ on: push: branches: - master + - android-10 pull_request: branches: - master + - android-10 jobs: validation: name: "Validation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1 + - uses: actions/checkout@v4 + - uses: gradle/wrapper-validation-action@v3 diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 87ccc68611..f8d33fd4fc 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -4,16 +4,18 @@ 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@v2 + uses: actions/checkout@v4 - name: Execute tests run: | ./gradlew test diff --git a/.github/workflows/trigger_library_builds_on_jitpack.yml b/.github/workflows/trigger_library_builds_on_jitpack.yml new file mode 100644 index 0000000000..fd00f13bfe --- /dev/null +++ b/.github/workflows/trigger_library_builds_on_jitpack.yml @@ -0,0 +1,21 @@ +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" diff --git a/.gitignore b/.gitignore index 5f7ee3c3dc..a52cbb3394 100644 --- a/.gitignore +++ b/.gitignore @@ -4,15 +4,13 @@ # Built application files build/ +release/ *.apk *.so .externalNativeBuild .cxx *.zip -# Crashlytics configuations -com_crashlytics_export_strings.xml - # Local configuration file (sdk path, etc) local.properties @@ -26,6 +24,10 @@ local.properties .idea/ *.iml +# Vim +*.swo +*.swp + # OS-specific files .DS_Store .DS_Store? diff --git a/LICENSE.md b/LICENSE.md index 7e951d4453..4b66170283 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,3 +1,6 @@ -Released under [the GPLv3 license](https://www.gnu.org/licenses/gpl.html). +The `termux/termux-app` repository is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license. -Contains code from `Terminal Emulator for Android` by which is released under [the Apache License 2.0](https://www.apache.org/licenses/). +### 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. diff --git a/README.md b/README.md index f1db39db24..8233bf4ef6 100644 --- a/README.md +++ b/README.md @@ -3,63 +3,293 @@ [![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.com) is an Android terminal application and Linux environment. -- [Termux Reddit community](https://reddit.com/r/termux) -- [Termux Wiki](https://wiki.termux.com/wiki/) -- [Termux Twitter](http://twitter.com/termux/) +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) +## + -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) ## Installation -Termux:Widget application can be obtained from: +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.com/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. -- [Google Play](https://play.google.com/store/apps/details?id=com.termux) -- [F-Droid](https://f-droid.org/en/packages/com.termux/) -- [Kali Nethunter Store](https://store.nethunter.com/en/packages/com.termux/) +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. -Additionally we provide per-commit debug builds for those who want to try -out the latest features or test their pull request. This build can be obtained -from one of the workflow runs listed on [Github Actions](https://github.com/termux/termux-app/actions) -page. +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. -Signature keys of all offered builds are different. Before you switch the -installation source, you will have to uninstall the Termux application and -all currently installed plugins. +### GitHub -## Terminal resources +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.** -- [XTerm control sequences](http://invisible-island.net/xterm/ctlseqs/ctlseqs.html) -- [vt100.net](http://vt100.net/) -- [Terminal codes (ANSI and terminfo equivalents)](http://wiki.bash-hackers.org/scripting/terminalcodes) +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. -## Terminal emulators +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. -- 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). +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) -- iTerm 2: OS X terminal application. [Source](https://github.com/gnachman/iTerm2), - [Issues](https://gitlab.com/gnachman/iterm2/issues) and [Documentation](http://www.iterm2.com/documentation.html) - (which includes [iTerm2 proprietary escape codes](http://www.iterm2.com/documentation-escape-codes.html)). +### 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) -- 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). +### Terminal -- xterm: The grandfather of terminal emulators. - [Source](http://invisible-island.net/datafiles/release/xterm.tar.gz). +
+ + +### 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). +- 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) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..479f15cb81 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1 @@ +Check https://termux.dev/security for info on Termux security policies and how to report vulnerabilities. diff --git a/app/build.gradle b/app/build.gradle index be59ac90ae..3df93fef10 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,24 +1,62 @@ plugins { - id "com.android.application" + 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 { - compileSdkVersion 28 - ndkVersion '21.3.6528147' + 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.1.0" + implementation "androidx.annotation:annotation:1.3.0" + implementation "androidx.core:core:1.6.0" + implementation "androidx.drawerlayout:drawerlayout:1.1.1" + implementation "androidx.preference:preference:1.1.1" implementation "androidx.viewpager:viewpager:1.0.0" - implementation "androidx.drawerlayout:drawerlayout:1.0.0" + implementation "com.google.android.material:material:1.4.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 project(":terminal-view") + implementation project(":termux-shared") } defaultConfig { applicationId "com.termux" - minSdkVersion 24 - targetSdkVersion 28 - versionCode 95 - versionName "0.95" + 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 { @@ -26,15 +64,20 @@ android { } } - ndk { - abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' + 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('dev_keystore.jks') + storeFile file('testkey_untrusted.jks') keyAlias 'alias' storePassword 'xrj45yWGLbsO7W0v' keyPassword 'xrj45yWGLbsO7W0v' @@ -44,7 +87,7 @@ android { buildTypes { release { minifyEnabled true - shrinkResources true + shrinkResources false // Reproducible builds proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } @@ -54,6 +97,9 @@ android { } compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -64,25 +110,56 @@ android { } } + lintOptions { + 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") + } + } + } + } dependencies { - testImplementation 'junit:junit:4.13' - testImplementation 'org.robolectric:robolectric:4.3.1' + 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 - } + 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, int version) { +def downloadBootstrap(String arch, String expectedChecksum, String version) { def digest = java.security.MessageDigest.getInstance("SHA-256") def localUrl = "src/main/cpp/bootstrap-" + arch + ".zip" @@ -96,15 +173,16 @@ def downloadBootstrap(String arch, String expectedChecksum, int version) { 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) + logger.quiet("Deleting old local file with wrong hash: " + localUrl + ": expected: " + expectedChecksum + ", actual: " + checksum) file.delete() } } - def remoteUrl = "https://bintray.com/termux/bootstrap/download_file?file_path=bootstrap-" + arch + "-v" + version + ".zip" + def remoteUrl = "https://github.com/termux/termux-packages/releases/download/bootstrap-" + version + "/bootstrap-" + arch + ".zip" logger.quiet("Downloading " + remoteUrl + " ...") file.parentFile.mkdirs() @@ -117,6 +195,7 @@ def downloadBootstrap(String arch, String expectedChecksum, int version) { 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) @@ -131,18 +210,29 @@ clean { } } -task downloadBootstraps(){ +task downloadBootstraps() { doLast { - def version = 25 - downloadBootstrap("aarch64", "633baa1f7edfd81f6064338a68d1149aa203d4b24cbc4f7c64283aaca109609e", version) - downloadBootstrap("arm", "a581a22e0d79a0e8cef9395b1bd951ba066ac2d688522e17cca0b3e1c0649daa", version) - downloadBootstrap("i686", "8288e13f0a6ddeb2ff9406d8f968a8930a58e9318d09fadb2b7c8970a034cfdc", version) - downloadBootstrap("x86_64", "c99b80a18d6bbb64c24c5a64d6ee6b8d4306729ebd172662b807bcb4a46dd39a", version) + def packageVariant = project.ext.packageVariant + if (packageVariant == "apt-android-7") { + def version = "2022.04.28-r5" + "+" + packageVariant + downloadBootstrap("aarch64", "4a51a7eb209fe82efc24d52e3cccc13165f27377290687cb82038cbd8e948430", version) + downloadBootstrap("arm", "6459a786acbae50d4c8a36fa1c3de6a4dd2d482572f6d54f73274709bd627325", version) + downloadBootstrap("i686", "919d212b2f19e08600938db4079e794e947365022dbfd50ac342c50fcedcd7be", version) + downloadBootstrap("x86_64", "61b02fdc03ea4f5d9da8d8cf018013fdc6659e6da6cbf44e9b24d1c623580b89", 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) - } + android.applicationVariants.all { variant -> + variant.javaCompileProvider.get().dependsOn(downloadBootstraps) + } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4306bcc419..a01c038960 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,5 +7,11 @@ # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html --renamesourcefileattribute SourceFile --keepattributes SourceFile,LineNumberTable +-dontobfuscate +#-renamesourcefileattribute SourceFile +#-keepattributes SourceFile,LineNumberTable + +# Temp fix for androidx.window:window:1.0.0-alpha09 imported by termux-shared +# https://issuetracker.google.com/issues/189001730 +# https://android-review.googlesource.com/c/platform/frameworks/support/+/1757630 +-keep class androidx.window.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b293037649..7c566c8f0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,77 +1,135 @@ + - - - - - + + + + + + + - + + + + + + + + + - - - + android:requestLegacyExternalStorage="true" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="false" + android:theme="@style/Theme.TermuxApp.DayNight.DarkActionBar" + tools:targetApi="m"> + android:theme="@style/Theme.TermuxActivity.DayNight.NoActionBar" + tools:targetApi="n"> + + - + + + + + + + + + + + + + + tools:targetApi="n" /> + + + + + android:taskAffinity="${TERMUX_PACKAGE_NAME}.filereceiver" + tools:targetApi="n"> + + + + - + + + @@ -80,64 +138,100 @@ - - - - - - - - - - - - + + android:name=".app.api.file.FileViewReceiverActivity" + android:exported="true" + android:targetActivity=".app.api.file.FileReceiverActivity"> - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + android:permission="${TERMUX_PACKAGE_NAME}.permission.RUN_COMMAND"> - + - - - - + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/termux/app/BackgroundJob.java b/app/src/main/java/com/termux/app/BackgroundJob.java deleted file mode 100644 index 2b1888aa04..0000000000 --- a/app/src/main/java/com/termux/app/BackgroundJob.java +++ /dev/null @@ -1,251 +0,0 @@ -package com.termux.app; - -import android.app.Activity; -import android.app.PendingIntent; -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * A background job launched by Termux. - */ -public final class BackgroundJob { - - private static final String LOG_TAG = "termux-task"; - - final Process mProcess; - - public BackgroundJob(String cwd, String fileToExecute, final String[] args, final TermuxService service){ - this(cwd, fileToExecute, args, service, null); - } - - public BackgroundJob(String cwd, String fileToExecute, final String[] args, final TermuxService service, PendingIntent pendingIntent) { - String[] env = buildEnvironment(false, cwd); - if (cwd == null) cwd = TermuxService.HOME_PATH; - - final String[] progArray = setupProcessArgs(fileToExecute, args); - final String processDescription = Arrays.toString(progArray); - - Process process; - try { - process = Runtime.getRuntime().exec(progArray, env, new File(cwd)); - } catch (IOException e) { - mProcess = null; - // TODO: Visible error message? - Log.e(LOG_TAG, "Failed running background job: " + processDescription, e); - return; - } - - mProcess = process; - final int pid = getPid(mProcess); - final Bundle result = new Bundle(); - final StringBuilder outResult = new StringBuilder(); - final StringBuilder errResult = new StringBuilder(); - - Thread errThread = new Thread() { - @Override - public void run() { - InputStream stderr = mProcess.getErrorStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(stderr, StandardCharsets.UTF_8)); - String line; - try { - // FIXME: Long lines. - while ((line = reader.readLine()) != null) { - errResult.append(line).append('\n'); - Log.i(LOG_TAG, "[" + pid + "] stderr: " + line); - } - } catch (IOException e) { - // Ignore. - } - } - }; - errThread.start(); - - new Thread() { - @Override - public void run() { - Log.i(LOG_TAG, "[" + pid + "] starting: " + processDescription); - InputStream stdout = mProcess.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(stdout, StandardCharsets.UTF_8)); - - String line; - try { - // FIXME: Long lines. - while ((line = reader.readLine()) != null) { - Log.i(LOG_TAG, "[" + pid + "] stdout: " + line); - outResult.append(line).append('\n'); - } - } catch (IOException e) { - Log.e(LOG_TAG, "Error reading output", e); - } - - try { - int exitCode = mProcess.waitFor(); - service.onBackgroundJobExited(BackgroundJob.this); - if (exitCode == 0) { - Log.i(LOG_TAG, "[" + pid + "] exited normally"); - } else { - Log.w(LOG_TAG, "[" + pid + "] exited with code: " + exitCode); - } - - result.putString("stdout", outResult.toString()); - result.putInt("exitCode", exitCode); - - errThread.join(); - result.putString("stderr", errResult.toString()); - - Intent data = new Intent(); - data.putExtra("result", result); - - if(pendingIntent != null) { - try { - pendingIntent.send(service.getApplicationContext(), Activity.RESULT_OK, data); - } catch (PendingIntent.CanceledException e) { - // The caller doesn't want the result? That's fine, just ignore - } - } - } catch (InterruptedException e) { - // Ignore - } - } - }.start(); - } - - private static void addToEnvIfPresent(List environment, String name) { - String value = System.getenv(name); - if (value != null) { - environment.add(name + "=" + value); - } - } - - static String[] buildEnvironment(boolean failSafe, String cwd) { - new File(TermuxService.HOME_PATH).mkdirs(); - - if (cwd == null) cwd = TermuxService.HOME_PATH; - - List environment = new ArrayList<>(); - - environment.add("TERM=xterm-256color"); - environment.add("HOME=" + TermuxService.HOME_PATH); - environment.add("PREFIX=" + TermuxService.PREFIX_PATH); - environment.add("BOOTCLASSPATH=" + System.getenv("BOOTCLASSPATH")); - environment.add("ANDROID_ROOT=" + System.getenv("ANDROID_ROOT")); - environment.add("ANDROID_DATA=" + System.getenv("ANDROID_DATA")); - // EXTERNAL_STORAGE is needed for /system/bin/am to work on at least - // Samsung S7 - see https://plus.google.com/110070148244138185604/posts/gp8Lk3aCGp3. - environment.add("EXTERNAL_STORAGE=" + System.getenv("EXTERNAL_STORAGE")); - // ANDROID_RUNTIME_ROOT and ANDROID_TZDATA_ROOT are required for `am` to run on Android Q - addToEnvIfPresent(environment, "ANDROID_RUNTIME_ROOT"); - addToEnvIfPresent(environment, "ANDROID_TZDATA_ROOT"); - if (failSafe) { - // Keep the default path so that system binaries can be used in the failsafe session. - environment.add("PATH= " + System.getenv("PATH")); - } else { - if (shouldAddLdLibraryPath()) { - environment.add("LD_LIBRARY_PATH=" + TermuxService.PREFIX_PATH + "/lib"); - } - environment.add("LANG=en_US.UTF-8"); - environment.add("PATH=" + TermuxService.PREFIX_PATH + "/bin:" + TermuxService.PREFIX_PATH + "/bin/applets"); - environment.add("PWD=" + cwd); - environment.add("TMPDIR=" + TermuxService.PREFIX_PATH + "/tmp"); - } - - return environment.toArray(new String[0]); - } - - private static boolean shouldAddLdLibraryPath() { - try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(TermuxService.PREFIX_PATH + "/etc/apt/sources.list")))) { - String line; - while ((line = in.readLine()) != null) { - if (!line.startsWith("#") && line.contains("//termux.net stable")) { - return true; - } - } - } catch (IOException e) { - Log.e(LOG_TAG, "Error trying to read sources.list", e); - } - return false; - } - - 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; - } - } - - static String[] setupProcessArgs(String fileToExecute, String[] args) { - // 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(fileToExecute); - 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 executable = builder.toString(); - if (executable.startsWith("/usr") || executable.startsWith("/bin")) { - String[] parts = executable.split("/"); - String binary = parts[parts.length - 1]; - interpreter = TermuxService.PREFIX_PATH + "/bin/" + binary; - } - break; - } - } else { - builder.append(c); - } - } - } else { - // No shebang and no ELF, use standard shell. - interpreter = TermuxService.PREFIX_PATH + "/bin/sh"; - } - } - } - } catch (IOException e) { - // Ignore. - } - - List result = new ArrayList<>(); - if (interpreter != null) result.add(interpreter); - result.add(fileToExecute); - if (args != null) Collections.addAll(result, args); - return result.toArray(new String[0]); - } - -} diff --git a/app/src/main/java/com/termux/app/ExtraKeysInfos.java b/app/src/main/java/com/termux/app/ExtraKeysInfos.java deleted file mode 100644 index 86d97449bc..0000000000 --- a/app/src/main/java/com/termux/app/ExtraKeysInfos.java +++ /dev/null @@ -1,338 +0,0 @@ -package com.termux.app; - -import androidx.annotation.Nullable; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.stream.Collectors; - -public class ExtraKeysInfos { - - /** - * Matrix of buttons displayed - */ - private ExtraKeyButton[][] buttons; - - /** - * This corresponds to one of the CharMapDisplay below - */ - private String style = "default"; - - public ExtraKeysInfos(String propertiesInfo, String style) throws JSONException { - this.style = style; - - // 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 - this.buttons = new ExtraKeyButton[matrix.length][]; - for (int i = 0; i < matrix.length; i++) { - this.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("popup")) { - // no popup - button = new ExtraKeyButton(getSelectedCharMap(), jobject); - } else { - // a popup - JSONObject popupJobject = normalizeKeyConfig(jobject.get("popup")); - ExtraKeyButton popup = new ExtraKeyButton(getSelectedCharMap(), popupJobject); - button = new ExtraKeyButton(getSelectedCharMap(), jobject, popup); - } - - this.buttons[i][j] = button; - } - } - } - - /** - * "hello" -> {"key": "hello"} - */ - private static JSONObject normalizeKeyConfig(Object key) throws JSONException { - JSONObject jobject; - if(key instanceof String) { - jobject = new JSONObject(); - jobject.put("key", 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 buttons; - } - - /** - * 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; - } - } - - static class CharDisplayMap extends CleverMap {} - - /** - * Keys are displayed in a natural looking way, like "→" for "RIGHT" - */ - static final CharDisplayMap classicArrowsDisplay = new CharDisplayMap() {{ - // 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 - }}; - - static final CharDisplayMap wellKnownCharactersDisplay = new CharDisplayMap() {{ - // 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 - }}; - - static final CharDisplayMap lessKnownCharactersDisplay = new CharDisplayMap() {{ - // 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 - }}; - - static final CharDisplayMap arrowTriangleVariationDisplay = new CharDisplayMap() {{ - // 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 - }}; - - static final CharDisplayMap notKnownIsoCharacters = new CharDisplayMap() {{ - // 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 - }}; - - static final CharDisplayMap nicerLookingDisplay = new CharDisplayMap() {{ - // nicer looking for most cases - put("-", "―"); // U+2015 ― HORIZONTAL BAR - }}; - - /** - * Multiple maps are available to quickly change - * the style of the keys. - */ - - /** - * Some classic symbols everybody knows - */ - private static final CharDisplayMap defaultCharDisplay = new CharDisplayMap() {{ - putAll(classicArrowsDisplay); - putAll(wellKnownCharactersDisplay); - putAll(nicerLookingDisplay); - // all other characters are displayed as themselves - }}; - - /** - * Classic symbols and less known symbols - */ - private static final CharDisplayMap lotsOfArrowsCharDisplay = new CharDisplayMap() {{ - putAll(classicArrowsDisplay); - putAll(wellKnownCharactersDisplay); - putAll(lessKnownCharactersDisplay); // NEW - putAll(nicerLookingDisplay); - }}; - - /** - * Only arrows - */ - private static final CharDisplayMap arrowsOnlyCharDisplay = new CharDisplayMap() {{ - putAll(classicArrowsDisplay); - // putAll(wellKnownCharactersDisplay); // REMOVED - // putAll(lessKnownCharactersDisplay); // REMOVED - putAll(nicerLookingDisplay); - }}; - - /** - * Full Iso - */ - private static final CharDisplayMap fullIsoCharDisplay = new CharDisplayMap() {{ - putAll(classicArrowsDisplay); - putAll(wellKnownCharactersDisplay); - putAll(lessKnownCharactersDisplay); // NEW - putAll(nicerLookingDisplay); - putAll(notKnownIsoCharacters); // NEW - }}; - - /** - * Some people might call our keys differently - */ - static private final CharDisplayMap controlCharsAliases = new CharDisplayMap() {{ - put("ESCAPE", "ESC"); - put("CONTROL", "CTRL"); - 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", "'"); - }}; - - CharDisplayMap getSelectedCharMap() { - switch (style) { - case "arrows-only": - return arrowsOnlyCharDisplay; - case "arrows-all": - return lotsOfArrowsCharDisplay; - case "all": - return fullIsoCharDisplay; - case "none": - return new CharDisplayMap(); - default: - return defaultCharDisplay; - } - } - - /** - * Applies the 'controlCharsAliases' mapping to all the strings in *buttons* - * Modifies the array, doesn't return a new one. - */ - public static String replaceAlias(String key) { - return controlCharsAliases.get(key, key); - } -} - -class ExtraKeyButton { - - /** - * The key that will be sent to the terminal, either a control character - * defined in ExtraKeysView.keyCodesForString (LEFT, RIGHT, PGUP...) or - * some text. - */ - private String key; - - /** - * If the key is a macro, i.e. a sequence of keys separated by space. - */ - private boolean macro; - - /** - * The text that will be shown on the button. - */ - private String display; - - /** - * The information of the popup (triggered by swipe up). - */ - @Nullable - private ExtraKeyButton popup = null; - - public ExtraKeyButton(ExtraKeysInfos.CharDisplayMap charDisplayMap, JSONObject config) throws JSONException { - this(charDisplayMap, config, null); - } - - public ExtraKeyButton(ExtraKeysInfos.CharDisplayMap charDisplayMap, JSONObject config, ExtraKeyButton popup) throws JSONException { - String keyFromConfig = config.optString("key", null); - String macroFromConfig = config.optString("macro", null); - String[] keys; - if (keyFromConfig != null && macroFromConfig != null) { - throw new JSONException("Both key and macro can't be set for the same key"); - } 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] = ExtraKeysInfos.replaceAlias(keys[i]); - } - - this.key = String.join(" ", keys); - - String displayFromConfig = config.optString("display", null); - if (displayFromConfig != null) { - this.display = displayFromConfig; - } else { - this.display = Arrays.stream(keys) - .map(key -> charDisplayMap.get(key, key)) - .collect(Collectors.joining(" ")); - } - - this.popup = popup; - } - - public String getKey() { - return key; - } - - public boolean isMacro() { - return macro; - } - - public String getDisplay() { - return display; - } - - @Nullable - public ExtraKeyButton getPopup() { - return popup; - } -} diff --git a/app/src/main/java/com/termux/app/ExtraKeysView.java b/app/src/main/java/com/termux/app/ExtraKeysView.java deleted file mode 100644 index b568703333..0000000000 --- a/app/src/main/java/com/termux/app/ExtraKeysView.java +++ /dev/null @@ -1,354 +0,0 @@ -package com.termux.app; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.os.Build; -import android.provider.Settings; -import android.util.AttributeSet; - -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.Arrays; - -import android.view.Gravity; -import android.view.HapticFeedbackConstants; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.Button; -import android.widget.GridLayout; -import android.widget.PopupWindow; -import android.widget.ToggleButton; - -import com.termux.R; -import com.termux.view.TerminalView; - -import androidx.drawerlayout.widget.DrawerLayout; - -/** - * A view showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft - * keyboard. - */ -public final class ExtraKeysView extends GridLayout { - - private static final int TEXT_COLOR = 0xFFFFFFFF; - private static final int BUTTON_COLOR = 0x00000000; - private static final int INTERESTING_COLOR = 0xFF80DEEA; - private static final int BUTTON_PRESSED_COLOR = 0xFF7F7F7F; - - public ExtraKeysView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - static final Map keyCodesForString = 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); - }}; - - private void sendKey(View view, String keyName, boolean forceCtrlDown, boolean forceLeftAltDown) { - TerminalView terminalView = view.findViewById(R.id.terminal_view); - if ("KEYBOARD".equals(keyName)) { - InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(0, 0); - } else if ("DRAWER".equals(keyName)) { - DrawerLayout drawer = view.findViewById(R.id.drawer_layout); - drawer.openDrawer(Gravity.LEFT); - } else if (keyCodesForString.containsKey(keyName)) { - int keyCode = keyCodesForString.get(keyName); - int metaState = 0; - if (forceCtrlDown) { - metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON; - } - if (forceLeftAltDown) { - metaState |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; - } - KeyEvent keyEvent = new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState); - terminalView.onKeyDown(keyCode, keyEvent); - } else { - // not a control char - keyName.codePoints().forEach(codePoint -> { - terminalView.inputCodePoint(codePoint, forceCtrlDown, forceLeftAltDown); - }); - } - } - - private void sendKey(View view, ExtraKeyButton buttonInfo) { - if (buttonInfo.isMacro()) { - String[] keys = buttonInfo.getKey().split(" "); - boolean ctrlDown = false; - boolean altDown = false; - for (String key : keys) { - if ("CTRL".equals(key)) { - ctrlDown = true; - } else if ("ALT".equals(key)) { - altDown = true; - } else { - sendKey(view, key, ctrlDown, altDown); - ctrlDown = false; - altDown = false; - } - } - } else { - sendKey(view, buttonInfo.getKey(), false, false); - } - } - - public enum SpecialButton { - CTRL, ALT, FN - } - - private static class SpecialButtonState { - boolean isOn = false; - ToggleButton button = null; - } - - private Map specialButtons = new HashMap() {{ - put(SpecialButton.CTRL, new SpecialButtonState()); - put(SpecialButton.ALT, new SpecialButtonState()); - put(SpecialButton.FN, new SpecialButtonState()); - }}; - - private ScheduledExecutorService scheduledExecutor; - private PopupWindow popupWindow; - private int longPressCount; - - public boolean readSpecialButton(SpecialButton name) { - SpecialButtonState state = specialButtons.get(name); - if (state == null) - throw new RuntimeException("Must be a valid special button (see source)"); - - if (! state.isOn) - return false; - - if (state.button == null) { - return false; - } - - if (state.button.isPressed()) - return true; - - if (! state.button.isChecked()) - return false; - - state.button.setChecked(false); - state.button.setTextColor(TEXT_COLOR); - return true; - } - - void popup(View view, String text) { - int width = view.getMeasuredWidth(); - int height = view.getMeasuredHeight(); - Button button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle); - button.setText(text); - button.setTextColor(TEXT_COLOR); - 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(BUTTON_PRESSED_COLOR); - popupWindow = new PopupWindow(this); - popupWindow.setWidth(LayoutParams.WRAP_CONTENT); - popupWindow.setHeight(LayoutParams.WRAP_CONTENT); - popupWindow.setContentView(button); - popupWindow.setOutsideTouchable(true); - popupWindow.setFocusable(false); - popupWindow.showAsDropDown(view, 0, -2 * height); - } - - /** - * General util function to compute the longest column length in a matrix. - */ - static int maximumLength(Object[][] matrix) { - int m = 0; - for (Object[] row : matrix) - m = Math.max(m, row.length); - return m; - } - - /** - * Reload the view given parameters in termux.properties - * - * @param infos matrix as defined in termux.properties extrakeys - * Can Contain The Strings CTRL ALT TAB FN ENTER LEFT RIGHT UP DOWN or normal strings - * Some aliases are possible like RETURN for ENTER, LT for LEFT and more (@see controlCharsAliases for the whole list). - * Any string of length > 1 in total Uppercase will print a warning - * - * Examples: - * "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 "-_-" - */ - @SuppressLint("ClickableViewAccessibility") - void reload(ExtraKeysInfos infos) { - if(infos == null) - return; - - for(SpecialButtonState state : specialButtons.values()) - state.button = null; - - removeAllViews(); - - ExtraKeyButton[][] buttons = infos.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]; - - Button button; - if(Arrays.asList("CTRL", "ALT", "FN").contains(buttonInfo.getKey())) { - SpecialButtonState state = specialButtons.get(SpecialButton.valueOf(buttonInfo.getKey())); // for valueOf: https://stackoverflow.com/a/604426/1980630 - state.isOn = true; - button = state.button = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle); - button.setClickable(true); - } else { - button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle); - } - - button.setText(buttonInfo.getDisplay()); - button.setTextColor(TEXT_COLOR); - button.setPadding(0, 0, 0, 0); - - final Button finalButton = button; - button.setOnClickListener(v -> { - if (Settings.System.getInt(getContext().getContentResolver(), - Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) { - - if (Build.VERSION.SDK_INT >= 28) { - finalButton.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) { - finalButton.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); - } - } - } - - View root = getRootView(); - if (Arrays.asList("CTRL", "ALT", "FN").contains(buttonInfo.getKey())) { - ToggleButton self = (ToggleButton) finalButton; - self.setChecked(self.isChecked()); - self.setTextColor(self.isChecked() ? INTERESTING_COLOR : TEXT_COLOR); - } else { - sendKey(root, buttonInfo); - } - }); - - button.setOnTouchListener((v, event) -> { - final View root = getRootView(); - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - longPressCount = 0; - v.setBackgroundColor(BUTTON_PRESSED_COLOR); - if (Arrays.asList("UP", "DOWN", "LEFT", "RIGHT", "BKSP", "DEL").contains(buttonInfo.getKey())) { - // autorepeat - scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); - scheduledExecutor.scheduleWithFixedDelay(() -> { - longPressCount++; - sendKey(root, buttonInfo); - }, 400, 80, TimeUnit.MILLISECONDS); - } - return true; - - case MotionEvent.ACTION_MOVE: - if (buttonInfo.getPopup() != null) { - if (popupWindow == null && event.getY() < 0) { - if (scheduledExecutor != null) { - scheduledExecutor.shutdownNow(); - scheduledExecutor = null; - } - v.setBackgroundColor(BUTTON_COLOR); - String extraButtonDisplayedText = buttonInfo.getPopup().getDisplay(); - popup(v, extraButtonDisplayedText); - } - if (popupWindow != null && event.getY() > 0) { - v.setBackgroundColor(BUTTON_PRESSED_COLOR); - popupWindow.dismiss(); - popupWindow = null; - } - } - return true; - - case MotionEvent.ACTION_CANCEL: - v.setBackgroundColor(BUTTON_COLOR); - if (scheduledExecutor != null) { - scheduledExecutor.shutdownNow(); - scheduledExecutor = null; - } - return true; - case MotionEvent.ACTION_UP: - v.setBackgroundColor(BUTTON_COLOR); - if (scheduledExecutor != null) { - scheduledExecutor.shutdownNow(); - scheduledExecutor = null; - } - if (longPressCount == 0 || popupWindow != null) { - if (popupWindow != null) { - popupWindow.setContentView(null); - popupWindow.dismiss(); - popupWindow = null; - if (buttonInfo.getPopup() != null) { - sendKey(root, buttonInfo.getPopup()); - } - } else { - v.performClick(); - } - } - return true; - - default: - return true; - } - }); - - LayoutParams param = new GridLayout.LayoutParams(); - param.width = 0; - 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); - } - } - } - -} diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index 42174657f1..635f281058 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -1,39 +1,41 @@ 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 android.util.Log; -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.Properties; +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; /** - * When allow-external-apps property is set to "true", Termux is able to process execute intents - * sent by third-party applications. + * 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. * - * Third-party program must declare com.termux.permission.RUN_COMMAND permission and it should be - * granted by user. - * - * Sample code to run command "top": - * Intent intent = new Intent(); - * intent.setClassName("com.termux", "com.termux.app.RunCommandService"); - * intent.setAction("com.termux.RUN_COMMAND"); - * intent.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/top"); - * startService(intent); + * Check https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent for more info. */ public class RunCommandService extends Service { - public static final String RUN_COMMAND_ACTION = "com.termux.RUN_COMMAND"; - public static final String RUN_COMMAND_PATH = "com.termux.RUN_COMMAND_PATH"; - public static final String RUN_COMMAND_ARGUMENTS = "com.termux.RUN_COMMAND_ARGUMENTS"; - public static final String RUN_COMMAND_WORKDIR = "com.termux.RUN_COMMAND_WORKDIR"; + private static final String LOG_TAG = "RunCommandService"; class LocalBinder extends Binder { public final RunCommandService service = RunCommandService.this; @@ -46,41 +48,240 @@ 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) { - if (allowExternalApps() && RUN_COMMAND_ACTION.equals(intent.getAction())) { - Uri programUri = new Uri.Builder().scheme("com.termux.file").path(intent.getStringExtra(RUN_COMMAND_PATH)).build(); - - Intent execIntent = new Intent(TermuxService.ACTION_EXECUTE, programUri); - execIntent.setClass(this, TermuxService.class); - execIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, intent.getStringExtra(RUN_COMMAND_ARGUMENTS)); - execIntent.putExtra(TermuxService.EXTRA_CURRENT_WORKING_DIRECTORY, intent.getStringExtra(RUN_COMMAND_WORKDIR)); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - this.startForegroundService(execIntent); - } else { - this.startService(execIntent); + 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 boolean allowExternalApps() { - File propsFile = new File(TermuxService.HOME_PATH + "/.termux/termux.properties"); - if (!propsFile.exists()) - propsFile = new File(TermuxService.HOME_PATH + "/.config/termux/termux.properties"); - - Properties props = new Properties(); - try { - if (propsFile.isFile() && propsFile.canRead()) { - try (FileInputStream in = new FileInputStream(propsFile)) { - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - } - } - } catch (Exception e) { - Log.e("termux", "Error loading props", e); + 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; - return props.getProperty("allow-external-apps", "false").equals("true"); + // 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); + } + } diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 129895394b..0c9f74125b 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -1,75 +1,72 @@ package com.termux.app; -import android.Manifest; import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; -import android.content.pm.PackageManager; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Typeface; -import android.media.AudioAttributes; -import android.media.SoundPool; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.os.IBinder; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.util.Log; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.Gravity; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; -import android.view.inputmethod.InputMethodManager; -import android.widget.ArrayAdapter; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ListView; -import android.widget.TextView; +import android.widget.RelativeLayout; import android.widget.Toast; import com.termux.R; -import com.termux.terminal.EmulatorDebug; -import com.termux.terminal.TerminalColors; +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.TerminalSession.SessionChangedCallback; -import com.termux.terminal.TextStyle; +import com.termux.terminal.TerminalSessionClient; import com.termux.view.TerminalView; - -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStream; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Properties; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import com.termux.view.TerminalViewClient; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; import androidx.drawerlayout.widget.DrawerLayout; -import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; +import java.util.Arrays; + /** * A terminal emulator activity. *

@@ -80,586 +77,577 @@ * * about memory leaks. */ -public final class TermuxActivity extends Activity implements ServiceConnection { +public final class TermuxActivity extends AppCompatActivity implements ServiceConnection { - public static final String TERMUX_FAILSAFE_SESSION_ACTION = "com.termux.app.failsafe_session"; + /** + * 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; - private static final int CONTEXTMENU_SELECT_URL_ID = 0; - private static final int CONTEXTMENU_SHARE_TRANSCRIPT_ID = 1; - private static final int CONTEXTMENU_PASTE_ID = 3; - private static final int CONTEXTMENU_KILL_PROCESS_ID = 4; - private static final int CONTEXTMENU_RESET_TERMINAL_ID = 5; - private static final int CONTEXTMENU_STYLING_ID = 6; - private static final int CONTEXTMENU_HELP_ID = 8; - private static final int CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON = 9; + /** + * The {@link TerminalView} shown in {@link TermuxActivity} that displays the terminal. + */ + TerminalView mTerminalView; - private static final int MAX_SESSIONS = 8; + /** + * The {@link TerminalViewClient} interface implementation to allow for communication between + * {@link TerminalView} and {@link TermuxActivity}. + */ + TermuxTerminalViewClient mTermuxTerminalViewClient; - private static final int REQUESTCODE_PERMISSION_STORAGE = 1234; + /** + * The {@link TerminalSessionClient} interface implementation to allow for communication between + * {@link TerminalSession} and {@link TermuxActivity}. + */ + TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; - private static final String RELOAD_STYLE_ACTION = "com.termux.app.reload_style"; + /** + * Termux app shared preferences manager. + */ + private TermuxAppSharedPreferences mPreferences; - /** The main view of the activity showing the terminal. Initialized in onCreate(). */ - @SuppressWarnings("NullableProblems") - @NonNull - TerminalView mTerminalView; + /** + * 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; - TermuxPreferences mSettings; + /** + * The client for the {@link #mExtraKeysView}. + */ + TermuxTerminalExtraKeys mTermuxTerminalExtraKeys; /** - * 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)}. + * The termux sessions list controller. */ - TermuxService mTermService; + TermuxSessionsListViewController mTermuxSessionListViewController; - /** Initialized in {@link #onServiceConnected(ComponentName, IBinder)}. */ - ArrayAdapter mListViewAdapter; + /** + * 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)}. */ + /** + * 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. */ - boolean mIsVisible; + private boolean mIsVisible; - boolean mIsUsingBlackUI; - - final SoundPool mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes( - new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build(); - int mBellSoundId; + /** + * If onResume() was called after onCreate(). + */ + private boolean mIsOnResumeAfterOnCreate = false; - private final BroadcastReceiver mBroadcastReceiever = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (mIsVisible) { - String whatToReload = intent.getStringExtra(RELOAD_STYLE_ACTION); - if ("storage".equals(whatToReload)) { - if (ensureStoragePermissionGranted()) - TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); - return; - } - checkForFontAndColors(); - mSettings.reloadFromProperties(TermuxActivity.this); + /** + * 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; - if (mExtraKeysView != null) { - mExtraKeysView.reload(mSettings.mExtraKeys); - } - } - } - }; + /** + * The {@link TermuxActivity} is in an invalid state and must not be run. + */ + private boolean mIsInvalidState; - void checkForFontAndColors() { - try { - @SuppressLint("SdCardPath") File fontFile = new File("/data/data/com.termux/files/home/.termux/font.ttf"); - @SuppressLint("SdCardPath") File colorsFile = new File("/data/data/com.termux/files/home/.termux/colors.properties"); + private int mNavBarHeight; - final Properties props = new Properties(); - if (colorsFile.isFile()) { - try (InputStream in = new FileInputStream(colorsFile)) { - props.load(in); - } - } + private float mTerminalToolbarDefaultHeight; - TerminalColors.COLOR_SCHEME.updateWith(props); - TerminalSession session = getCurrentTermSession(); - if (session != null && session.getEmulator() != null) { - session.getEmulator().mColors.reset(); - } - updateBackgroundColor(); - final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE; - mTerminalView.setTypeface(newTypeface); - } catch (Exception e) { - Log.e(EmulatorDebug.LOG_TAG, "Error in checkForFontAndColors()", e); - } - } + 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; - void updateBackgroundColor() { - TerminalSession session = getCurrentTermSession(); - if (session != null && session.getEmulator() != null) { - getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); - } - } + private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input"; + private static final String ARG_ACTIVITY_RECREATED = "activity_recreated"; - /** For processes to access shared internal storage (/sdcard) we need this permission. */ - public boolean ensureStoragePermissionGranted() { - if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { - return true; - } else { - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUESTCODE_PERMISSION_STORAGE); - return false; - } - } + private static final String LOG_TAG = "TermuxActivity"; @Override - public void onCreate(Bundle bundle) { - mSettings = new TermuxPreferences(this); - mIsUsingBlackUI = mSettings.isUsingBlackUI(); - if (mIsUsingBlackUI) { - this.setTheme(R.style.Theme_Termux_Black); - } else { - this.setTheme(R.style.Theme_Termux); + 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; } - super.onCreate(bundle); + setMargins(); - setContentView(R.layout.drawer_layout); + 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()); - if (mIsUsingBlackUI) { - findViewById(R.id.left_drawer).setBackgroundColor( - getResources().getColor(android.R.color.background_dark) - ); + 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); } - mTerminalView = findViewById(R.id.terminal_view); - mTerminalView.setOnKeyListener(new TermuxViewClient(this)); + setTermuxTerminalViewAndClients(); - mTerminalView.setTextSize(mSettings.getFontSize()); - mTerminalView.setKeepScreenOn(mSettings.isScreenAlwaysOn()); - mTerminalView.requestFocus(); + setTerminalToolbarView(savedInstanceState); - final ViewPager viewPager = findViewById(R.id.viewpager); - if (mSettings.mShowExtraKeys) viewPager.setVisibility(View.VISIBLE); + setSettingsButtonView(); + setNewSessionButtonView(); - ViewGroup.LayoutParams layoutParams = viewPager.getLayoutParams(); - layoutParams.height = layoutParams.height * (mSettings.mExtraKeys == null ? 0 : mSettings.mExtraKeys.getMatrix().length); - viewPager.setLayoutParams(layoutParams); + setToggleKeyboardView(); - viewPager.setAdapter(new PagerAdapter() { - @Override - public int getCount() { - return 2; - } + registerForContextMenu(mTerminalView); - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } + FileReceiverActivity.updateFileReceiverActivityComponentsState(this); - @NonNull - @Override - public Object instantiateItem(@NonNull ViewGroup collection, int position) { - LayoutInflater inflater = LayoutInflater.from(TermuxActivity.this); - View layout; - if (position == 0) { - layout = mExtraKeysView = (ExtraKeysView) inflater.inflate(R.layout.extra_keys_main, collection, false); - mExtraKeysView.reload(mSettings.mExtraKeys); - } else { - layout = inflater.inflate(R.layout.extra_keys_right, collection, false); - final EditText editText = layout.findViewById(R.id.text_input); - editText.setOnEditorActionListener((v, actionId, event) -> { - TerminalSession session = getCurrentTermSession(); - if (session != null) { - if (session.isRunning()) { - String textToSend = editText.getText().toString(); - if (textToSend.length() == 0) textToSend = "\r"; - session.write(textToSend); - } else { - removeFinishedSession(session); - } - editText.setText(""); - } - return true; - }); - } - collection.addView(layout); - return layout; - } + try { + // Start the {@link TermuxService} and make it run regardless of who is bound to it + Intent serviceIntent = new Intent(this, TermuxService.class); + startService(serviceIntent); + + // Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)} + // callback if it succeeds. + if (!bindService(serviceIntent, this, 0)) + throw new RuntimeException("bindService() failed"); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG,"TermuxActivity failed to start TermuxService", e); + Logger.showToast(this, + getString(e.getMessage() != null && e.getMessage().contains("app is in background") ? + R.string.error_termux_service_start_failed_bg : R.string.error_termux_service_start_failed_general), + true); + mIsInvalidState = true; + return; + } - @Override - public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { - collection.removeView((View) view); - } - }); + // Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux + // app has been opened. + TermuxUtils.sendTermuxOpenedBroadcast(this); + } - viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - if (position == 0) { - mTerminalView.requestFocus(); - } else { - final EditText editText = viewPager.findViewById(R.id.text_input); - if (editText != null) editText.requestFocus(); - } - } - }); + @Override + public void onStart() { + super.onStart(); - View newSessionButton = findViewById(R.id.new_session_button); - newSessionButton.setOnClickListener(v -> addNewSession(false, null)); - newSessionButton.setOnLongClickListener(v -> { - DialogUtils.textInput(TermuxActivity.this, R.string.session_new_named_title, null, R.string.session_new_named_positive_button, - text -> addNewSession(false, text), R.string.new_session_failsafe, text -> addNewSession(true, text) - , -1, null, null); - return true; - }); + Logger.logDebug(LOG_TAG, "onStart"); - findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { - InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); - getDrawer().closeDrawers(); - }); + if (mIsInvalidState) return; - findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { - toggleShowExtraKeys(); - return true; - }); + mIsVisible = true; - registerForContextMenu(mTerminalView); + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onStart(); - Intent serviceIntent = new Intent(this, TermuxService.class); - // Start the service and make it run regardless of who is bound to it: - startService(serviceIntent); - if (!bindService(serviceIntent, this, 0)) - throw new RuntimeException("bindService() failed"); + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStart(); - checkForFontAndColors(); + if (mPreferences.isTerminalMarginAdjustmentEnabled()) + addTermuxActivityRootViewGlobalLayoutListener(); - mBellSoundId = mBellSoundPool.load(this, R.raw.bell, 1); + registerTermuxActivityBroadcastReceiver(); } - void toggleShowExtraKeys() { - final ViewPager viewPager = findViewById(R.id.viewpager); - final boolean showNow = mSettings.toggleShowExtraKeys(TermuxActivity.this); - viewPager.setVisibility(showNow ? View.VISIBLE : View.GONE); - if (showNow && viewPager.getCurrentItem() == 1) { - // Focus the text input view if just revealed. - findViewById(R.id.text_input).requestFocus(); - } + @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; } - /** - * 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) { - mTermService = ((TermuxService.LocalBinder) service).service; + protected void onStop() { + super.onStop(); - mTermService.mSessionChangeCallback = new SessionChangedCallback() { - @Override - public void onTextChanged(TerminalSession changedSession) { - if (!mIsVisible) return; - if (getCurrentTermSession() == changedSession) mTerminalView.onScreenUpdated(); - } + Logger.logDebug(LOG_TAG, "onStop"); - @Override - public void onTitleChanged(TerminalSession updatedSession) { - if (!mIsVisible) return; - if (updatedSession != getCurrentTermSession()) { - // Only show toast for other sessions than the current one, since the user - // probably consciously caused the title change to change in the current session - // and don't want an annoying toast for that. - showToast(toToastTitle(updatedSession), false); - } - mListViewAdapter.notifyDataSetChanged(); - } + if (mIsInvalidState) return; - @Override - public void onSessionFinished(final TerminalSession finishedSession) { - if (mTermService.mWantsToStop) { - // The service wants to stop as soon as possible. - finish(); - return; - } - if (mIsVisible && finishedSession != getCurrentTermSession()) { - // Show toast for non-current sessions that exit. - int indexOfSession = mTermService.getSessions().indexOf(finishedSession); - // Verify that session was not removed before we got told about it finishing: - if (indexOfSession >= 0) - showToast(toToastTitle(finishedSession) + " - exited", true); - } + mIsVisible = false; - if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - // On Android TV devices we need to use older behaviour because we may - // not be able to have multiple launcher icons. - if (mTermService.getSessions().size() > 1) { - removeFinishedSession(finishedSession); - } - } else { - // Once we have a separate launcher icon for the failsafe session, it - // should be safe to auto-close session on exit code '0' or '130'. - if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130) { - removeFinishedSession(finishedSession); - } - } + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onStop(); - mListViewAdapter.notifyDataSetChanged(); - } + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStop(); - @Override - public void onClipboardText(TerminalSession session, String text) { - if (!mIsVisible) return; - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(text))); - } + removeTermuxActivityRootViewGlobalLayoutListener(); - @Override - public void onBell(TerminalSession session) { - if (!mIsVisible) return; - - switch (mSettings.mBellBehaviour) { - case TermuxPreferences.BELL_BEEP: - mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f); - break; - case TermuxPreferences.BELL_VIBRATE: - BellUtil.getInstance(TermuxActivity.this).doBell(); - break; - case TermuxPreferences.BELL_IGNORE: - // Ignore the bell character. - break; - } + unregisterTermuxActivityBroadcastReceiver(); + getDrawer().closeDrawers(); + } - } + @Override + public void onDestroy() { + super.onDestroy(); - @Override - public void onColorsChanged(TerminalSession changedSession) { - if (getCurrentTermSession() == changedSession) updateBackgroundColor(); - } - }; + Logger.logDebug(LOG_TAG, "onDestroy"); - ListView listView = findViewById(R.id.left_drawer_list); - mListViewAdapter = new ArrayAdapter(getApplicationContext(), R.layout.line_in_drawer, mTermService.getSessions()) { - final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); - final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC); + if (mIsInvalidState) return; - @NonNull - @Override - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - View row = convertView; - if (row == null) { - LayoutInflater inflater = getLayoutInflater(); - row = inflater.inflate(R.layout.line_in_drawer, parent, false); - } + 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. + } + } - TerminalSession sessionAtRow = getItem(position); - boolean sessionRunning = sessionAtRow.isRunning(); + @Override + public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { + Logger.logVerbose(LOG_TAG, "onSaveInstanceState"); - TextView firstLineView = row.findViewById(R.id.row_line); - if (mIsUsingBlackUI) { - firstLineView.setBackground( - getResources().getDrawable(R.drawable.selected_session_background_black) - ); - } - String name = sessionAtRow.mSessionName; - String sessionTitle = sessionAtRow.getTitle(); + super.onSaveInstanceState(savedInstanceState); + saveTerminalToolbarTextInput(savedInstanceState); + savedInstanceState.putBoolean(ARG_ACTIVITY_RECREATED, true); + } - String numberPart = "[" + (position + 1) + "] "; - String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name); - String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle)); - String text = numberPart + sessionNamePart + sessionTitlePart; - SpannableString styledText = new SpannableString(text); - styledText.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - styledText.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - firstLineView.setText(styledText); - if (sessionRunning) { - firstLineView.setPaintFlags(firstLineView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); - } else { - firstLineView.setPaintFlags(firstLineView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); - } - int defaultColor = mIsUsingBlackUI ? Color.WHITE : Color.BLACK; - int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; - firstLineView.setTextColor(color); - return row; - } - }; - listView.setAdapter(mListViewAdapter); - listView.setOnItemClickListener((parent, view, position, id) -> { - TerminalSession clickedSession = mListViewAdapter.getItem(position); - switchToSession(clickedSession); - getDrawer().closeDrawers(); - }); - listView.setOnItemLongClickListener((parent, view, position, id) -> { - final TerminalSession selectedSession = mListViewAdapter.getItem(position); - renameSession(selectedSession); - return true; - }); - if (mTermService.getSessions().isEmpty()) { + /** + * 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.setupIfNeeded(TermuxActivity.this, () -> { - if (mTermService == null) return; // Activity might have been destroyed. + TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> { + if (mTermuxService == null) return; // Activity might have been destroyed. try { - Bundle bundle = getIntent().getExtras(); boolean launchFailsafe = false; - if (bundle != null) { - launchFailsafe = bundle.getBoolean(TERMUX_FAILSAFE_SESSION_ACTION, false); + if (intent != null && intent.getExtras() != null) { + launchFailsafe = intent.getExtras().getBoolean(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); } - addNewSession(launchFailsafe, null); + mTermuxTerminalSessionActivityClient.addNewSession(launchFailsafe, null); } catch (WindowManager.BadTokenException e) { // Activity finished - ignore. } }); } else { // The service connected while not in foreground - just bail out. - finish(); + finishActivityIfNotFinishing(); } } else { - Intent i = getIntent(); - if (i != null && Intent.ACTION_RUN.equals(i.getAction())) { + // 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 failSafe = i.getBooleanExtra(TERMUX_FAILSAFE_SESSION_ACTION, false); - addNewSession(failSafe, null); + boolean isFailSafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + mTermuxTerminalSessionActivityClient.addNewSession(isFailSafe, null); } else { - switchToSession(getStoredCurrentSessionOrLast()); + mTermuxTerminalSessionActivityClient.setCurrentSession(mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast()); } } + + // Update the {@link TerminalSession} and {@link TerminalEmulator} clients. + mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionActivityClient); } - public void switchToSession(boolean forward) { - TerminalSession currentSession = getCurrentTermSession(); - int index = mTermService.getSessions().indexOf(currentSession); - if (forward) { - if (++index >= mTermService.getSessions().size()) index = 0; - } else { - if (--index < 0) index = mTermService.getSessions().size() - 1; - } - switchToSession(mTermService.getSessions().get(index)); + @Override + public void onServiceDisconnected(ComponentName name) { + Logger.logDebug(LOG_TAG, "onServiceDisconnected"); + + // Respect being stopped from the {@link TermuxService} notification action. + finishActivityIfNotFinishing(); } - @SuppressLint("InflateParams") - void renameSession(final TerminalSession sessionToRename) { - DialogUtils.textInput(this, R.string.session_rename_title, sessionToRename.mSessionName, R.string.session_rename_positive_button, text -> { - sessionToRename.mSessionName = text; - mListViewAdapter.notifyDataSetChanged(); - }, -1, null, -1, null, null); + + + + + + private void reloadProperties() { + mProperties.loadTermuxPropertiesFromDisk(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onReloadProperties(); } - @Override - public void onServiceDisconnected(ComponentName name) { - // Respect being stopped from the TermuxService notification action. - finish(); + + + 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); } - @Nullable - TerminalSession getCurrentTermSession() { - return mTerminalView.getCurrentSession(); + 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); } - @Override - public void onStart() { - super.onStart(); - mIsVisible = true; - if (mTermService != null) { - // The service has connected, but data may have changed since we were last in the foreground. - switchToSession(getStoredCurrentSessionOrLast()); - mListViewAdapter.notifyDataSetChanged(); - } - registerReceiver(mBroadcastReceiever, new IntentFilter(RELOAD_STYLE_ACTION)); + public void addTermuxActivityRootViewGlobalLayoutListener() { + getTermuxActivityRootView().getViewTreeObserver().addOnGlobalLayoutListener(getTermuxActivityRootView()); + } - // The current terminal session may have changed while being away, force - // a refresh of the displayed terminal: - mTerminalView.onScreenUpdated(); + public void removeTermuxActivityRootViewGlobalLayoutListener() { + if (getTermuxActivityRootView() != null) + getTermuxActivityRootView().getViewTreeObserver().removeOnGlobalLayoutListener(getTermuxActivityRootView()); } - @Override - protected void onStop() { - super.onStop(); - mIsVisible = false; - TerminalSession currentSession = getCurrentTermSession(); - if (currentSession != null) TermuxPreferences.storeCurrentSession(this, currentSession); - unregisterReceiver(mBroadcastReceiever); - getDrawer().closeDrawers(); + + + 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(); } - @Override - public void onBackPressed() { - if (getDrawer().isDrawerOpen(Gravity.LEFT)) { - getDrawer().closeDrawers(); - } else { - finish(); + 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(); } } - @Override - public void onDestroy() { - super.onDestroy(); - if (mTermService != null) { - // Do not leave service with references to activity. - mTermService.mSessionChangeCallback = null; - mTermService = null; + 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); } - unbindService(this); } - DrawerLayout getDrawer() { - return (DrawerLayout) findViewById(R.id.drawer_layout); + + + private void setSettingsButtonView() { + ImageButton settingsButton = findViewById(R.id.settings_button); + settingsButton.setOnClickListener(v -> { + ActivityUtils.startActivity(this, new Intent(this, SettingsActivity.class)); + }); } - void addNewSession(boolean failSafe, String sessionName) { - if (mTermService.getSessions().size() >= MAX_SESSIONS) { - new AlertDialog.Builder(this).setTitle(R.string.max_terminals_reached_title).setMessage(R.string.max_terminals_reached_message) - .setPositiveButton(android.R.string.ok, null).show(); - } else { - TerminalSession currentSession = getCurrentTermSession(); - String workingDirectory = (currentSession == null) ? null : currentSession.getCwd(); - TerminalSession newSession = mTermService.createTermSession(null, null, workingDirectory, failSafe); - if (sessionName != null) { - newSession.mSessionName = sessionName; - } - switchToSession(newSession); + 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; + }); } - /** Try switching to session and note about it, but do nothing if already displaying the session. */ - void switchToSession(TerminalSession session) { - if (mTerminalView.attachSession(session)) { - noteSessionInfo(); - updateBackgroundColor(); + + + + + @SuppressLint("RtlHardcoded") + @Override + public void onBackPressed() { + if (getDrawer().isDrawerOpen(Gravity.LEFT)) { + getDrawer().closeDrawers(); + } else { + finishActivityIfNotFinishing(); } } - String toToastTitle(TerminalSession session) { - final int indexOfSession = mTermService.getSessions().indexOf(session); - StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]"); - if (!TextUtils.isEmpty(session.mSessionName)) { - toastTitle.append(" ").append(session.mSessionName); - } - String title = session.getTitle(); - if (!TextUtils.isEmpty(title)) { - // Space to "[${NR}] or newline after session name: - toastTitle.append(session.mSessionName == null ? " " : "\n"); - toastTitle.append(title); + public void finishActivityIfNotFinishing() { + // prevent duplicate calls to finish() if called from multiple places + if (!TermuxActivity.this.isFinishing()) { + finish(); } - return toastTitle.toString(); } - void noteSessionInfo() { - if (!mIsVisible) return; - TerminalSession session = getCurrentTermSession(); - final int indexOfSession = mTermService.getSessions().indexOf(session); - showToast(toToastTitle(session), false); - mListViewAdapter.notifyDataSetChanged(); - final ListView lv = findViewById(R.id.left_drawer_list); - lv.setItemChecked(indexOfSession, true); - lv.smoothScrollToPosition(indexOfSession); + /** 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 = getCurrentTermSession(); + TerminalSession currentSession = getCurrentSession(); if (currentSession == null) return; - menu.add(Menu.NONE, CONTEXTMENU_SELECT_URL_ID, Menu.NONE, R.string.select_url); - menu.add(Menu.NONE, CONTEXTMENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.select_all_and_share); - menu.add(Menu.NONE, CONTEXTMENU_RESET_TERMINAL_ID, Menu.NONE, R.string.reset_terminal); - menu.add(Menu.NONE, CONTEXTMENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.kill_process, getCurrentTermSession().getPid())).setEnabled(currentSession.isRunning()); - menu.add(Menu.NONE, CONTEXTMENU_STYLING_ID, Menu.NONE, R.string.style_terminal); - menu.add(Menu.NONE, CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.toggle_keep_screen_on).setCheckable(true).setChecked(mSettings.isScreenAlwaysOn()); - menu.add(Menu.NONE, CONTEXTMENU_HELP_ID, Menu.NONE, R.string.help); + 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. */ @@ -669,264 +657,357 @@ public boolean onCreateOptionsMenu(Menu menu) { return false; } - static LinkedHashSet extractUrls(String text) { - - StringBuilder regex_sb = new StringBuilder(); - - regex_sb.append("("); // Begin first matching group. - regex_sb.append("(?:"); // Begin scheme group. - regex_sb.append("dav|"); // The DAV proto. - regex_sb.append("dict|"); // The DICT proto. - regex_sb.append("dns|"); // The DNS proto. - regex_sb.append("file|"); // File path. - regex_sb.append("finger|"); // The Finger proto. - regex_sb.append("ftp(?:s?)|"); // The FTP proto. - regex_sb.append("git|"); // The Git proto. - regex_sb.append("gopher|"); // The Gopher proto. - regex_sb.append("http(?:s?)|"); // The HTTP proto. - regex_sb.append("imap(?:s?)|"); // The IMAP proto. - regex_sb.append("irc(?:[6s]?)|"); // The IRC proto. - regex_sb.append("ip[fn]s|"); // The IPFS proto. - regex_sb.append("ldap(?:s?)|"); // The LDAP proto. - regex_sb.append("pop3(?:s?)|"); // The POP3 proto. - regex_sb.append("redis(?:s?)|"); // The Redis proto. - regex_sb.append("rsync|"); // The Rsync proto. - regex_sb.append("rtsp(?:[su]?)|"); // The RTSP proto. - regex_sb.append("sftp|"); // The SFTP proto. - regex_sb.append("smb(?:s?)|"); // The SAMBA proto. - regex_sb.append("smtp(?:s?)|"); // The SMTP proto. - regex_sb.append("svn(?:(?:\\+ssh)?)|"); // The Subversion proto. - regex_sb.append("tcp|"); // The TCP proto. - regex_sb.append("telnet|"); // The Telnet proto. - regex_sb.append("tftp|"); // The TFTP proto. - regex_sb.append("udp|"); // The UDP proto. - regex_sb.append("vnc|"); // The VNC proto. - regex_sb.append("ws(?:s?)"); // The Websocket proto. - regex_sb.append(")://"); // End scheme group. - regex_sb.append(")"); // End first matching group. - - - // Begin second matching group. - regex_sb.append("("); - - // User name and/or password in format 'user:pass@'. - regex_sb.append("(?:\\S+(?::\\S*)?@)?"); - - // Begin host group. - regex_sb.append("(?:"); - - // IP address (from http://www.regular-expressions.info/examples.html). - regex_sb.append("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|"); - - // Host name or domain. - regex_sb.append("(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))?|"); - - // Just path. Used in case of 'file://' scheme. - regex_sb.append("/(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)"); - - // End host group. - regex_sb.append(")"); - - // Port number. - regex_sb.append("(?::\\d{1,5})?"); - - // Resource path with optional query string. - regex_sb.append("(?:/[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); - - // Fragment. - regex_sb.append("(?:#[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); - - // End second matching group. - regex_sb.append(")"); - - final Pattern urlPattern = Pattern.compile( - regex_sb.toString(), - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); - - LinkedHashSet urlSet = new LinkedHashSet<>(); - Matcher matcher = urlPattern.matcher(text); - - while (matcher.find()) { - int matchStart = matcher.start(1); - int matchEnd = matcher.end(); - String url = text.substring(matchStart, matchEnd); - urlSet.add(url); - } - - return urlSet; - } - - void showUrlSelection() { - String text = getCurrentTermSession().getEmulator().getScreen().getTranscriptTextWithFullLinesJoined(); - LinkedHashSet urlSet = extractUrls(text); - if (urlSet.isEmpty()) { - new AlertDialog.Builder(this).setMessage(R.string.select_url_no_found).show(); - return; - } - - final CharSequence[] urls = urlSet.toArray(new CharSequence[0]); - Collections.reverse(Arrays.asList(urls)); // Latest first. - - // Click to copy url to clipboard: - final AlertDialog dialog = new AlertDialog.Builder(TermuxActivity.this).setItems(urls, (di, which) -> { - String url = (String) urls[which]; - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(url))); - Toast.makeText(TermuxActivity.this, R.string.select_url_copied_to_clipboard, Toast.LENGTH_LONG).show(); - }).setTitle(R.string.select_url_dialog_title).create(); - - // Long press to open URL: - dialog.setOnShowListener(di -> { - ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it - lv.setOnItemLongClickListener((parent, view, position, id) -> { - dialog.dismiss(); - String url = (String) urls[position]; - Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - try { - startActivity(i, null); - } catch (ActivityNotFoundException e) { - // If no applications match, Android displays a system message. - startActivity(Intent.createChooser(i, null)); - } - return true; - }); - }); - - dialog.show(); - } - @Override public boolean onContextItemSelected(MenuItem item) { - TerminalSession session = getCurrentTermSession(); + TerminalSession session = getCurrentSession(); switch (item.getItemId()) { - case CONTEXTMENU_SELECT_URL_ID: - showUrlSelection(); + case CONTEXT_MENU_SELECT_URL_ID: + mTermuxTerminalViewClient.showUrlSelection(); return true; - case CONTEXTMENU_SHARE_TRANSCRIPT_ID: - if (session != null) { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - String transcriptText = session.getEmulator().getScreen().getTranscriptTextWithoutJoinedLines().trim(); - // See https://github.com/termux/termux-app/issues/1166. - final int MAX_LENGTH = 100_000; - if (transcriptText.length() > MAX_LENGTH) { - int cutOffIndex = transcriptText.length() - MAX_LENGTH; - int nextNewlineIndex = transcriptText.indexOf('\n', cutOffIndex); - if (nextNewlineIndex != -1 && nextNewlineIndex != transcriptText.length() - 1) { - cutOffIndex = nextNewlineIndex + 1; - } - transcriptText = transcriptText.substring(cutOffIndex).trim(); - } - intent.putExtra(Intent.EXTRA_TEXT, transcriptText); - intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_transcript_title)); - startActivity(Intent.createChooser(intent, getString(R.string.share_transcript_chooser_title))); - } + case CONTEXT_MENU_SHARE_TRANSCRIPT_ID: + mTermuxTerminalViewClient.shareSessionTranscript(); return true; - case CONTEXTMENU_PASTE_ID: - doPaste(); + case CONTEXT_MENU_SHARE_SELECTED_TEXT: + mTermuxTerminalViewClient.shareSelectedText(); return true; - case CONTEXTMENU_KILL_PROCESS_ID: - final AlertDialog.Builder b = new AlertDialog.Builder(this); - b.setIcon(android.R.drawable.ic_dialog_alert); - b.setMessage(R.string.confirm_kill_process); - b.setPositiveButton(android.R.string.yes, (dialog, id) -> { - dialog.dismiss(); - getCurrentTermSession().finishIfRunning(); - }); - b.setNegativeButton(android.R.string.no, null); - b.show(); + case CONTEXT_MENU_AUTOFILL_USERNAME: + mTerminalView.requestAutoFillUsername(); return true; - case CONTEXTMENU_RESET_TERMINAL_ID: { - if (session != null) { - session.reset(); - showToast(getResources().getString(R.string.reset_toast_notification), true); - } + case CONTEXT_MENU_AUTOFILL_PASSWORD: + mTerminalView.requestAutoFillPassword(); return true; - } - case CONTEXTMENU_STYLING_ID: { - Intent stylingIntent = new Intent(); - stylingIntent.setClassName("com.termux.styling", "com.termux.styling.TermuxStyleActivity"); - 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(R.string.styling_not_installed) - .setPositiveButton(R.string.styling_install, (dialog, which) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://play.google.com/store/apps/details?id=com.termux.styling")))).setNegativeButton(android.R.string.cancel, null).show(); - } + case CONTEXT_MENU_RESET_TERMINAL_ID: + onResetTerminalSession(session); return true; - } - case CONTEXTMENU_HELP_ID: - startActivity(new Intent(this, TermuxHelpActivity.class)); + case CONTEXT_MENU_KILL_PROCESS_ID: + showKillSessionDialog(session); return true; - case CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON: { - if(mTerminalView.getKeepScreenOn()) { - mTerminalView.setKeepScreenOn(false); - mSettings.setScreenAlwaysOn(this, false); - } else { - mTerminalView.setKeepScreenOn(true); - mSettings.setScreenAlwaysOn(this, 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 onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { - if (requestCode == REQUESTCODE_PERMISSION_STORAGE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - TermuxInstaller.setupStorageSymlinks(this); + 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); } } - void changeFontSize(boolean increase) { - mSettings.changeFontSize(this, increase); - mTerminalView.setTextSize(mSettings.getFontSize()); + @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); + } } - void doPaste() { - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null) return; - CharSequence paste = clipData.getItemAt(0).coerceToText(this); - if (!TextUtils.isEmpty(paste)) - getCurrentTermSession().getEmulator().paste(paste.toString()); + + + public int getNavBarHeight() { + return mNavBarHeight; } - /** The current session as stored or the last one if that does not exist. */ - public TerminalSession getStoredCurrentSessionOrLast() { - TerminalSession stored = TermuxPreferences.getCurrentSession(this); - if (stored != null) return stored; - List sessions = mTermService.getSessions(); - return sessions.isEmpty() ? null : sessions.get(sessions.size() - 1); + public TermuxActivityRootView getTermuxActivityRootView() { + return mTermuxActivityRootView; } - /** Show a toast and dismiss the last one if still visible. */ - void showToast(String text, boolean longDuration) { - if (mLastToast != null) mLastToast.cancel(); - mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT); - mLastToast.setGravity(Gravity.TOP, 0, 0); - mLastToast.show(); + public View getTermuxActivityBottomSpaceView() { + return mTermuxActivityBottomSpaceView; + } + + public ExtraKeysView getExtraKeysView() { + return mExtraKeysView; } - public void removeFinishedSession(TerminalSession finishedSession) { - // Return pressed with finished session - remove it. - TermuxService service = mTermService; + public TermuxTerminalExtraKeys getTermuxTerminalExtraKeys() { + return mTermuxTerminalExtraKeys; + } - int index = service.removeTermSession(finishedSession); - mListViewAdapter.notifyDataSetChanged(); - if (mTermService.getSessions().isEmpty()) { - // There are no sessions to show, so finish the activity. - finish(); - } else { - if (index >= service.getSessions().size()) { - index = service.getSessions().size() - 1; + 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: + } } - switchToSession(service.getSessions().get(index)); } } + private void reloadActivityStyling(boolean recreateActivity) { + if (mProperties != null) { + reloadProperties(); + + if (mExtraKeysView != null) { + mExtraKeysView.setButtonTextAllCaps(mProperties.shouldExtraKeysTextBeAllCaps()); + mExtraKeysView.reload(mTermuxTerminalExtraKeys.getExtraKeysInfo(), mTerminalToolbarDefaultHeight); + } + + // Update NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(mProperties.getNightMode()); + } + + setMargins(); + setTerminalToolbarHeight(); + + FileReceiverActivity.updateFileReceiverActivityComponentsState(this); + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onReloadActivityStyling(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onReloadActivityStyling(); + + // To change the activity and drawer theme, activity needs to be recreated. + // It will destroy the activity, including all stored variables and views, and onCreate() + // will be called again. Extra keys input text, terminal sessions and transcripts will be preserved. + if (recreateActivity) { + Logger.logDebug(LOG_TAG, "Recreating activity"); + TermuxActivity.this.recreate(); + } + } + + + + public static void startTermuxActivity(@NonNull final Context context) { + ActivityUtils.startActivity(context, newInstance(context)); + } + + public static Intent newInstance(@NonNull final Context context) { + Intent intent = new Intent(context, TermuxActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + } diff --git a/app/src/main/java/com/termux/app/TermuxApplication.java b/app/src/main/java/com/termux/app/TermuxApplication.java new file mode 100644 index 0000000000..1123abf623 --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxApplication.java @@ -0,0 +1,85 @@ +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()); + } + +} diff --git a/app/src/main/java/com/termux/app/TermuxInstaller.java b/app/src/main/java/com/termux/app/TermuxInstaller.java index 6e50b22dd6..b14149993a 100644 --- a/app/src/main/java/com/termux/app/TermuxInstaller.java +++ b/app/src/main/java/com/termux/app/TermuxInstaller.java @@ -4,36 +4,49 @@ import android.app.AlertDialog; import android.app.ProgressDialog; import android.content.Context; +import android.os.Build; import android.os.Environment; -import android.os.UserManager; import android.system.Os; -import android.util.Log; import android.util.Pair; import android.view.WindowManager; import com.termux.R; -import com.termux.terminal.EmulatorDebug; +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.IOException; 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 folder below. + * broken $PREFIX directory below. *

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

- * (3) A staging folder, $STAGING_PREFIX, is {@link #deleteFolder(File)} if left over from broken installation below. + * (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. *

@@ -46,36 +59,100 @@ */ final class TermuxInstaller { - /** Performs setup if necessary. */ - static void setupIfNeeded(final Activity activity, final Runnable whenDone) { + 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: - UserManager um = (UserManager) activity.getSystemService(Context.USER_SERVICE); - boolean isPrimaryUser = um.getSerialNumberForUser(android.os.Process.myUserHandle()) == 0; - if (!isPrimaryUser) { - new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_not_primary_user_message) - .setOnDismissListener(dialog -> System.exit(0)).setPositiveButton(android.R.string.ok, null).show(); + 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; } - final File PREFIX_FILE = new File(TermuxService.PREFIX_PATH); - if (PREFIX_FILE.isDirectory()) { - whenDone.run(); + 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 { - final String STAGING_PREFIX_PATH = TermuxService.FILES_PATH + "/usr-staging"; - final File STAGING_PREFIX_FILE = new File(STAGING_PREFIX_PATH); + Logger.logInfo(LOG_TAG, "Installing " + TermuxConstants.TERMUX_APP_NAME + " bootstrap packages."); + + Error error; - if (STAGING_PREFIX_FILE.exists()) { - deleteFolder(STAGING_PREFIX_FILE); + // 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); @@ -91,17 +168,25 @@ public void run() { if (parts.length != 2) throw new RuntimeException("Malformed symlink line: " + line); String oldPath = parts[0]; - String newPath = STAGING_PREFIX_PATH + "/" + parts[1]; + String newPath = TERMUX_STAGING_PREFIX_DIR_PATH + "/" + parts[1]; symlinks.add(Pair.create(oldPath, newPath)); - ensureDirectoryExists(new File(newPath).getParentFile()); + 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(STAGING_PREFIX_PATH, zipEntryName); + File targetFile = new File(TERMUX_STAGING_PREFIX_DIR_PATH, zipEntryName); boolean isDirectory = zipEntry.isDirectory(); - ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile()); + error = ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile()); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } if (!isDirectory) { try (FileOutputStream outStream = new FileOutputStream(targetFile)) { @@ -109,7 +194,8 @@ public void run() { while ((readBytes = zipInput.read(buffer)) != -1) outStream.write(buffer, 0, readBytes); } - if (zipEntryName.startsWith("bin/") || zipEntryName.startsWith("libexec") || zipEntryName.startsWith("lib/apt/methods")) { + 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); } @@ -124,27 +210,22 @@ public void run() { Os.symlink(symlink.first, symlink.second); } - if (!STAGING_PREFIX_FILE.renameTo(PREFIX_FILE)) { - throw new RuntimeException("Unable to rename staging folder"); + 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) { - Log.e(EmulatorDebug.LOG_TAG, "Bootstrap error", e); - 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(); - TermuxInstaller.setupIfNeeded(activity, whenDone); - }).show(); - } catch (WindowManager.BadTokenException e1) { - // Activity already dismissed - ignore. - } - }); + showBootstrapErrorDialog(activity, whenDone, Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e))); + } finally { activity.runOnUiThread(() -> { try { @@ -158,61 +239,72 @@ public void run() { }.start(); } - private static void ensureDirectoryExists(File directory) { - if (!directory.isDirectory() && !directory.mkdirs()) { - throw new RuntimeException("Unable to create directory: " + 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 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. + } + }); } - public static native byte[] getZip(); - - /** Delete a folder and all its content or throw. Don't follow symlinks. */ - static void deleteFolder(File fileOrDirectory) throws IOException { - if (fileOrDirectory.getCanonicalPath().equals(fileOrDirectory.getAbsolutePath()) && fileOrDirectory.isDirectory()) { - File[] children = fileOrDirectory.listFiles(); - - if (children != null) { - for (File child : children) { - deleteFolder(child); - } - } - } + private static void sendBootstrapCrashReportNotification(Activity activity, String message) { + final String title = TermuxConstants.TERMUX_APP_NAME + " Bootstrap Error"; - if (!fileOrDirectory.delete()) { - throw new RuntimeException("Unable to delete " + (fileOrDirectory.isDirectory() ? "directory " : "file ") + fileOrDirectory.getAbsolutePath()); - } + // 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 { - File storageDir = new File(TermuxService.HOME_PATH, "storage"); - - if (storageDir.exists()) { - try { - deleteFolder(storageDir); - } catch (IOException e) { - Log.e(LOG_TAG, "Could not delete old $HOME/storage, " + e.getMessage()); - return; - } - } - - if (!storageDir.mkdirs()) { - Log.e(LOG_TAG, "Unable to mkdirs() for $HOME/storage"); + 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()); @@ -228,20 +320,67 @@ public void run() { File moviesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); Os.symlink(moviesDir.getAbsolutePath(), new File(storageDir, "movies").getAbsolutePath()); - final File[] dirs = context.getExternalFilesDirs(null); - if (dirs != null && dirs.length > 1) { - for (int i = 1; i < dirs.length; i++) { + 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) { - Log.e(LOG_TAG, "Error setting up link", e); + Logger.logErrorAndShowToast(context, LOG_TAG, e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, "Setup Storage Error: Error setting up link", e); + TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null, + "## " + title + "\n\n" + Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)), + true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true); } } }.start(); } + private static Error ensureDirectoryExists(File directory) { + return FileUtils.createDirectoryFile(directory.getAbsolutePath()); + } + + public static byte[] loadZipBytes() { + // Only load the shared library when necessary to save memory usage. + System.loadLibrary("termux-bootstrap"); + return getZip(); + } + + public static native byte[] getZip(); + } diff --git a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java index 6b8bf227b6..f27f922cab 100644 --- a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java +++ b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java @@ -11,10 +11,15 @@ import android.os.Environment; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; -import android.util.Log; import android.webkit.MimeTypeMap; -import com.termux.terminal.EmulatorDebug; +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; @@ -24,15 +29,19 @@ 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) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: Called without intent data"); + Logger.logError(LOG_TAG, "Called without intent data"); return; } - final String filePath = data.getPath(); + 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(); @@ -42,12 +51,12 @@ public void onReceive(Context context, Intent intent) { // Ok. break; default: - Log.e(EmulatorDebug.LOG_TAG, "Invalid action '" + intentAction + "', using 'view'"); + Logger.logError(LOG_TAG, "Invalid action '" + intentAction + "', using 'view'"); break; } - final boolean isExternalUrl = data.getScheme() != null && !data.getScheme().equals("file"); - if (isExternalUrl) { + 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()); @@ -59,14 +68,21 @@ public void onReceive(Context context, Intent intent) { try { context.startActivity(urlIntent); } catch (ActivityNotFoundException e) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: No app handles the url " + data); + 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())) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); + Logger.logError(LOG_TAG, "Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); return; } @@ -87,7 +103,8 @@ public void onReceive(Context context, Intent intent) { contentTypeToUse = contentTypeExtra; } - Uri uriToShare = Uri.parse("content://com.termux.files" + fileToShare.getAbsolutePath()); + // 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); @@ -103,12 +120,14 @@ public void onReceive(Context context, Intent intent) { try { context.startActivity(sendIntent); } catch (ActivityNotFoundException e) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: No app handles the url " + data); + 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; @@ -153,6 +172,13 @@ public Cursor query(@NonNull Uri uri, String[] projection, String selection, Str @Override public String getType(@NonNull Uri uri) { + String path = uri.getLastPathSegment(); + int extIndex = path.lastIndexOf('.') + 1; + if (extIndex > 0) { + MimeTypeMap mimeMap = MimeTypeMap.getSingleton(); + String ext = path.substring(extIndex).toLowerCase(); + return mimeMap.getMimeTypeFromExtension(ext); + } return null; } @@ -176,15 +202,33 @@ public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) thr 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(TermuxService.FILES_PATH) || path.startsWith(storagePath))) { + 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.MODE_READ_ONLY); + + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)); } } diff --git a/app/src/main/java/com/termux/app/TermuxPreferences.java b/app/src/main/java/com/termux/app/TermuxPreferences.java deleted file mode 100644 index 3703cd0956..0000000000 --- a/app/src/main/java/com/termux/app/TermuxPreferences.java +++ /dev/null @@ -1,251 +0,0 @@ -package com.termux.app; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.preference.PreferenceManager; -import android.util.Log; -import android.util.TypedValue; -import android.widget.Toast; -import com.termux.terminal.TerminalSession; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Properties; - -import androidx.annotation.IntDef; - -final class TermuxPreferences { - - @IntDef({BELL_VIBRATE, BELL_BEEP, BELL_IGNORE}) - @Retention(RetentionPolicy.SOURCE) - @interface AsciiBellBehaviour { - } - - final static class KeyboardShortcut { - - KeyboardShortcut(int codePoint, int shortcutAction) { - this.codePoint = codePoint; - this.shortcutAction = shortcutAction; - } - - final int codePoint; - final int shortcutAction; - } - - static final int SHORTCUT_ACTION_CREATE_SESSION = 1; - static final int SHORTCUT_ACTION_NEXT_SESSION = 2; - static final int SHORTCUT_ACTION_PREVIOUS_SESSION = 3; - static final int SHORTCUT_ACTION_RENAME_SESSION = 4; - - static final int BELL_VIBRATE = 1; - static final int BELL_BEEP = 2; - static final int BELL_IGNORE = 3; - - private final int MIN_FONTSIZE; - private static final int MAX_FONTSIZE = 256; - - private static final String SHOW_EXTRA_KEYS_KEY = "show_extra_keys"; - private static final String FONTSIZE_KEY = "fontsize"; - private static final String CURRENT_SESSION_KEY = "current_session"; - private static final String SCREEN_ALWAYS_ON_KEY = "screen_always_on"; - - private boolean mUseDarkUI; - private boolean mScreenAlwaysOn; - private int mFontSize; - - @AsciiBellBehaviour - int mBellBehaviour = BELL_VIBRATE; - - boolean mBackIsEscape; - boolean mDisableVolumeVirtualKeys; - boolean mShowExtraKeys; - - ExtraKeysInfos mExtraKeys; - - final List shortcuts = new ArrayList<>(); - - /** - * If value is not in the range [min, max], set it to either min or max. - */ - static int clamp(int value, int min, int max) { - return Math.min(Math.max(value, min), max); - } - - TermuxPreferences(Context context) { - reloadFromProperties(context); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, context.getResources().getDisplayMetrics()); - - // 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: - MIN_FONTSIZE = (int) (4f * dipInPixels); - - mShowExtraKeys = prefs.getBoolean(SHOW_EXTRA_KEYS_KEY, true); - mScreenAlwaysOn = prefs.getBoolean(SCREEN_ALWAYS_ON_KEY, false); - - // 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--; - - try { - mFontSize = Integer.parseInt(prefs.getString(FONTSIZE_KEY, Integer.toString(defaultFontSize))); - } catch (NumberFormatException | ClassCastException e) { - mFontSize = defaultFontSize; - } - mFontSize = clamp(mFontSize, MIN_FONTSIZE, MAX_FONTSIZE); - } - - boolean toggleShowExtraKeys(Context context) { - mShowExtraKeys = !mShowExtraKeys; - PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(SHOW_EXTRA_KEYS_KEY, mShowExtraKeys).apply(); - return mShowExtraKeys; - } - - int getFontSize() { - return mFontSize; - } - - void changeFontSize(Context context, boolean increase) { - mFontSize += (increase ? 1 : -1) * 2; - mFontSize = Math.max(MIN_FONTSIZE, Math.min(mFontSize, MAX_FONTSIZE)); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putString(FONTSIZE_KEY, Integer.toString(mFontSize)).apply(); - } - - boolean isScreenAlwaysOn() { - return mScreenAlwaysOn; - } - - boolean isUsingBlackUI() { - return mUseDarkUI; - } - - void setScreenAlwaysOn(Context context, boolean newValue) { - mScreenAlwaysOn = newValue; - PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(SCREEN_ALWAYS_ON_KEY, newValue).apply(); - } - - static void storeCurrentSession(Context context, TerminalSession session) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putString(TermuxPreferences.CURRENT_SESSION_KEY, session.mHandle).apply(); - } - - static TerminalSession getCurrentSession(TermuxActivity context) { - String sessionHandle = PreferenceManager.getDefaultSharedPreferences(context).getString(TermuxPreferences.CURRENT_SESSION_KEY, ""); - for (int i = 0, len = context.mTermService.getSessions().size(); i < len; i++) { - TerminalSession session = context.mTermService.getSessions().get(i); - if (session.mHandle.equals(sessionHandle)) return session; - } - return null; - } - - void reloadFromProperties(Context context) { - File propsFile = new File(TermuxService.HOME_PATH + "/.termux/termux.properties"); - if (!propsFile.exists()) - propsFile = new File(TermuxService.HOME_PATH + "/.config/termux/termux.properties"); - - Properties props = new Properties(); - try { - if (propsFile.isFile() && propsFile.canRead()) { - try (FileInputStream in = new FileInputStream(propsFile)) { - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - } - } - } catch (Exception e) { - Toast.makeText(context, "Could not open properties file termux.properties: " + e.getMessage(), Toast.LENGTH_LONG).show(); - Log.e("termux", "Error loading props", e); - } - - switch (props.getProperty("bell-character", "vibrate")) { - case "beep": - mBellBehaviour = BELL_BEEP; - break; - case "ignore": - mBellBehaviour = BELL_IGNORE; - break; - default: // "vibrate". - mBellBehaviour = BELL_VIBRATE; - break; - } - - switch (props.getProperty("use-black-ui", "").toLowerCase()) { - case "true": - mUseDarkUI = true; - break; - case "false": - mUseDarkUI = false; - break; - default: - int nightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - mUseDarkUI = nightMode == Configuration.UI_MODE_NIGHT_YES; - } - - String defaultExtraKeys = "[[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]]"; - - try { - String extrakeyProp = props.getProperty("extra-keys", defaultExtraKeys); - String extraKeysStyle = props.getProperty("extra-keys-style", "default"); - mExtraKeys = new ExtraKeysInfos(extrakeyProp, extraKeysStyle); - } catch (JSONException e) { - Toast.makeText(context, "Could not load the extra-keys property from the config: " + e.toString(), Toast.LENGTH_LONG).show(); - Log.e("termux", "Error loading props", e); - - try { - mExtraKeys = new ExtraKeysInfos(defaultExtraKeys, "default"); - } catch (JSONException e2) { - e2.printStackTrace(); - Toast.makeText(context, "Can't create default extra keys", Toast.LENGTH_LONG).show(); - mExtraKeys = null; - } - } - - mBackIsEscape = "escape".equals(props.getProperty("back-key", "back")); - mDisableVolumeVirtualKeys = "volume".equals(props.getProperty("volume-keys", "virtual")); - - shortcuts.clear(); - parseAction("shortcut.create-session", SHORTCUT_ACTION_CREATE_SESSION, props); - parseAction("shortcut.next-session", SHORTCUT_ACTION_NEXT_SESSION, props); - parseAction("shortcut.previous-session", SHORTCUT_ACTION_PREVIOUS_SESSION, props); - parseAction("shortcut.rename-session", SHORTCUT_ACTION_RENAME_SESSION, props); - } - - private void parseAction(String name, int shortcutAction, Properties props) { - String value = props.getProperty(name); - if (value == null) return; - 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) { - Log.e("termux", "Keyboard shortcut '" + name + "' is not Ctrl+"); - return; - } - - char c = input.charAt(0); - int codePoint = c; - if (Character.isLowSurrogate(c)) { - if (input.length() != 2 || Character.isHighSurrogate(input.charAt(1))) { - Log.e("termux", "Keyboard shortcut '" + name + "' is not Ctrl+"); - return; - } else { - codePoint = Character.toCodePoint(input.charAt(1), c); - } - } - shortcuts.add(new KeyboardShortcut(codePoint, shortcutAction)); - } - -} diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index bbaf87c548..8025d0bd2c 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -2,38 +2,61 @@ import android.annotation.SuppressLint; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.res.Resources; -import android.net.Uri; 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 android.provider.Settings; -import android.util.Log; -import android.widget.ArrayAdapter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.termux.R; -import com.termux.terminal.EmulatorDebug; +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.TerminalSession.SessionChangedCallback; +import com.termux.terminal.TerminalSessionClient; -import java.io.File; import java.util.ArrayList; import java.util.List; /** - * A service holding a list of terminal sessions, {@link #mTerminalSessions}, 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 + * 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 @@ -42,28 +65,7 @@ * Optionally may hold a wake and a wifi lock, in which case that is shown in the notification - see * {@link #buildNotification()}. */ -public final class TermuxService extends Service implements SessionChangedCallback { - - private static final String NOTIFICATION_CHANNEL_ID = "termux_notification_channel"; - - /** Note that this is a symlink on the Android M preview. */ - @SuppressLint("SdCardPath") - public static final String FILES_PATH = "/data/data/com.termux/files"; - public static final String PREFIX_PATH = FILES_PATH + "/usr"; - public static final String HOME_PATH = FILES_PATH + "/home"; - - private static final int NOTIFICATION_ID = 1337; - - private static final String ACTION_STOP_SERVICE = "com.termux.service_stop"; - private static final String ACTION_LOCK_WAKE = "com.termux.service_wake_lock"; - private static final String ACTION_UNLOCK_WAKE = "com.termux.service_wake_unlock"; - /** Intent action to launch a new terminal session. Executed from TermuxWidgetProvider. */ - public static final String ACTION_EXECUTE = "com.termux.service_execute"; - - public static final String EXTRA_ARGUMENTS = "com.termux.execute.arguments"; - - public static final String EXTRA_CURRENT_WORKING_DIRECTORY = "com.termux.execute.cwd"; - private static final String EXTRA_EXECUTE_IN_BACKGROUND = "com.termux.execute.background"; +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 { @@ -74,103 +76,88 @@ class LocalBinder extends Binder { private final Handler mHandler = new Handler(); - /** - * The terminal sessions which this service manages. - *

- * Note that this list is observed by {@link TermuxActivity#mListViewAdapter}, so any changes must be made on the UI - * thread and followed by a call to {@link ArrayAdapter#notifyDataSetChanged()} }. + + /** 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. */ - final List mTerminalSessions = new ArrayList<>(); + private TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; - final List mBackgroundTasks = new ArrayList<>(); + /** 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); - /** Note that the service may often outlive the activity, so need to clear this reference. */ - SessionChangedCallback mSessionChangeCallback; + /** + * 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 #ACTION_STOP_SERVICE} intent. */ + /** If the user has executed the {@link TERMUX_SERVICE#ACTION_STOP_SERVICE} intent. */ boolean mWantsToStop = false; - @SuppressLint("Wakelock") + private static final String LOG_TAG = "TermuxService"; + @Override - public int onStartCommand(Intent intent, int flags, int startId) { - String action = intent.getAction(); - if (ACTION_STOP_SERVICE.equals(action)) { - mWantsToStop = true; - for (int i = 0; i < mTerminalSessions.size(); i++) - mTerminalSessions.get(i).finishIfRunning(); - stopSelf(); - } else if (ACTION_LOCK_WAKE.equals(action)) { - if (mWakeLock == null) { - PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); - mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, EmulatorDebug.LOG_TAG); - 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, EmulatorDebug.LOG_TAG); - mWifiLock.acquire(); - - String packageName = getPackageName(); - if (!pm.isIgnoringBatteryOptimizations(packageName)) { - Intent whitelist = new Intent(); - whitelist.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); - whitelist.setData(Uri.parse("package:" + packageName)); - whitelist.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - try { - startActivity(whitelist); - } catch (ActivityNotFoundException e) { - Log.e(EmulatorDebug.LOG_TAG, "Failed to call ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", e); - } - } + public void onCreate() { + Logger.logVerbose(LOG_TAG, "onCreate"); - updateNotification(); - } - } else if (ACTION_UNLOCK_WAKE.equals(action)) { - if (mWakeLock != null) { - mWakeLock.release(); - mWakeLock = null; + // Get Termux app SharedProperties without loading from disk since TermuxApplication handles + // load and TermuxActivity handles reloads + mProperties = TermuxAppSharedProperties.getProperties(); - mWifiLock.release(); - mWifiLock = null; + mShellManager = TermuxShellManager.getShellManager(); - updateNotification(); - } - } else if (ACTION_EXECUTE.equals(action)) { - Uri executableUri = intent.getData(); - String executablePath = (executableUri == null ? null : executableUri.getPath()); + runStartForeground(); - String[] arguments = (executableUri == null ? null : intent.getStringArrayExtra(EXTRA_ARGUMENTS)); - String cwd = intent.getStringExtra(EXTRA_CURRENT_WORKING_DIRECTORY); + SystemEventReceiver.registerPackageUpdateEvents(this); + } - if (intent.getBooleanExtra(EXTRA_EXECUTE_IN_BACKGROUND, false)) { - BackgroundJob task = new BackgroundJob(cwd, executablePath, arguments, this, intent.getParcelableExtra("pendingIntent")); - mBackgroundTasks.add(task); - updateNotification(); - } else { - boolean failsafe = intent.getBooleanExtra(TermuxActivity.TERMUX_FAILSAFE_SESSION_ACTION, false); - TerminalSession newSession = createTermSession(executablePath, arguments, cwd, failsafe); - - // Transform executable path to session name, e.g. "/bin/do-something.sh" => "do something.sh". - if (executablePath != null) { - int lastSlash = executablePath.lastIndexOf('/'); - String name = (lastSlash == -1) ? executablePath : executablePath.substring(lastSlash + 1); - name = name.replace('-', ' '); - newSession.mSessionName = name; - } + @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(); - // Make the newly created session the current one to be displayed: - TermuxPreferences.storeCurrentSession(this, newSession); + String action = null; + if (intent != null) { + Logger.logVerboseExtended(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + action = intent.getAction(); + } - // Launch the main Termux app, which will now show the current session: - startActivity(new Intent(this, TermuxActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + 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; } - } else if (action != null) { - Log.e(EmulatorDebug.LOG_TAG, "Unknown TermuxService action: '" + action + "'"); } // If this service really do get killed, there is no point restarting it automatically - let the user do on next @@ -178,216 +165,795 @@ public int onStartCommand(Intent intent, int flags, int startId) { 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 void onCreate() { + 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(NOTIFICATION_ID, buildNotification()); + startForeground(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification()); } - /** Update the shown foreground service notification after making any changes that affect it. */ - void updateNotification() { - if (mWakeLock == null && mTerminalSessions.isEmpty() && mBackgroundTasks.isEmpty()) { - // Exit if we are updating after the user disabled all locks with no sessions or tasks running. - stopSelf(); - } else { - ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(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); + } + } } } - private Notification buildNotification() { - Intent notifyIntent = new Intent(this, TermuxActivity.class); - // PendingIntent#getActivity(): "Note that the activity will be started outside of the context of an existing - // activity, so you must use the Intent.FLAG_ACTIVITY_NEW_TASK launch flag in the Intent": - notifyIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notifyIntent, 0); - - int sessionCount = mTerminalSessions.size(); - int taskCount = mBackgroundTasks.size(); - String contentText = sessionCount + " session" + (sessionCount == 1 ? "" : "s"); - if (taskCount > 0) { - contentText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s"); + + + /** 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; } - final boolean wakeLockHeld = mWakeLock != null; - if (wakeLockHeld) contentText += " (wake lock held)"; + Logger.logDebug(LOG_TAG, "Acquiring WakeLocks"); - Notification.Builder builder = new Notification.Builder(this); - builder.setContentTitle(getText(R.string.application_name)); - builder.setContentText(contentText); - builder.setSmallIcon(R.drawable.ic_service_notification); - builder.setContentIntent(pendingIntent); - builder.setOngoing(true); + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TermuxConstants.TERMUX_APP_NAME.toLowerCase() + ":service-wakelock"); + mWakeLock.acquire(); - // If holding a wake or wifi lock consider the notification of high priority since it's using power, - // otherwise use a low priority - builder.setPriority((wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_LOW); + // 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(); - // No need to show a timestamp: - builder.setShowWhen(false); + if (!PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) { + PermissionUtils.requestDisableBatteryOptimizations(this); + } - // Background color for small notification icon: - builder.setColor(0xFF607D8B); + updateNotification(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - builder.setChannelId(NOTIFICATION_CHANNEL_ID); + 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; } - Resources res = getResources(); - Intent exitIntent = new Intent(this, TermuxService.class).setAction(ACTION_STOP_SERVICE); - builder.addAction(android.R.drawable.ic_delete, res.getString(R.string.notification_action_exit), PendingIntent.getService(this, 0, exitIntent, 0)); + Logger.logDebug(LOG_TAG, "Releasing WakeLocks"); - String newWakeAction = wakeLockHeld ? ACTION_UNLOCK_WAKE : ACTION_LOCK_WAKE; - 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)); + if (mWakeLock != null) { + mWakeLock.release(); + mWakeLock = null; + } - return builder.build(); + if (mWifiLock != null) { + mWifiLock.release(); + mWifiLock = null; + } + + if (updateNotification) + updateNotification(); + + Logger.logDebug(LOG_TAG, "WakeLocks released successfully"); } - @Override - public void onDestroy() { - File termuxTmpDir = new File(TermuxService.PREFIX_PATH + "/tmp"); + /** 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; + } - if (termuxTmpDir.exists()) { - try { - TermuxInstaller.deleteFolder(termuxTmpDir.getCanonicalFile()); - } catch (Exception e) { - Log.e(EmulatorDebug.LOG_TAG, "Error while removing file at " + termuxTmpDir.getAbsolutePath(), e); - } + ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId()); - termuxTmpDir.mkdirs(); + 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 (mWakeLock != null) mWakeLock.release(); - if (mWifiLock != null) mWifiLock.release(); + if (executionCommand.executableUri != null) { + Logger.logVerbose(LOG_TAG, "uri: \"" + executionCommand.executableUri + "\", path: \"" + executionCommand.executableUri.getPath() + "\", fragment: \"" + executionCommand.executableUri.getFragment() + "\""); - stopForeground(true); + // 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); + } - for (int i = 0; i < mTerminalSessions.size(); i++) - mTerminalSessions.get(i).finishIfRunning(); + 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); + } } - public List getSessions() { - return mTerminalSessions; + + + + + /** 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); } - TerminalSession createTermSession(String executablePath, String[] arguments, String cwd, boolean failSafe) { - new File(HOME_PATH).mkdirs(); + /** 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)); + } - if (cwd == null) cwd = HOME_PATH; + /** Create a TermuxTask. */ + @Nullable + public synchronized AppShell createTermuxTask(ExecutionCommand executionCommand) { + if (executionCommand == null) return null; - String[] env = BackgroundJob.buildEnvironment(failSafe, cwd); - boolean isLoginShell = false; + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); - if (executablePath == null) { - if (!failSafe) { - for (String shellBinary : new String[]{"login", "bash", "zsh"}) { - File shellFile = new File(PREFIX_PATH + "/bin/" + shellBinary); - if (shellFile.canExecute()) { - executablePath = shellFile.getAbsolutePath(); - break; - } - } - } + if (!Runner.APP_SHELL.equalsRunner(executionCommand.runner)) { + Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxTask()"); + return null; + } - if (executablePath == null) { - // Fall back to system shell as last resort: - executablePath = "/system/bin/sh"; + 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()); } - isLoginShell = true; + return null; } - String[] processArgs = BackgroundJob.setupProcessArgs(executablePath, arguments); - executablePath = processArgs[0]; - int lastSlashIndex = executablePath.lastIndexOf('/'); - String processName = (isLoginShell ? "-" : "") + - (lastSlashIndex == -1 ? executablePath : executablePath.substring(lastSlashIndex + 1)); + mShellManager.mTermuxTasks.add(newTermuxTask); - String[] args = new String[processArgs.length]; - args[0] = processName; - if (processArgs.length > 1) System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1); + // Remove the execution command from the pending plugin execution commands list since it has + // now been processed + if (executionCommand.isPluginExecutionCommand) + mShellManager.mPendingPluginExecutionCommands.remove(executionCommand); - TerminalSession session = new TerminalSession(executablePath, cwd, args, env, this); - mTerminalSessions.add(session); updateNotification(); - // Make sure that terminal styling is always applied. - Intent stylingIntent = new Intent("com.termux.app.reload_style"); - stylingIntent.putExtra("com.termux.app.reload_style", "styling"); - sendBroadcast(stylingIntent); - - return session; + return newTermuxTask; } - public int removeTermSession(TerminalSession sessionToRemove) { - int indexOfRemoved = mTerminalSessions.indexOf(sessionToRemove); - mTerminalSessions.remove(indexOfRemoved); - if (mTerminalSessions.isEmpty() && mWakeLock == null) { - // Finish if there are no sessions left and the wake lock is not held, otherwise keep the service alive if - // holding wake lock since there may be daemon processes (e.g. sshd) running. - stopSelf(); - } else { + /** 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() + "\""); } - return indexOfRemoved; + + 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()); } - @Override - public void onTitleChanged(TerminalSession changedSession) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onTitleChanged(changedSession); + /** + * 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); } - @Override - public void onSessionFinished(final TerminalSession finishedSession) { - if (mSessionChangeCallback != null) - mSessionChangeCallback.onSessionFinished(finishedSession); + /** 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; } - @Override - public void onTextChanged(TerminalSession changedSession) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onTextChanged(changedSession); + /** 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 onClipboardText(TerminalSession session, String text) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onClipboardText(session, text); + 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(); } - @Override - public void onBell(TerminalSession session) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onBell(session); + + + + + 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; + } } - @Override - public void onColorsChanged(TerminalSession session) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onColorsChanged(session); + /** 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; + } } - public void onBackgroundJobExited(final BackgroundJob task) { - mHandler.post(() -> { - mBackgroundTasks.remove(task); - updateNotification(); - }); + /** 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; - String channelName = "Termux"; - String channelDescription = "Notifications from Termux"; - int importance = NotificationManager.IMPORTANCE_LOW; + NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); + } - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName,importance); - channel.setDescription(channelDescription); - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - manager.createNotificationChannel(channel); + /** 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; + } + } diff --git a/app/src/main/java/com/termux/app/TermuxViewClient.java b/app/src/main/java/com/termux/app/TermuxViewClient.java deleted file mode 100644 index 3dcc406c77..0000000000 --- a/app/src/main/java/com/termux/app/TermuxViewClient.java +++ /dev/null @@ -1,283 +0,0 @@ -package com.termux.app; - -import android.content.Context; -import android.media.AudioManager; -import android.view.Gravity; -import android.view.InputDevice; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.inputmethod.InputMethodManager; - -import com.termux.terminal.KeyHandler; -import com.termux.terminal.TerminalEmulator; -import com.termux.terminal.TerminalSession; -import com.termux.view.TerminalViewClient; - -import java.util.List; - -import androidx.drawerlayout.widget.DrawerLayout; - -public final class TermuxViewClient implements TerminalViewClient { - - final TermuxActivity mActivity; - - /** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */ - boolean mVirtualControlKeyDown, mVirtualFnKeyDown; - - public TermuxViewClient(TermuxActivity activity) { - this.mActivity = activity; - } - - @Override - public float onScale(float scale) { - if (scale < 0.9f || scale > 1.1f) { - boolean increase = scale > 1.f; - mActivity.changeFontSize(increase); - return 1.0f; - } - return scale; - } - - @Override - public void onSingleTapUp(MotionEvent e) { - InputMethodManager mgr = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); - mgr.showSoftInput(mActivity.mTerminalView, InputMethodManager.SHOW_IMPLICIT); - } - - @Override - public boolean shouldBackButtonBeMappedToEscape() { - return mActivity.mSettings.mBackIsEscape; - } - - @Override - public void copyModeChanged(boolean copyMode) { - // Disable drawer while copying. - mActivity.getDrawer().setDrawerLockMode(copyMode ? DrawerLayout.LOCK_MODE_LOCKED_CLOSED : DrawerLayout.LOCK_MODE_UNLOCKED); - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) { - if (handleVirtualKeys(keyCode, e, true)) return true; - - if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) { - mActivity.removeFinishedSession(currentSession); - return true; - } else if (e.isCtrlPressed() && e.isAltPressed()) { - // Get the unmodified code point: - int unicodeChar = e.getUnicodeChar(0); - - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) { - mActivity.switchToSession(true); - } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) { - mActivity.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 */) { - InputMethodManager imm = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); - } else if (unicodeChar == 'm'/* menu */) { - mActivity.mTerminalView.showContextMenu(); - } else if (unicodeChar == 'r'/* rename */) { - mActivity.renameSession(currentSession); - } else if (unicodeChar == 'c'/* create */) { - mActivity.addNewSession(false, null); - } else if (unicodeChar == 'u' /* urls */) { - mActivity.showUrlSelection(); - } else if (unicodeChar == 'v') { - mActivity.doPaste(); - } else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') { - // We also check for the shifted char here since shift may be required to produce '+', - // see https://github.com/termux/termux-api/issues/2 - mActivity.changeFontSize(true); - } else if (unicodeChar == '-') { - mActivity.changeFontSize(false); - } else if (unicodeChar >= '1' && unicodeChar <= '9') { - int num = unicodeChar - '1'; - TermuxService service = mActivity.mTermService; - if (service.getSessions().size() > num) - mActivity.switchToSession(service.getSessions().get(num)); - } - return true; - } - - return false; - - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent e) { - return handleVirtualKeys(keyCode, e, false); - } - - @Override - public boolean readControlKey() { - return (mActivity.mExtraKeysView != null && mActivity.mExtraKeysView.readSpecialButton(ExtraKeysView.SpecialButton.CTRL)) || mVirtualControlKeyDown; - } - - @Override - public boolean readAltKey() { - return (mActivity.mExtraKeysView != null && mActivity.mExtraKeysView.readSpecialButton(ExtraKeysView.SpecialButton.ALT)); - } - - @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.toggleShowExtraKeys(); - 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()) { - mActivity.removeFinishedSession(session); - return true; - } - - List shortcuts = mActivity.mSettings.shortcuts; - if (!shortcuts.isEmpty()) { - int codePointLowerCase = Character.toLowerCase(codePoint); - for (int i = shortcuts.size() - 1; i >= 0; i--) { - TermuxPreferences.KeyboardShortcut shortcut = shortcuts.get(i); - if (codePointLowerCase == shortcut.codePoint) { - switch (shortcut.shortcutAction) { - case TermuxPreferences.SHORTCUT_ACTION_CREATE_SESSION: - mActivity.addNewSession(false, null); - return true; - case TermuxPreferences.SHORTCUT_ACTION_PREVIOUS_SESSION: - mActivity.switchToSession(false); - return true; - case TermuxPreferences.SHORTCUT_ACTION_NEXT_SESSION: - mActivity.switchToSession(true); - return true; - case TermuxPreferences.SHORTCUT_ACTION_RENAME_SESSION: - mActivity.renameSession(mActivity.getCurrentTermSession()); - return true; - } - } - } - } - } - - return false; - } - - @Override - public boolean onLongPress(MotionEvent event) { - return 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.mSettings.mDisableVolumeVirtualKeys) { - 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; - } - - -} diff --git a/app/src/main/java/com/termux/app/TermuxHelpActivity.java b/app/src/main/java/com/termux/app/activities/HelpActivity.java similarity index 86% rename from app/src/main/java/com/termux/app/TermuxHelpActivity.java rename to app/src/main/java/com/termux/app/activities/HelpActivity.java index 0aa8a97ac0..a2e4d6a985 100644 --- a/app/src/main/java/com/termux/app/TermuxHelpActivity.java +++ b/app/src/main/java/com/termux/app/activities/HelpActivity.java @@ -1,6 +1,5 @@ -package com.termux.app; +package com.termux.app.activities; -import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; @@ -12,8 +11,12 @@ 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 TermuxHelpActivity extends Activity { +public final class HelpActivity extends AppCompatActivity { WebView mWebView; @@ -39,7 +42,7 @@ protected void onCreate(Bundle savedInstanceState) { mWebView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (url.startsWith("https://wiki.termux.com")) { + if (url.equals(TermuxConstants.TERMUX_WIKI_URL) || url.startsWith(TermuxConstants.TERMUX_WIKI_URL + "/")) { // Inline help. setContentView(progressLayout); return false; @@ -60,7 +63,7 @@ public void onPageFinished(WebView view, String url) { setContentView(mWebView); } }); - mWebView.loadUrl("https://wiki.termux.com/wiki/Main_Page"); + mWebView.loadUrl(TermuxConstants.TERMUX_WIKI_URL); } @Override diff --git a/app/src/main/java/com/termux/app/activities/SettingsActivity.java b/app/src/main/java/com/termux/app/activities/SettingsActivity.java new file mode 100644 index 0000000000..7ca7848335 --- /dev/null +++ b/app/src/main/java/com/termux/app/activities/SettingsActivity.java @@ -0,0 +1,169 @@ +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; + }); + } + } + } + +} diff --git a/app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java b/app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java new file mode 100644 index 0000000000..ca5c07407c --- /dev/null +++ b/app/src/main/java/com/termux/app/api/file/FileReceiverActivity.java @@ -0,0 +1,287 @@ +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(); + } + +} diff --git a/app/src/main/java/com/termux/app/event/SystemEventReceiver.java b/app/src/main/java/com/termux/app/event/SystemEventReceiver.java new file mode 100644 index 0000000000..efc710f312 --- /dev/null +++ b/app/src/main/java/com/termux/app/event/SystemEventReceiver.java @@ -0,0 +1,91 @@ +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()); + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java new file mode 100644 index 0000000000..8f4a17145b --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java @@ -0,0 +1,49 @@ +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; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java new file mode 100644 index 0000000000..a6f25c83dc --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java @@ -0,0 +1,49 @@ +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; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java new file mode 100644 index 0000000000..448b9c7baa --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java @@ -0,0 +1,49 @@ +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; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java new file mode 100644 index 0000000000..1e02880765 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java @@ -0,0 +1,49 @@ +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; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java new file mode 100644 index 0000000000..ded9c7010e --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java @@ -0,0 +1,49 @@ +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; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java new file mode 100644 index 0000000000..8afd568fdb --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java @@ -0,0 +1,155 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java new file mode 100644 index 0000000000..f8504f43fd --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java @@ -0,0 +1,82 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java new file mode 100644 index 0000000000..ff033fd369 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java @@ -0,0 +1,77 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java new file mode 100644 index 0000000000..908c6ebd89 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java @@ -0,0 +1,101 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java new file mode 100644 index 0000000000..1d815aa09a --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java @@ -0,0 +1,126 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java new file mode 100644 index 0000000000..7e2918392f --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java @@ -0,0 +1,101 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java new file mode 100644 index 0000000000..50823d540e --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java @@ -0,0 +1,101 @@ +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; + } + } + +} diff --git a/app/src/main/java/com/termux/app/models/UserAction.java b/app/src/main/java/com/termux/app/models/UserAction.java new file mode 100644 index 0000000000..1e82255e99 --- /dev/null +++ b/app/src/main/java/com/termux/app/models/UserAction.java @@ -0,0 +1,18 @@ +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; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java b/app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java new file mode 100644 index 0000000000..7e8b0e9d38 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java @@ -0,0 +1,284 @@ +package com.termux.app.terminal; + +import android.content.Context; +import android.graphics.Rect; +import android.inputmethodservice.InputMethodService; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.view.inputmethod.EditorInfo; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.core.view.WindowInsetsCompat; + +import com.termux.app.TermuxActivity; +import com.termux.shared.logger.Logger; +import com.termux.shared.view.ViewUtils; + + +/** + * The {@link TermuxActivity} relies on {@link android.view.WindowManager.LayoutParams#SOFT_INPUT_ADJUST_RESIZE)} + * set by {@link TermuxTerminalViewClient#setSoftKeyboardState(boolean, boolean)} to automatically + * resize the view and push the terminal up when soft keyboard is opened. However, this does not + * always work properly. When `enforce-char-based-input=true` is set in `termux.properties` + * and {@link com.termux.view.TerminalView#onCreateInputConnection(EditorInfo)} sets the inputType + * to `InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS` + * instead of the default `InputType.TYPE_NULL` for termux, some keyboards may still show suggestions. + * Gboard does too, but only when text is copied and clipboard suggestions **and** number keys row + * toggles are enabled in its settings. When number keys row toggle is not enabled, Gboard will still + * show the row but will switch it with suggestions if needed. If its enabled, then number keys row + * is always shown and suggestions are shown in an additional row on top of it. This additional row is likely + * part of the candidates view returned by the keyboard app in {@link InputMethodService#onCreateCandidatesView()}. + * + * With the above configuration, the additional clipboard suggestions row partially covers the + * extra keys/terminal. Reopening the keyboard/activity does not fix the issue. This is either a bug + * in the Android OS where it does not consider the candidate's view height in its calculation to push + * up the view or because Gboard does not include the candidate's view height in the height reported + * to android that should be used, hence causing an overlap. + * + * Gboard logs the following entry to `logcat` when its opened with or without the suggestions bar showing: + * I/KeyboardViewUtil: KeyboardViewUtil.calculateMaxKeyboardBodyHeight():62 leave 500 height for app when screen height:2392, header height:176 and isFullscreenMode:false, so the max keyboard body height is:1716 + * where `keyboard_height = screen_height - height_for_app - header_height` (62 is a hardcoded value in Gboard source code and may be a version number) + * So this may in fact be due to Gboard but https://stackoverflow.com/questions/57567272 suggests + * otherwise. Another similar report https://stackoverflow.com/questions/66761661. + * Also check https://github.com/termux/termux-app/issues/1539. + * + * This overlap may happen even without `enforce-char-based-input=true` for keyboards with extended layouts + * like number row, etc. + * + * To fix these issues, `activity_termux.xml` has the constant 1sp transparent + * `activity_termux_bottom_space_view` View at the bottom. This will appear as a line matching the + * activity theme. When {@link TermuxActivity} {@link ViewTreeObserver.OnGlobalLayoutListener} is + * called when any of the sub view layouts change, like keyboard opening/closing keyboard, + * extra keys/input view switched, etc, we check if the bottom space view is visible or not. + * If its not, then we add a margin to the bottom of the root view, so that the keyboard does not + * overlap the extra keys/terminal, since the margin will push up the view. By default the margin + * added is equal to the height of the hidden part of extra keys/terminal. For Gboard's case, the + * hidden part equals the `header_height`. The updates to margins may cause a jitter in some cases + * when the view is redrawn if the margin is incorrect, but logic has been implemented to avoid that. + */ +public class TermuxActivityRootView extends LinearLayout implements ViewTreeObserver.OnGlobalLayoutListener { + + public TermuxActivity mActivity; + public Integer marginBottom; + public Integer lastMarginBottom; + public long lastMarginBottomTime; + public long lastMarginBottomExtraTime; + + /** Log root view events. */ + private boolean ROOT_VIEW_LOGGING_ENABLED = false; + + private static final String LOG_TAG = "TermuxActivityRootView"; + + private static int mStatusBarHeight; + + public TermuxActivityRootView(Context context) { + super(context); + } + + public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setActivity(TermuxActivity activity) { + mActivity = activity; + } + + /** + * Sets whether root view logging is enabled or not. + * + * @param value The boolean value that defines the state. + */ + public void setIsRootViewLoggingEnabled(boolean value) { + ROOT_VIEW_LOGGING_ENABLED = value; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (marginBottom != null) { + if (ROOT_VIEW_LOGGING_ENABLED) + Logger.logVerbose(LOG_TAG, "onMeasure: Setting bottom margin to " + marginBottom); + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams(); + params.setMargins(0, 0, 0, marginBottom); + setLayoutParams(params); + marginBottom = null; + requestLayout(); + } + } + + @Override + public void onGlobalLayout() { + if (mActivity == null || !mActivity.isVisible()) return; + + View bottomSpaceView = mActivity.getTermuxActivityBottomSpaceView(); + if (bottomSpaceView == null) return; + + boolean root_view_logging_enabled = ROOT_VIEW_LOGGING_ENABLED; + + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, ":\nonGlobalLayout:"); + + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) getLayoutParams(); + + // Get the position Rects of the bottom space view and the main window holding it + Rect[] windowAndViewRects = ViewUtils.getWindowAndViewRects(bottomSpaceView, mStatusBarHeight); + if (windowAndViewRects == null) + return; + + Rect windowAvailableRect = windowAndViewRects[0]; + Rect bottomSpaceViewRect = windowAndViewRects[1]; + + // If the bottomSpaceViewRect is inside the windowAvailableRect, then it must be completely visible + //boolean isVisible = windowAvailableRect.contains(bottomSpaceViewRect); // rect.right comparison often fails in landscape + boolean isVisible = ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect); + boolean isVisibleBecauseMargin = (windowAvailableRect.bottom == bottomSpaceViewRect.bottom) && params.bottomMargin > 0; + boolean isVisibleBecauseExtraMargin = ((bottomSpaceViewRect.bottom - windowAvailableRect.bottom) < 0); + + if (root_view_logging_enabled) { + Logger.logVerbose(LOG_TAG, "windowAvailableRect " + ViewUtils.toRectString(windowAvailableRect) + ", bottomSpaceViewRect " + ViewUtils.toRectString(bottomSpaceViewRect)); + Logger.logVerbose(LOG_TAG, "windowAvailableRect.bottom " + windowAvailableRect.bottom + + ", bottomSpaceViewRect.bottom " +bottomSpaceViewRect.bottom + + ", diff " + (bottomSpaceViewRect.bottom - windowAvailableRect.bottom) + ", bottom " + params.bottomMargin + + ", isVisible " + windowAvailableRect.contains(bottomSpaceViewRect) + ", isRectAbove " + ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect) + + ", isVisibleBecauseMargin " + isVisibleBecauseMargin + ", isVisibleBecauseExtraMargin " + isVisibleBecauseExtraMargin); + } + + // If the bottomSpaceViewRect is visible, then remove the margin if needed + if (isVisible) { + // If visible because of margin, i.e the bottom of bottomSpaceViewRect equals that of windowAvailableRect + // and a margin has been added + // Necessary so that we don't get stuck in an infinite loop since setting margin + // will call OnGlobalLayoutListener again and next time bottom space view + // will be visible and margin will be set to 0, which again will call + // OnGlobalLayoutListener... + // Calling addTermuxActivityRootViewGlobalLayoutListener with a delay fails to + // set appropriate margins when views are changed quickly since some changes + // may be missed. + if (isVisibleBecauseMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Visible due to margin"); + + // Once the view has been redrawn with new margin, we set margin back to 0 so that + // when next time onMeasure() is called, margin 0 is used. This is necessary for + // cases when view has been redrawn with new margin because bottom space view was + // hidden by keyboard and then view was redrawn again due to layout change (like + // keyboard symbol view is switched to), android will add margin below its new position + // if its greater than 0, which was already above the keyboard creating x2x margin. + // Adding time check since moving split screen divider in landscape causes jitter + // and prevents some infinite loops + if ((System.currentTimeMillis() - lastMarginBottomTime) > 40) { + lastMarginBottomTime = System.currentTimeMillis(); + marginBottom = 0; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Ignoring restoring marginBottom to 0 since called to quickly"); + } + + return; + } + + boolean setMargin = params.bottomMargin != 0; + + // If visible because of extra margin, i.e the bottom of bottomSpaceViewRect is above that of windowAvailableRect + // onGlobalLayout: windowAvailableRect 1408, bottomSpaceViewRect 1232, diff -176, bottom 0, isVisible true, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false + // onGlobalLayout: Bottom margin already equals 0 + if (isVisibleBecauseExtraMargin) { + // Adding time check since prevents infinite loops, like in landscape mode in freeform mode in Taskbar + if ((System.currentTimeMillis() - lastMarginBottomExtraTime) > 40) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Resetting margin since visible due to extra margin"); + lastMarginBottomExtraTime = System.currentTimeMillis(); + // lastMarginBottom must be invalid. May also happen when keyboards are changed. + lastMarginBottom = null; + setMargin = true; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Ignoring resetting margin since visible due to extra margin since called to quickly"); + } + } + + if (setMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Setting bottom margin to 0"); + params.setMargins(0, 0, 0, 0); + setLayoutParams(params); + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Bottom margin already equals 0"); + // This is done so that when next time onMeasure() is called, lastMarginBottom is used. + // This is done since we **expect** the keyboard to have same dimensions next time layout + // changes, so best set margin while view is drawn the first time, otherwise it will + // cause a jitter when OnGlobalLayoutListener is called with margin 0 and it sets the + // likely same lastMarginBottom again and requesting a redraw. Hopefully, this logic + // works fine for all cases. + marginBottom = lastMarginBottom; + } + } + // ELse find the part of the extra keys/terminal that is hidden and add a margin accordingly + else { + int pxHidden = bottomSpaceViewRect.bottom - windowAvailableRect.bottom; + + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "pxHidden " + pxHidden + ", bottom " + params.bottomMargin); + + boolean setMargin = params.bottomMargin != pxHidden; + + // If invisible despite margin, i.e a margin was added, but the bottom of bottomSpaceViewRect + // is still below that of windowAvailableRect, this will trigger OnGlobalLayoutListener + // again, so that margins are set properly. May happen when toolbar/extra keys is disabled + // and enabled from left drawer, just like case for isVisibleBecauseExtraMargin. + // onMeasure: Setting bottom margin to 176 + // onGlobalLayout: windowAvailableRect 1232, bottomSpaceViewRect 1408, diff 176, bottom 176, isVisible false, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false + // onGlobalLayout: Bottom margin already equals 176 + if (pxHidden > 0 && params.bottomMargin > 0) { + if (pxHidden != params.bottomMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since not visible due to wrong margin"); + pxHidden = 0; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Force setting margin since not visible despite required margin"); + } + setMargin = true; + } + + if (pxHidden < 0) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since new margin is negative"); + pxHidden = 0; + } + + + if (setMargin) { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Setting bottom margin to " + pxHidden); + params.setMargins(0, 0, 0, pxHidden); + setLayoutParams(params); + lastMarginBottom = pxHidden; + } else { + if (root_view_logging_enabled) + Logger.logVerbose(LOG_TAG, "Bottom margin already equals " + pxHidden); + } + } + } + + public static class WindowInsetsListener implements View.OnApplyWindowInsetsListener { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mStatusBarHeight = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.statusBars()).top; + // Let view window handle insets however it wants + return v.onApplyWindowInsets(insets); + } + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java new file mode 100644 index 0000000000..bf914b977b --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -0,0 +1,109 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; +import com.termux.shared.theme.NightMode; +import com.termux.shared.theme.ThemeUtils; +import com.termux.terminal.TerminalSession; + +import java.util.List; + +public class TermuxSessionsListViewController extends ArrayAdapter implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { + + final TermuxActivity mActivity; + + final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); + final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC); + + public TermuxSessionsListViewController(TermuxActivity activity, List sessionList) { + super(activity.getApplicationContext(), R.layout.item_terminal_sessions_list, sessionList); + this.mActivity = activity; + } + + @SuppressLint("SetTextI18n") + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + View sessionRowView = convertView; + if (sessionRowView == null) { + LayoutInflater inflater = mActivity.getLayoutInflater(); + sessionRowView = inflater.inflate(R.layout.item_terminal_sessions_list, parent, false); + } + + TextView sessionTitleView = sessionRowView.findViewById(R.id.session_title); + + TerminalSession sessionAtRow = getItem(position).getTerminalSession(); + if (sessionAtRow == null) { + sessionTitleView.setText("null session"); + return sessionRowView; + } + + boolean shouldEnableDarkTheme = ThemeUtils.shouldEnableDarkTheme(mActivity, NightMode.getAppNightMode().getName()); + + if (shouldEnableDarkTheme) { + sessionTitleView.setBackground( + ContextCompat.getDrawable(mActivity, R.drawable.session_background_black_selected) + ); + } + + String name = sessionAtRow.mSessionName; + String sessionTitle = sessionAtRow.getTitle(); + + String numberPart = "[" + (position + 1) + "] "; + String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name); + String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle)); + + String fullSessionTitle = numberPart + sessionNamePart + sessionTitlePart; + SpannableString fullSessionTitleStyled = new SpannableString(fullSessionTitle); + fullSessionTitleStyled.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + fullSessionTitleStyled.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), fullSessionTitle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + sessionTitleView.setText(fullSessionTitleStyled); + + boolean sessionRunning = sessionAtRow.isRunning(); + + if (sessionRunning) { + sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); + } else { + sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + } + int defaultColor = shouldEnableDarkTheme ? Color.WHITE : Color.BLACK; + int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; + sessionTitleView.setTextColor(color); + return sessionRowView; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + TermuxSession clickedSession = getItem(position); + mActivity.getTermuxTerminalSessionClient().setCurrentSession(clickedSession.getTerminalSession()); + mActivity.getDrawer().closeDrawers(); + } + + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { + final TermuxSession selectedSession = getItem(position); + mActivity.getTermuxTerminalSessionClient().renameSession(selectedSession.getTerminalSession()); + return true; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java new file mode 100644 index 0000000000..cd38163116 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java @@ -0,0 +1,528 @@ +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, 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]); + } + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java new file mode 100644 index 0000000000..e943bb3ad1 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java @@ -0,0 +1,31 @@ +package com.termux.app.terminal; + +import android.app.Service; + +import androidx.annotation.NonNull; + +import com.termux.app.TermuxService; +import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession; +import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; + +/** The {@link TerminalSessionClient} implementation that may require a {@link Service} for its interface methods. */ +public class TermuxTerminalSessionServiceClient extends TermuxTerminalSessionClientBase { + + private static final String LOG_TAG = "TermuxTerminalSessionServiceClient"; + + private final TermuxService mService; + + public TermuxTerminalSessionServiceClient(TermuxService service) { + this.mService = service; + } + + @Override + public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) { + TermuxSession termuxSession = mService.getTermuxSessionForTerminalSession(terminalSession); + if (termuxSession != null) + termuxSession.getExecutionCommand().mPid = pid; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java new file mode 100644 index 0000000000..a3d09d3d52 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -0,0 +1,802 @@ +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(R.string.action_yes), (dialog, which) -> reportIssueFromTranscript(transcriptText, true), + mActivity.getString(R.string.action_no), (dialog, which) -> reportIssueFromTranscript(transcriptText, false), + null); + } + + private void reportIssueFromTranscript(String transcriptText, boolean addTermuxDebugInfo) { + Logger.showToast(mActivity, mActivity.getString(R.string.msg_generating_report), true); + + new Thread() { + @Override + public void run() { + StringBuilder reportString = new StringBuilder(); + + String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue"; + + reportString.append("## Transcript\n"); + reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true)); + reportString.append("\n##\n"); + + if (addTermuxDebugInfo) { + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES)); + } else { + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_PACKAGE)); + } + + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(mActivity, true)); + + if (TermuxBootstrap.isAppPackageManagerAPT()) { + String termuxAptInfo = TermuxUtils.geAPTInfoMarkdownString(mActivity); + if (termuxAptInfo != null) + reportString.append("\n\n").append(termuxAptInfo); + } + + if (addTermuxDebugInfo) { + String termuxDebugInfo = TermuxUtils.getTermuxDebugMarkdownString(mActivity); + if (termuxDebugInfo != null) + reportString.append("\n\n").append(termuxDebugInfo); + } + + String userActionName = UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName(); + + ReportInfo reportInfo = new ReportInfo(userActionName, + TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title); + reportInfo.setReportString(reportString.toString()); + reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity)); + reportInfo.setReportSaveFileLabelAndPath(userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)); + + ReportActivity.startReportActivity(mActivity, reportInfo); + } + }.start(); + } + + public void doPaste() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + if (!session.isRunning()) return; + + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + session.getEmulator().paste(text); + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java new file mode 100644 index 0000000000..c01f8994d8 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java @@ -0,0 +1,68 @@ +package com.termux.app.terminal.io; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import com.termux.app.TermuxActivity; + +/** + * Work around for fullscreen mode in Termux to fix ExtraKeysView not being visible. + * This class is derived from: + * https://stackoverflow.com/questions/7417123/android-how-to-adjust-layout-in-full-screen-mode-when-softkeyboard-is-visible + * and has some additional tweaks + * --- + * For more information, see https://issuetracker.google.com/issues/36911528 + */ +public class FullScreenWorkAround { + private final View mChildOfContent; + private int mUsableHeightPrevious; + private final ViewGroup.LayoutParams mViewGroupLayoutParams; + + private final int mNavBarHeight; + + + public static void apply(TermuxActivity activity) { + new FullScreenWorkAround(activity); + } + + private FullScreenWorkAround(TermuxActivity activity) { + ViewGroup content = activity.findViewById(android.R.id.content); + mChildOfContent = content.getChildAt(0); + mViewGroupLayoutParams = mChildOfContent.getLayoutParams(); + mNavBarHeight = activity.getNavBarHeight(); + mChildOfContent.getViewTreeObserver().addOnGlobalLayoutListener(this::possiblyResizeChildOfContent); + } + + private void possiblyResizeChildOfContent() { + int usableHeightNow = computeUsableHeight(); + if (usableHeightNow != mUsableHeightPrevious) { + int usableHeightSansKeyboard = mChildOfContent.getRootView().getHeight(); + int heightDifference = usableHeightSansKeyboard - usableHeightNow; + if (heightDifference > (usableHeightSansKeyboard / 4)) { + // keyboard probably just became visible + + // ensures that usable layout space does not extend behind the + // soft keyboard, causing the extra keys to not be visible + mViewGroupLayoutParams.height = (usableHeightSansKeyboard - heightDifference) + getNavBarHeight(); + } else { + // keyboard probably just became hidden + mViewGroupLayoutParams.height = usableHeightSansKeyboard; + } + mChildOfContent.requestLayout(); + mUsableHeightPrevious = usableHeightNow; + } + } + + private int getNavBarHeight() { + return mNavBarHeight; + } + + private int computeUsableHeight() { + Rect r = new Rect(); + mChildOfContent.getWindowVisibleDisplayFrame(r); + return (r.bottom - r.top); + } + +} + diff --git a/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java new file mode 100644 index 0000000000..00a832dd6a --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java @@ -0,0 +1,13 @@ +package com.termux.app.terminal.io; + +public class KeyboardShortcut { + + public final int codePoint; + public final int shortcutAction; + + public KeyboardShortcut(int codePoint, int shortcutAction) { + this.codePoint = codePoint; + this.shortcutAction = shortcutAction; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java new file mode 100644 index 0000000000..a526570b09 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -0,0 +1,117 @@ +package com.termux.app.terminal.io; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.shared.termux.extrakeys.ExtraKeysView; +import com.termux.terminal.TerminalSession; + +public class TerminalToolbarViewPager { + + public static class PageAdapter extends PagerAdapter { + + final TermuxActivity mActivity; + String mSavedTextInput; + + public PageAdapter(TermuxActivity activity, String savedTextInput) { + this.mActivity = activity; + this.mSavedTextInput = savedTextInput; + } + + @Override + public int getCount() { + return 2; + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return view == object; + } + + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup collection, int position) { + LayoutInflater inflater = LayoutInflater.from(mActivity); + View layout; + if (position == 0) { + layout = inflater.inflate(R.layout.view_terminal_toolbar_extra_keys, collection, false); + ExtraKeysView extraKeysView = (ExtraKeysView) layout; + extraKeysView.setExtraKeysViewClient(mActivity.getTermuxTerminalExtraKeys()); + extraKeysView.setButtonTextAllCaps(mActivity.getProperties().shouldExtraKeysTextBeAllCaps()); + mActivity.setExtraKeysView(extraKeysView); + extraKeysView.reload(mActivity.getTermuxTerminalExtraKeys().getExtraKeysInfo(), + mActivity.getTerminalToolbarDefaultHeight()); + + // apply extra keys fix if enabled in prefs + if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) { + FullScreenWorkAround.apply(mActivity); + } + + } else { + layout = inflater.inflate(R.layout.view_terminal_toolbar_text_input, collection, false); + final EditText editText = layout.findViewById(R.id.terminal_toolbar_text_input); + + if (mSavedTextInput != null) { + editText.setText(mSavedTextInput); + mSavedTextInput = null; + } + + editText.setOnEditorActionListener((v, actionId, event) -> { + TerminalSession session = mActivity.getCurrentSession(); + if (session != null) { + if (session.isRunning()) { + String textToSend = editText.getText().toString(); + if (textToSend.length() == 0) textToSend = "\r"; + session.write(textToSend); + } else { + mActivity.getTermuxTerminalSessionClient().removeFinishedSession(session); + } + editText.setText(""); + } + return true; + }); + } + collection.addView(layout); + return layout; + } + + @Override + public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { + collection.removeView((View) view); + } + + } + + + + public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeListener { + + final TermuxActivity mActivity; + final ViewPager mTerminalToolbarViewPager; + + public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) { + this.mActivity = activity; + this.mTerminalToolbarViewPager = viewPager; + } + + @Override + public void onPageSelected(int position) { + if (position == 0) { + mActivity.getTerminalView().requestFocus(); + } else { + final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input); + if (editText != null) editText.requestFocus(); + } + } + + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java b/app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java new file mode 100644 index 0000000000..a38e8cbbc8 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java @@ -0,0 +1,108 @@ +package com.termux.app.terminal.io; + +import android.annotation.SuppressLint; +import android.view.Gravity; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.drawerlayout.widget.DrawerLayout; + +import com.termux.app.TermuxActivity; +import com.termux.app.terminal.TermuxTerminalSessionActivityClient; +import com.termux.app.terminal.TermuxTerminalViewClient; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.extrakeys.ExtraKeysConstants; +import com.termux.shared.termux.extrakeys.ExtraKeysInfo; +import com.termux.shared.termux.settings.properties.TermuxPropertyConstants; +import com.termux.shared.termux.settings.properties.TermuxSharedProperties; +import com.termux.shared.termux.terminal.io.TerminalExtraKeys; +import com.termux.view.TerminalView; + +import org.json.JSONException; + +public class TermuxTerminalExtraKeys extends TerminalExtraKeys { + + private ExtraKeysInfo mExtraKeysInfo; + + final TermuxActivity mActivity; + final TermuxTerminalViewClient mTermuxTerminalViewClient; + final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; + + private static final String LOG_TAG = "TermuxTerminalExtraKeys"; + + public TermuxTerminalExtraKeys(TermuxActivity activity, @NonNull TerminalView terminalView, + TermuxTerminalViewClient termuxTerminalViewClient, + TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) { + super(terminalView); + + mActivity = activity; + mTermuxTerminalViewClient = termuxTerminalViewClient; + mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient; + + setExtraKeys(); + } + + + /** + * Set the terminal extra keys and style. + */ + private void setExtraKeys() { + mExtraKeysInfo = null; + + try { + // The mMap stores the extra key and style string values while loading properties + // Check {@link #getExtraKeysInternalPropertyValueFromValue(String)} and + // {@link #getExtraKeysStyleInternalPropertyValueFromValue(String)} + String extrakeys = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true); + String extraKeysStyle = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true); + + ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap = ExtraKeysInfo.getCharDisplayMapForStyle(extraKeysStyle); + if (ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY.equals(extraKeyDisplayMap) && !TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE.equals(extraKeysStyle)) { + Logger.logError(TermuxSharedProperties.LOG_TAG, "The style \"" + extraKeysStyle + "\" for the key \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE + "\" is invalid. Using default style instead."); + extraKeysStyle = TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE; + } + + mExtraKeysInfo = new ExtraKeysInfo(extrakeys, extraKeysStyle, ExtraKeysConstants.CONTROL_CHARS_ALIASES); + } catch (JSONException e) { + Logger.showToast(mActivity, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: " + e.toString(), true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: ", e); + + try { + mExtraKeysInfo = new ExtraKeysInfo(TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE, ExtraKeysConstants.CONTROL_CHARS_ALIASES); + } catch (JSONException e2) { + Logger.showToast(mActivity, "Can't create default extra keys",true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e); + mExtraKeysInfo = null; + } + } + } + + public ExtraKeysInfo getExtraKeysInfo() { + return mExtraKeysInfo; + } + + @SuppressLint("RtlHardcoded") + @Override + public void onTerminalExtraKeyButtonClick(View view, String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) { + if ("KEYBOARD".equals(key)) { + if(mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onToggleSoftKeyboardRequest(); + } else if ("DRAWER".equals(key)) { + DrawerLayout drawerLayout = mTermuxTerminalViewClient.getActivity().getDrawer(); + if (drawerLayout.isDrawerOpen(Gravity.LEFT)) + drawerLayout.closeDrawer(Gravity.LEFT); + else + drawerLayout.openDrawer(Gravity.LEFT); + } else if ("PASTE".equals(key)) { + if(mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onPasteTextFromClipboard(null); + } else if ("SCROLL".equals(key)) { + TerminalView terminalView = mTermuxTerminalViewClient.getActivity().getTerminalView(); + if (terminalView != null && terminalView.mEmulator != null) + terminalView.mEmulator.toggleAutoScrollDisabled(); + } else { + super.onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown); + } + } + +} diff --git a/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java b/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java index 6a84904033..7974d6dbc1 100644 --- a/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java +++ b/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java @@ -12,7 +12,7 @@ import android.webkit.MimeTypeMap; import com.termux.R; -import com.termux.app.TermuxService; +import com.termux.shared.termux.TermuxConstants; import java.io.File; import java.io.FileNotFoundException; @@ -22,7 +22,7 @@ /** * A document provider for the Storage Access Framework which exposes the files in the - * $HOME/ folder to other apps. + * $HOME/ directory to other apps. *

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

@@ -35,7 +35,7 @@ public class TermuxDocumentsProvider extends DocumentsProvider { private static final String ALL_MIME_TYPES = "*/*"; - private static final File BASE_DIR = new File(TermuxService.HOME_PATH); + private static final File BASE_DIR = TermuxConstants.TERMUX_HOME_DIR; // The default columns to return information about a root if no specific @@ -63,9 +63,9 @@ public class TermuxDocumentsProvider extends DocumentsProvider { }; @Override - public Cursor queryRoots(String[] projection) throws FileNotFoundException { + public Cursor queryRoots(String[] projection) { final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); - @SuppressWarnings("ConstantConditions") final String applicationName = getContext().getString(R.string.application_name); + final String applicationName = getContext().getString(R.string.application_name); final MatrixCursor.RowBuilder row = result.newRow(); row.add(Root.COLUMN_ROOT_ID, getDocIdForFile(BASE_DIR)); @@ -75,7 +75,7 @@ public Cursor queryRoots(String[] projection) throws FileNotFoundException { 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.drawable.ic_launcher); + row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher); return result; } @@ -91,9 +91,7 @@ public Cursor queryChildDocuments(String parentDocumentId, String[] projection, final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); final File parent = getFileForDocId(parentDocumentId); for (File file : parent.listFiles()) { - if (!file.getName().startsWith(".")) { - includeFile(result, null, file); - } + includeFile(result, null, file); } return result; } @@ -169,16 +167,15 @@ public Cursor querySearchDocuments(String rootId, String query, String[] project final int MAX_SEARCH_RESULTS = 50; while (!pending.isEmpty() && result.getCount() < MAX_SEARCH_RESULTS) { final File file = pending.removeFirst(); - // Avoid folders outside the $HOME folders linked in to symlinks (to avoid e.g. search + // 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(TermuxService.HOME_PATH); + isInsideHome = file.getCanonicalPath().startsWith(TermuxConstants.TERMUX_HOME_DIR_PATH); } catch (IOException e) { isInsideHome = true; } - final boolean isHidden = file.getName().startsWith("."); - if (isInsideHome && !isHidden) { + if (isInsideHome) { if (file.isDirectory()) { Collections.addAll(pending, file.listFiles()); } else { @@ -265,7 +262,7 @@ private void includeFile(MatrixCursor result, String docId, File file) 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.drawable.ic_launcher); + row.add(Document.COLUMN_ICON, R.mipmap.ic_launcher); } } diff --git a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java deleted file mode 100644 index e1ef5d423f..0000000000 --- a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.termux.filepicker; - -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.provider.OpenableColumns; -import android.util.Log; -import android.util.Patterns; - -import com.termux.R; -import com.termux.app.DialogUtils; -import com.termux.app.TermuxService; - -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 TermuxFileReceiverActivity extends Activity { - - static final String TERMUX_RECEIVEDIR = TermuxService.FILES_PATH + "/home/downloads"; - static final String EDITOR_PROGRAM = TermuxService.HOME_PATH + "/bin/termux-file-editor"; - static final String URL_OPENER_PROGRAM = TermuxService.HOME_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; - - static boolean isSharedTextAnUrl(String sharedText) { - return Patterns.WEB_URL.matcher(sharedText).matches() - || Pattern.matches("magnet:\\?xt=urn:btih:.*?", sharedText); - } - - @Override - protected void onResume() { - super.onResume(); - - final Intent intent = getIntent(); - final String action = intent.getAction(); - final String type = intent.getType(); - final String scheme = intent.getScheme(); - - 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 (sharedText != null) { - if (isSharedTextAnUrl(sharedText)) { - handleUrlAndFinish(sharedText); - } else { - String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); - if (subject == null) subject = intent.getStringExtra(Intent.EXTRA_TITLE); - if (subject != null) subject += ".txt"; - promptNameAndSave(new ByteArrayInputStream(sharedText.getBytes(StandardCharsets.UTF_8)), subject); - } - } else if (sharedUri != null) { - handleContentUri(sharedUri, intent.getStringExtra(Intent.EXTRA_TITLE)); - } else { - showErrorDialogAndQuit("Send action without content - nothing to save."); - } - } else if ("content".equals(scheme)) { - handleContentUri(intent.getData(), intent.getStringExtra(Intent.EXTRA_TITLE)); - } else if ("file".equals(scheme)) { - // When e.g. clicking on a downloaded apk: - String path = intent.getData().getPath(); - 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; - new AlertDialog.Builder(this).setMessage(message).setOnDismissListener(dialog -> finish()).setPositiveButton(android.R.string.ok, (dialog, which) -> finish()).show(); - } - - void handleContentUri(final Uri uri, String subjectFromIntent) { - try { - 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; - - InputStream in = getContentResolver().openInputStream(uri); - promptNameAndSave(in, attachmentFileName); - } catch (Exception e) { - showErrorDialogAndQuit("Unable to handle shared content:\n\n" + e.getMessage()); - Log.e("termux", "handleContentUri(uri=" + uri + ") failed", e); - } - } - - void promptNameAndSave(final InputStream in, final String attachmentFileName) { - DialogUtils.textInput(this, R.string.file_received_title, attachmentFileName, R.string.file_received_edit_button, 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 = new Uri.Builder().scheme("file").path(EDITOR_PROGRAM).build(); - - Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE, scriptUri); - executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class); - executeIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()}); - startService(executeIntent); - finish(); - }, - R.string.file_received_open_folder_button, text -> { - if (saveStreamWithName(in, text) == null) return; - - Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE); - executeIntent.putExtra(TermuxService.EXTRA_CURRENT_WORKING_DIRECTORY, TERMUX_RECEIVEDIR); - executeIntent.setClass(TermuxFileReceiverActivity.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 (!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); - Log.e("termux", "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 only argument."); - return; - } - - // Do this for the user if necessary: - //noinspection ResultOfMethodCallIgnored - urlOpenerProgramFile.setExecutable(true); - - final Uri urlOpenerProgramUri = new Uri.Builder().scheme("file").path(URL_OPENER_PROGRAM).build(); - - Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE, urlOpenerProgramUri); - executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class); - executeIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, new String[]{url}); - startService(executeIntent); - finish(); - } - -} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index 749a55bc5d..0000000000 --- a/app/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_service_notification.xml b/app/src/main/res/drawable/ic_service_notification.xml index 0fe78b4cee..205dcfc0c3 100644 --- a/app/src/main/res/drawable/ic_service_notification.xml +++ b/app/src/main/res/drawable/ic_service_notification.xml @@ -1,40 +1,24 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> - - + + - - + - + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000000..e5d1108b0f --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/selected_session_background_black.xml b/app/src/main/res/drawable/session_background_black_selected.xml similarity index 100% rename from app/src/main/res/drawable/selected_session_background_black.xml rename to app/src/main/res/drawable/session_background_black_selected.xml diff --git a/app/src/main/res/drawable/selected_session_background.xml b/app/src/main/res/drawable/session_background_selected.xml similarity index 100% rename from app/src/main/res/drawable/selected_session_background.xml rename to app/src/main/res/drawable/session_background_selected.xml diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000000..d3914191e7 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_termux.xml b/app/src/main/res/layout/activity_termux.xml new file mode 100644 index 0000000000..831ea7cfb8 --- /dev/null +++ b/app/src/main/res/layout/activity_termux.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/drawer_layout.xml b/app/src/main/res/layout/drawer_layout.xml deleted file mode 100644 index c5117f14db..0000000000 --- a/app/src/main/res/layout/drawer_layout.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - -