diff --git a/.gitattributes b/.gitattributes index 21c2769ab9..253738449b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ * text=auto -*.bat eol=crlf -*.gradle eol=lf -*.mk eol=lf -*.sh eol=lf +*.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 index 7871554d6e..c7fc532b31 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1 @@ -patreon: termux -custom: https://paypal.me/fornwall +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 0439523ebf..a1df15ae15 100644 --- a/.github/workflows/debug_build.yml +++ b/.github/workflows/debug_build.yml @@ -4,23 +4,124 @@ on: push: branches: - master - - android-10 + - 'github-releases/**' pull_request: branches: - master - - android-10 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@v2 - with: - name: termux-app - path: ./app/build/outputs/apk/debug + - 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 7c8edafc63..b9306a157b 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -15,5 +15,5 @@ jobs: 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/publish_libraries.yml b/.github/workflows/publish_libraries.yml deleted file mode 100644 index 077539deba..0000000000 --- a/.github/workflows/publish_libraries.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Publish library packages - -on: - push: - branches: - - master - paths: - - 'terminal-emulator/build.gradle' - - 'terminal-view/build.gradle' - - 'termux-shared/build.gradle' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Clone repository - uses: actions/checkout@v2 - - name: Perform release build - run: | - ./gradlew assembleRelease - - name: Publish libraries on Github Packages - env: - GH_USERNAME: xeffyr - GH_TOKEN: ${{ secrets.GH_TOKEN }} - run: | - ./gradlew publish diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 2647499554..f8d33fd4fc 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -15,7 +15,7 @@ jobs: 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 0eecba0068..8233bf4ef6 100644 --- a/README.md +++ b/README.md @@ -3,68 +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). -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.** -**@termux is looking for Termux Application maintainer for implementing new features, -fixing bugs and reviewing pull requests since current one (@fornwall) is inactive.** +*** -Issue https://github.com/termux/termux-app/issues/1072 needs extra attention. +**NOTICE: Termux may be unstable on Android 12+.** Android OS will kill any (phantom) processes greater than 32 (limit is for all apps combined) and also kill any processes using excessive CPU. You may get `[Process completed (signal 9) - press Enter]` message in the terminal without actually exiting the shell process yourself. Check the related issue [#2366](https://github.com/termux/termux-app/issues/2366), [issue tracker](https://issuetracker.google.com/u/1/issues/205156966), [phantom cached and empty processes docs](https://github.com/agnostic-apollo/Android-Docs/blob/master/en/docs/apps/processes/phantom-cached-and-empty-processes.md) and [this TLDR comment](https://github.com/termux/termux-app/issues/2366#issuecomment-1237468220) on how to disable trimming of phantom and excessive cpu usage processes. A proper docs page will be added later. An option to disable the killing should be available in Android 12L or 13, so upgrade at your own risk if you are on Android 11, specially if you are not rooted. *** +## Contents +- [Termux App and Plugins](#termux-app-and-plugins) +- [Installation](#installation) +- [Uninstallation](#uninstallation) +- [Important Links](#important-links) +- [Debugging](#debugging) +- [For Maintainers and Contributors](#for-maintainers-and-contributors) +- [Forking](#forking) +- [Sponsors and Funders](#sponsors-and-funders) +## + + + +## Termux App and Plugins + +The core [Termux](https://github.com/termux/termux-app) app comes with the following optional plugin apps. + +- [Termux:API](https://github.com/termux/termux-api) +- [Termux:Boot](https://github.com/termux/termux-boot) +- [Termux:Float](https://github.com/termux/termux-float) +- [Termux:Styling](https://github.com/termux/termux-styling) +- [Termux:Tasker](https://github.com/termux/termux-tasker) +- [Termux:Widget](https://github.com/termux/termux-widget) +## + + + ## Installation -Termux application can be obtained from [F-Droid](https://f-droid.org/en/packages/com.termux/). +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. + +The `F-Droid` app often may not notify you of updates and you will manually have to do a pull down swipe action in the `Updates` tab of the app for it to check updates. Make sure battery optimizations are disabled for the app, check https://dontkillmyapp.com/ for details on how to do that. + +Only a universal APK is released, which will work on all supported architectures. The APK and bootstrap installation size will be `~180MB`. `F-Droid` does [not support](https://github.com/termux/termux-app/pull/1904) architecture specific APKs. + +### GitHub + +Termux application can be obtained on `GitHub` either from [`GitHub Releases`](https://github.com/termux/termux-app/releases) for version `>= 0.118.0` or from [`GitHub Build Action`](https://github.com/termux/termux-app/actions/workflows/debug_build.yml?query=branch%3Amaster+event%3Apush) workflows. **For android `>= 7`, only install `apt-android-7` variants. For android `5` and `6`, only install `apt-android-5` variants.** + +The APKs for `GitHub Releases` will be listed under `Assets` drop-down of a release. These are automatically attached when a new version is released. + +The APKs for `GitHub Build` action workflows will be listed under `Artifacts` section of a workflow run. These are created for each commit/push done to the repository and can be used by users who don't want to wait for releases and want to try out the latest features immediately or want to test their pull requests. Note that for action workflows, you need to be [**logged into a `GitHub` account**](https://github.com/login) for the `Artifacts` links to be enabled/clickable. If you are using the [`GitHub` app](https://github.com/mobile), then make sure to open workflow link in a browser like Chrome or Firefox that has your GitHub account logged in since the in-app browser may not be logged in. + +The APKs for both of these are [`debuggable`](https://developer.android.com/studio/debug) and are compatible with each other but they are not compatible with other sources. + +Both universal and architecture specific APKs are released. The APK and bootstrap installation size will be `~180MB` if using universal and `~120MB` if using architecture specific. Check [here](https://github.com/termux/termux-app/issues/2153) for details. + +**Security warning**: APK files on GitHub are signed with a test key that has been [shared with community](https://github.com/termux/termux-app/blob/master/app/testkey_untrusted.jks). This IS NOT an official developer key and everyone can use it to generate releases for own testing. Be very careful when using Termux GitHub builds obtained elsewhere except https://github.com/termux/termux-app. Everyone is able to use it to forge a malicious Termux update installable over the GitHub build. Think twice about installing Termux builds distributed via Telegram or other social media. If your device get caught by malware, we will not be able to help you. + +The [test key](https://github.com/termux/termux-app/blob/master/app/testkey_untrusted.jks) shall not be used to impersonate @termux and can't be used for this anyway. This key is not trusted by us and it is quite easy to detect its use in user generated content. + +
+Keystore information + +``` +Alias name: alias +Creation date: Oct 4, 2019 +Entry type: PrivateKeyEntry +Certificate chain length: 1 +Certificate[1]: +Owner: CN=APK Signer, OU=Earth, O=Earth +Issuer: CN=APK Signer, OU=Earth, O=Earth +Serial number: 29be297b +Valid from: Wed Sep 04 02:03:24 EEST 2019 until: Tue Oct 26 02:03:24 EEST 2049 +Certificate fingerprints: + SHA1: 51:79:55:EA:BF:69:FC:05:7C:41:C7:D3:79:DB:BC:EF:20:AD:85:F2 + SHA256: B6:DA:01:48:0E:EF:D5:FB:F2:CD:37:71:B8:D1:02:1E:C7:91:30:4B:DD:6C:4B:F4:1D:3F:AA:BA:D4:8E:E5:E1 +Signature algorithm name: SHA1withRSA (disabled) +Subject Public Key Algorithm: 2048-bit RSA key +Version: 3 +``` + +
+ +### Google Play Store **(Experimental branch)** + +There is currently a build of Termux available on Google Play for Android 11+ devices, with extensive adjustments in order to pass policy requirements there. This is under development and has missing functionality and bugs (see [here](https://github.com/termux-play-store/) for status updates) compared to the stable F-Droid build, which is why most users who can should still use F-Droid or GitHub build as mentioned above. + +Currently, Google Play will try to update installations away from F-Droid ones. Updating will still fail as [sharedUserId](https://developer.android.com/guide/topics/manifest/manifest-element#uid) has been removed. A planned 0.118.1 F-Droid release will fix this by setting a higher version code than used for the PlayStore app. Meanwhile, to prevent Google Play from attempting to download and then fail to install the Google Play releases over existing installations, you can open the Termux apps pages on Google Play and then click on the 3 dots options button in the top right and then disable the Enable auto update toggle. However, the Termux apps updates will still show in the PlayStore app updates list. + +If you want to help out with testing the Google Play build (or cannot install Termux from other sources), be aware that it's built from a separate repository (https://github.com/termux-play-store/) - be sure to report issues [there](https://github.com/termux-play-store/termux-issues/issues/new/choose), as any issues encountered might very well be specific to that repository. + +## Uninstallation + +Uninstallation may be required if a user doesn't want Termux installed in their device anymore or is switching to a different [install source](#installation). You may also want to consider [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) before the uninstallation. + +To uninstall Termux completely, you must uninstall **any and all existing Termux or its plugin app APKs** listed in [Termux App and Plugins](#termux-app-and-plugins). + +Go to `Android Settings` -> `Applications` and then look for those apps. You can also use the search feature if it’s available on your device and search `termux` in the applications list. + +Even if you think you have not installed any of the plugins, it's strongly suggested to go through the application list in Android settings and double-check. +## + + + +## Important Links + +### Community +All community links are available [here](https://wiki.termux.com/wiki/Community). -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. +The main ones are the following. -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. +- [Termux Reddit community](https://reddit.com/r/termux) +- [Termux User Matrix Channel](https://matrix.to/#/#termux_termux:gitter.im) ([Gitter](https://gitter.im/termux/termux)) +- [Termux Dev Matrix Channel](https://matrix.to/#/#termux_dev:gitter.im) ([Gitter](https://gitter.im/termux/dev)) +- [Termux X (Twitter)](https://twitter.com/termuxdevs) +- [Termux Support Email](mailto:support@termux.dev) + +### Wikis + +- [Termux Wiki](https://wiki.termux.com/wiki/) +- [Termux App Wiki](https://github.com/termux/termux-app/wiki) +- [Termux Packages Wiki](https://github.com/termux/termux-packages/wiki) + +### Miscellaneous +- [FAQ](https://wiki.termux.com/wiki/FAQ) +- [Termux File System Layout](https://github.com/termux/termux-packages/wiki/Termux-file-system-layout) +- [Differences From Linux](https://wiki.termux.com/wiki/Differences_from_Linux) +- [Package Management](https://wiki.termux.com/wiki/Package_Management) +- [Remote Access](https://wiki.termux.com/wiki/Remote_Access) +- [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) +- [Terminal Settings](https://wiki.termux.com/wiki/Terminal_Settings) +- [Touch Keyboard](https://wiki.termux.com/wiki/Touch_Keyboard) +- [Android Storage and Sharing Data with Other Apps](https://wiki.termux.com/wiki/Internal_and_external_storage) +- [Android APIs](https://wiki.termux.com/wiki/Termux:API) +- [Moved Termux Packages Hosting From Bintray to IPFS](https://github.com/termux/termux-packages/issues/6348) +- [Running Commands in Termux From Other Apps via `RUN_COMMAND` intent](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent) +- [Termux and Android 10](https://github.com/termux/termux-packages/wiki/Termux-and-Android-10) + + +### Terminal -## Terminal resources +
+ -- [XTerm control sequences](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) +### Terminal resources -## Terminal emulators +- [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) -- 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). +### Terminal emulators -- 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)). +- 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). -- 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). +- 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)). -- 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). +- 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). -- xterm: The grandfather of terminal emulators. - [Source](http://invisible-island.net/datafiles/release/xterm.tar.gz). +- 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 4fb53eefff..3df93fef10 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,17 +1,32 @@ 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 project.properties.compileSdkVersion.toInteger() - ndkVersion project.properties.ndkVersion + 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.2.0" - implementation "androidx.core:core:1.5.0-rc01" + 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 "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" @@ -26,8 +41,13 @@ android { applicationId "com.termux" minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() - versionCode 109 - versionName "0.109" + 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" @@ -44,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' @@ -62,7 +87,7 @@ android { buildTypes { release { minifyEnabled true - shrinkResources true + shrinkResources false // Reproducible builds proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } @@ -72,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 } @@ -91,17 +119,44 @@ android { 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.2" - testImplementation "org.robolectric:robolectric:4.4" + 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, String version) { @@ -118,10 +173,11 @@ def downloadBootstrap(String arch, String expectedChecksum, String 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() } } @@ -139,6 +195,7 @@ def downloadBootstrap(String arch, String expectedChecksum, String 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) @@ -155,16 +212,27 @@ clean { task downloadBootstraps() { doLast { - def version = "2021.04.13-r1" - downloadBootstrap("aarch64", "ff82e5755d947cd1f3e0b30916d125c6ddd8ba3254801ca7499d73653417e158", version) - downloadBootstrap("arm", "53a7df2d6d0a36a8c9ab5259c8b5457c93b8bae8aec2321a470236b6da54e59a", version) - downloadBootstrap("i686", "f0e1399a13ebed6c5229fde161f9848d9f5eeae7b8cd82f31250a813b52e371", version) - downloadBootstrap("x86_64", "e36c4d8c933dc12b3f48937b7747c7a4dcfaa70f0dd89ad5e8b4465930075ae9", 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 7365abb1dc..a01c038960 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -10,3 +10,8 @@ -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 cef9943d05..7c566c8f0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,9 @@ + + @@ -32,6 +34,9 @@ + + + - - - + android:theme="@style/Theme.TermuxApp.DayNight.DarkActionBar" + tools:targetApi="m"> + android:theme="@style/Theme.TermuxActivity.DayNight.NoActionBar" + tools:targetApi="n"> @@ -77,6 +78,7 @@ @@ -94,27 +96,33 @@ android:label="@string/application_name" android:parentActivityName=".app.TermuxActivity" android:resizeableActivity="true" - android:theme="@android:style/Theme.Material.Light.DarkActionBar" /> + tools:targetApi="n" /> + android:theme="@style/Theme.TermuxApp.DayNight.NoActionBar" /> + android:name=".shared.activities.ReportActivity" + android:theme="@style/Theme.MarkdownViewActivity.DayNight" + android:documentLaunchMode="intoExisting" /> + android:taskAffinity="${TERMUX_PACKAGE_NAME}.filereceiver" + tools:targetApi="n"> + + + @@ -130,6 +138,13 @@ + + + + @@ -142,7 +157,7 @@ - + + + + + + + + + + + + + + + + - - + + + + + + + + + + + diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index d81b3ec53e..635f281058 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -10,15 +10,21 @@ import android.os.IBinder; import com.termux.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.termux.plugins.TermuxPluginUtils; +import com.termux.shared.termux.file.TermuxFileUtils; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.errors.Errno; +import com.termux.shared.errors.Error; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import com.termux.shared.file.FileUtils; import com.termux.shared.logger.Logger; import com.termux.shared.notification.NotificationUtils; -import com.termux.app.utils.PluginUtils; -import com.termux.shared.data.DataUtils; -import com.termux.shared.models.ExecutionCommand; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.ExecutionCommand.Runner; /** * A service that receives {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent from third party apps and @@ -57,43 +63,85 @@ public int onStartCommand(Intent intent, int flags, int startId) { // 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(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); - PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); - return Service.START_NOT_STICKY; + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); } - executionCommand.executable = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); - executionCommand.arguments = intent.getStringArrayExtra(RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS); - executionCommand.stdin = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_STDIN); - executionCommand.workingDirectory = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_WORKDIR); - executionCommand.inBackground = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, false); - executionCommand.sessionAction = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_SESSION_ACTION); - executionCommand.commandLabel = DataUtils.getDefaultIfNull(intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_LABEL), "RUN_COMMAND Execution Intent Command"); - executionCommand.commandDescription = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_DESCRIPTION); - executionCommand.commandHelp = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_HELP); - executionCommand.isPluginExecutionCommand = true; - executionCommand.pluginPendingIntent = intent.getParcelableExtra(RUN_COMMAND_SERVICE.EXTRA_PENDING_INTENT); + 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 = PluginUtils.checkIfRunCommandServiceAllowExternalAppsPolicyIsViolated(this); + errmsg = TermuxPluginUtils.checkIfAllowExternalAppsPolicyIsViolated(this, LOG_TAG); if (errmsg != null) { - executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); - PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, true); - return Service.START_NOT_STICKY; + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, true); + return stopService(); } @@ -101,24 +149,23 @@ public int onStartCommand(Intent intent, int flags, int startId) { // 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(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); - PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); - return Service.START_NOT_STICKY; + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); } // Get canonical path of executable - executionCommand.executable = FileUtils.getCanonicalPath(executionCommand.executable, null, true); + 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 - errmsg = FileUtils.validateRegularFileExistenceAndPermissions(this, "executable", executionCommand.executable, null, - PluginUtils.PLUGIN_EXECUTABLE_FILE_PERMISSIONS, true, true, + error = FileUtils.validateRegularFileExistenceAndPermissions("executable", executionCommand.executable, null, + FileUtils.APP_EXECUTABLE_FILE_PERMISSIONS, true, true, false); - if (errmsg != null) { - errmsg += "\n" + this.getString(R.string.msg_executable_absolute_path, executionCommand.executable); - executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); - PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); - return Service.START_NOT_STICKY; + if (error != null) { + executionCommand.setStateFailed(error); + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); } @@ -126,29 +173,36 @@ public int onStartCommand(Intent intent, int flags, int startId) { // If workingDirectory is not null or empty if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) { // Get canonical path of workingDirectory - executionCommand.workingDirectory = FileUtils.getCanonicalPath(executionCommand.workingDirectory, null, true); + 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 {@link TermuxConstants#TERMUX_FILES_DIR_PATH} + // 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. - errmsg = FileUtils.validateDirectoryFileExistenceAndPermissions(this, "working", executionCommand.workingDirectory, TermuxConstants.TERMUX_FILES_DIR_PATH, true, - PluginUtils.PLUGIN_WORKING_DIRECTORY_PERMISSIONS, true, true, - true, true); - if (errmsg != null) { - errmsg += "\n" + this.getString(R.string.msg_working_directory_absolute_path, executionCommand.workingDirectory); - executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); - PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); - return Service.START_NOT_STICKY; + 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(); - executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(FileUtils.getExpandedTermuxPath(executionCommand.executable)).build(); - - Logger.logVerbose(LOG_TAG, executionCommand.toString()); + 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); @@ -156,13 +210,24 @@ public int onStartCommand(Intent intent, int flags, int startId) { 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_BACKGROUND, executionCommand.inBackground); + 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.pluginPendingIntent); + 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) { @@ -171,8 +236,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { this.startService(execIntent); } - runStopForeground(); + return stopService(); + } + private int stopService() { + runStopForeground(); return Service.START_NOT_STICKY; } @@ -194,7 +262,7 @@ private Notification buildNotification() { 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, NotificationUtils.NOTIFICATION_MODE_SILENT); + null, null, NotificationUtils.NOTIFICATION_MODE_SILENT); if (builder == null) return null; // No need to show a timestamp: diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index f323d0fe22..0c9f74125b 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -1,8 +1,6 @@ package com.termux.app; -import android.Manifest; import android.annotation.SuppressLint; -import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; @@ -11,9 +9,7 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; -import android.content.pm.PackageManager; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.view.ContextMenu; @@ -24,39 +20,53 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; -import android.view.autofill.AutofillManager; -import android.view.inputmethod.InputMethodManager; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ListView; +import android.widget.RelativeLayout; import android.widget.Toast; import com.termux.R; +import com.termux.app.api.file.FileReceiverActivity; +import com.termux.app.terminal.TermuxActivityRootView; +import com.termux.app.terminal.TermuxTerminalSessionActivityClient; +import com.termux.app.terminal.io.TermuxTerminalExtraKeys; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.activity.ActivityUtils; +import com.termux.shared.activity.media.AppCompatActivityUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.android.PermissionUtils; +import com.termux.shared.data.DataUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; import com.termux.app.activities.HelpActivity; import com.termux.app.activities.SettingsActivity; -import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +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.TermuxTerminalSessionClient; import com.termux.app.terminal.TermuxTerminalViewClient; -import com.termux.app.terminal.io.extrakeys.ExtraKeysView; -import com.termux.app.settings.properties.TermuxAppSharedProperties; -import com.termux.shared.interact.DialogUtils; +import com.termux.shared.termux.extrakeys.ExtraKeysView; +import com.termux.shared.termux.interact.TextInputDialogUtils; import com.termux.shared.logger.Logger; import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.termux.theme.TermuxThemeUtils; +import com.termux.shared.theme.NightMode; +import com.termux.shared.view.ViewUtils; import com.termux.terminal.TerminalSession; import com.termux.terminal.TerminalSessionClient; -import com.termux.app.utils.CrashUtils; import com.termux.view.TerminalView; import com.termux.view.TerminalViewClient; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; +import androidx.appcompat.app.AppCompatActivity; import androidx.drawerlayout.widget.DrawerLayout; import androidx.viewpager.widget.ViewPager; +import java.util.Arrays; + /** * A terminal emulator activity. *

@@ -67,8 +77,7 @@ * * about memory leaks. */ -public final class TermuxActivity extends Activity implements ServiceConnection { - +public final class TermuxActivity extends AppCompatActivity implements ServiceConnection { /** * The connection to the {@link TermuxService}. Requested in {@link #onCreate(Bundle)} with a call to @@ -78,7 +87,7 @@ public final class TermuxActivity extends Activity implements ServiceConnection TermuxService mTermuxService; /** - * The main view of the activity showing the terminal. Initialized in onCreate(). + * The {@link TerminalView} shown in {@link TermuxActivity} that displays the terminal. */ TerminalView mTerminalView; @@ -92,7 +101,7 @@ public final class TermuxActivity extends Activity implements ServiceConnection * The {@link TerminalSessionClient} interface implementation to allow for communication between * {@link TerminalSession} and {@link TermuxActivity}. */ - TermuxTerminalSessionClient mTermuxTerminalSessionClient; + TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; /** * Termux app shared preferences manager. @@ -100,15 +109,30 @@ public final class TermuxActivity extends Activity implements ServiceConnection private TermuxAppSharedPreferences mPreferences; /** - * Termux app shared properties manager, loaded from termux.properties + * Termux app SharedProperties loaded from termux.properties */ private TermuxAppSharedProperties mProperties; + /** + * The root view of the {@link TermuxActivity}. + */ + TermuxActivityRootView mTermuxActivityRootView; + + /** + * The space at the bottom of {@link @mTermuxActivityRootView} of the {@link TermuxActivity}. + */ + View mTermuxActivityBottomSpaceView; + /** * The terminal extra keys view. */ ExtraKeysView mExtraKeysView; + /** + * The client for the {@link #mExtraKeysView}. + */ + TermuxTerminalExtraKeys mTermuxTerminalExtraKeys; + /** * The termux sessions list controller. */ @@ -130,13 +154,33 @@ public final class TermuxActivity extends Activity implements ServiceConnection */ private boolean mIsVisible; + /** + * If onResume() was called after onCreate(). + */ + private boolean mIsOnResumeAfterOnCreate = false; + + /** + * If activity was restarted like due to call to {@link #recreate()} after receiving + * {@link TERMUX_ACTIVITY#ACTION_RELOAD_STYLE}, system dark night mode was changed or activity + * was killed by android. + */ + private boolean mIsActivityRecreated = false; + + /** + * The {@link TermuxActivity} is in an invalid state and must not be run. + */ + private boolean mIsInvalidState; + private int mNavBarHeight; - private int mTerminalToolbarDefaultHeight; + private float mTerminalToolbarDefaultHeight; + private static final int CONTEXT_MENU_SELECT_URL_ID = 0; private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1; - private static final int CONTEXT_MENU_AUTOFILL_ID = 2; + 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; @@ -145,24 +189,25 @@ public final class TermuxActivity extends Activity implements ServiceConnection private static final int CONTEXT_MENU_SETTINGS_ID = 8; private static final int CONTEXT_MENU_REPORT_ID = 9; - private static final int REQUESTCODE_PERMISSION_STORAGE = 1234; - private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input"; + private static final String ARG_ACTIVITY_RECREATED = "activity_recreated"; private static final String LOG_TAG = "TermuxActivity"; @Override public void onCreate(Bundle savedInstanceState) { - Logger.logDebug(LOG_TAG, "onCreate"); + mIsOnResumeAfterOnCreate = true; - // Check if a crash happened on last run of the app and show a - // notification with the crash details if it did - CrashUtils.notifyCrash(this, LOG_TAG); + 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 shared preferences and properties - mPreferences = new TermuxAppSharedPreferences(this); - mProperties = new TermuxAppSharedProperties(this); + // Load Termux app SharedProperties from disk + mProperties = TermuxAppSharedProperties.getProperties(); + reloadProperties(); setActivityTheme(); @@ -170,6 +215,22 @@ public void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_termux); + // Load termux shared preferences + // This will also fail if TermuxConstants.TERMUX_PACKAGE_NAME does not equal applicationId + mPreferences = TermuxAppSharedPreferences.build(this, true); + if (mPreferences == null) { + // An AlertDialog should have shown to kill the app, so we don't continue running activity code + mIsInvalidState = true; + return; + } + + setMargins(); + + mTermuxActivityRootView = findViewById(R.id.activity_termux_root_view); + mTermuxActivityRootView.setActivity(this); + mTermuxActivityBottomSpaceView = findViewById(R.id.activity_termux_bottom_space_view); + mTermuxActivityRootView.setOnApplyWindowInsetsListener(new TermuxActivityRootView.WindowInsetsListener()); + View content = findViewById(android.R.id.content); content.setOnApplyWindowInsetsListener((v, insets) -> { mNavBarHeight = insets.getSystemWindowInsetBottom(); @@ -180,26 +241,38 @@ public void onCreate(Bundle savedInstanceState) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } - setDrawerTheme(); - setTermuxTerminalViewAndClients(); setTerminalToolbarView(savedInstanceState); + setSettingsButtonView(); + setNewSessionButtonView(); setToggleKeyboardView(); registerForContextMenu(mTerminalView); - // Start the {@link TermuxService} and make it run regardless of who is bound to it - Intent serviceIntent = new Intent(this, TermuxService.class); - startService(serviceIntent); + FileReceiverActivity.updateFileReceiverActivityComponentsState(this); - // 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"); + try { + // Start the {@link TermuxService} and make it run regardless of who is bound to it + Intent serviceIntent = new Intent(this, TermuxService.class); + startService(serviceIntent); + + // Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)} + // callback if it succeeds. + if (!bindService(serviceIntent, this, 0)) + throw new RuntimeException("bindService() failed"); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG,"TermuxActivity failed to start TermuxService", e); + Logger.showToast(this, + getString(e.getMessage() != null && e.getMessage().contains("app is in background") ? + R.string.error_termux_service_start_failed_bg : R.string.error_termux_service_start_failed_general), + true); + mIsInvalidState = true; + return; + } // Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux // app has been opened. @@ -212,34 +285,99 @@ public void onStart() { Logger.logDebug(LOG_TAG, "onStart"); + if (mIsInvalidState) return; + mIsVisible = true; - if (mTermuxService != null) { - // The service has connected, but data may have changed since we were last in the foreground. - // Get the session stored in shared preferences stored by {@link #onStop} if its valid, - // otherwise get the last session currently running. - mTermuxTerminalSessionClient.setCurrentSession(mTermuxTerminalSessionClient.getCurrentStoredSessionOrLast()); - termuxSessionListNotifyUpdated(); - } + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onStart(); - registerTermuxActivityBroadcastReceiver(); + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStart(); - // If user changed the preference from {@link TermuxSettings} activity and returns, then - // update the {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value. - mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); + if (mPreferences.isTerminalMarginAdjustmentEnabled()) + addTermuxActivityRootViewGlobalLayoutListener(); - // The current terminal session may have changed while being away, force - // a refresh of the displayed terminal. - mTerminalView.onScreenUpdated(); + registerTermuxActivityBroadcastReceiver(); } @Override public void onResume() { super.onResume(); - setSoftKeyboardState(); + Logger.logVerbose(LOG_TAG, "onResume"); + + if (mIsInvalidState) return; + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onResume(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onResume(); + + // Check if a crash happened on last run of the app or if a plugin crashed and show a + // notification with the crash details if it did + TermuxCrashUtils.notifyAppCrashFromCrashLogFile(this, LOG_TAG); + + mIsOnResumeAfterOnCreate = false; } + @Override + protected void onStop() { + super.onStop(); + + Logger.logDebug(LOG_TAG, "onStop"); + + if (mIsInvalidState) return; + + mIsVisible = false; + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onStop(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStop(); + + removeTermuxActivityRootViewGlobalLayoutListener(); + + unregisterTermuxActivityBroadcastReceiver(); + getDrawer().closeDrawers(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + Logger.logDebug(LOG_TAG, "onDestroy"); + + if (mIsInvalidState) return; + + if (mTermuxService != null) { + // Do not leave service and session clients with references to activity. + mTermuxService.unsetTermuxTerminalSessionClient(); + mTermuxService = null; + } + + try { + unbindService(this); + } catch (Exception e) { + // ignore. + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { + Logger.logVerbose(LOG_TAG, "onSaveInstanceState"); + + super.onSaveInstanceState(savedInstanceState); + saveTerminalToolbarTextInput(savedInstanceState); + savedInstanceState.putBoolean(ARG_ACTIVITY_RECREATED, true); + } + + + + + /** * Part of the {@link ServiceConnection} interface. The service is bound with * {@link #bindService(Intent, ServiceConnection, int)} in {@link #onCreate(Bundle)} which will cause a call to this @@ -247,24 +385,25 @@ public void onResume() { */ @Override public void onServiceConnected(ComponentName componentName, IBinder service) { - Logger.logDebug(LOG_TAG, "onServiceConnected"); mTermuxService = ((TermuxService.LocalBinder) service).service; setTermuxSessionsListView(); + final Intent intent = getIntent(); + setIntent(null); + if (mTermuxService.isTermuxSessionsEmpty()) { if (mIsVisible) { TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> { if (mTermuxService == null) return; // Activity might have been destroyed. try { - Bundle bundle = getIntent().getExtras(); boolean launchFailsafe = false; - if (bundle != null) { - launchFailsafe = bundle.getBoolean(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); + if (intent != null && intent.getExtras() != null) { + launchFailsafe = intent.getExtras().getBoolean(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); } - mTermuxTerminalSessionClient.addNewSession(launchFailsafe, null); + mTermuxTerminalSessionActivityClient.addNewSession(launchFailsafe, null); } catch (WindowManager.BadTokenException e) { // Activity finished - ignore. } @@ -274,87 +413,106 @@ public void onServiceConnected(ComponentName componentName, IBinder service) { 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 isFailSafe = i.getBooleanExtra(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); - mTermuxTerminalSessionClient.addNewSession(isFailSafe, null); + boolean isFailSafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + mTermuxTerminalSessionActivityClient.addNewSession(isFailSafe, null); } else { - mTermuxTerminalSessionClient.setCurrentSession(mTermuxTerminalSessionClient.getCurrentStoredSessionOrLast()); + mTermuxTerminalSessionActivityClient.setCurrentSession(mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast()); } } // Update the {@link TerminalSession} and {@link TerminalEmulator} clients. - mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionClient); + mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionActivityClient); } @Override public void onServiceDisconnected(ComponentName name) { - Logger.logDebug(LOG_TAG, "onServiceDisconnected"); // Respect being stopped from the {@link TermuxService} notification action. finishActivityIfNotFinishing(); } - @Override - protected void onStop() { - super.onStop(); - Logger.logDebug(LOG_TAG, "onStop"); - mIsVisible = false; - // Store current session in shared preferences so that it can be restored later in - // {@link #onStart} if needed. - mTermuxTerminalSessionClient.setCurrentStoredSession(); - unregisterTermuxActivityBroadcastReceiever(); - getDrawer().closeDrawers(); + + private void reloadProperties() { + mProperties.loadTermuxPropertiesFromDisk(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onReloadProperties(); } - @Override - public void onDestroy() { - super.onDestroy(); - Logger.logDebug(LOG_TAG, "onDestroy"); - if (mTermuxService != null) { - // Do not leave service and session clients with references to activity. - mTermuxService.unsetTermuxTerminalSessionClient(); - mTermuxService = null; - } - unbindService(this); + 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); } - @Override - public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { - super.onSaveInstanceState(savedInstanceState); - saveTerminalToolbarTextInput(savedInstanceState); + 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); } - private void setActivityTheme() { - if (mProperties.isUsingBlackUI()) { - this.setTheme(R.style.Theme_Termux_Black); - } else { - this.setTheme(R.style.Theme_Termux); - } + public void addTermuxActivityRootViewGlobalLayoutListener() { + getTermuxActivityRootView().getViewTreeObserver().addOnGlobalLayoutListener(getTermuxActivityRootView()); } - private void setDrawerTheme() { - if (mProperties.isUsingBlackUI()) { - findViewById(R.id.left_drawer).setBackgroundColor(ContextCompat.getColor(this, - android.R.color.background_dark)); - } + public void removeTermuxActivityRootViewGlobalLayoutListener() { + if (getTermuxActivityRootView() != null) + getTermuxActivityRootView().getViewTreeObserver().removeOnGlobalLayoutListener(getTermuxActivityRootView()); + } + + + + private void setTermuxTerminalViewAndClients() { + // Set termux terminal view and session clients + mTermuxTerminalSessionActivityClient = new TermuxTerminalSessionActivityClient(this); + mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionActivityClient); + + // Set termux terminal view + mTerminalView = findViewById(R.id.terminal_view); + mTerminalView.setTerminalViewClient(mTermuxTerminalViewClient); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onCreate(); + + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.onCreate(); + } + + private void setTermuxSessionsListView() { + ListView termuxSessionsListView = findViewById(R.id.terminal_sessions_list); + mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions()); + termuxSessionsListView.setAdapter(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); } private void setTerminalToolbarView(Bundle savedInstanceState) { - final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); - if (mPreferences.getShowTerminalToolbar()) terminalToolbarViewPager.setVisibility(View.VISIBLE); + 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; @@ -370,23 +528,24 @@ private void setTerminalToolbarView(Bundle savedInstanceState) { } private void setTerminalToolbarHeight() { - final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager(); if (terminalToolbarViewPager == null) return; + ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams(); - layoutParams.height = (int) Math.round(mTerminalToolbarDefaultHeight * - (mProperties.getExtraKeysInfo() == null ? 0 : mProperties.getExtraKeysInfo().getMatrix().length) * - mProperties.getTerminalToolbarHeightScaleFactor()); + layoutParams.height = Math.round(mTerminalToolbarDefaultHeight * + (mTermuxTerminalExtraKeys.getExtraKeysInfo() == null ? 0 : mTermuxTerminalExtraKeys.getExtraKeysInfo().getMatrix().length) * + mProperties.getTerminalToolbarHeightScaleFactor()); terminalToolbarViewPager.setLayoutParams(layoutParams); } public void toggleTerminalToolbar() { - final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + 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 && terminalToolbarViewPager.getCurrentItem() == 1) { + if (showNow && isTerminalToolbarTextInputViewSelected()) { // Focus the text input view if just revealed. findViewById(R.id.terminal_toolbar_text_input).requestFocus(); } @@ -395,7 +554,7 @@ public void toggleTerminalToolbar() { private void saveTerminalToolbarTextInput(Bundle savedInstanceState) { if (savedInstanceState == null) return; - final EditText textInputView = findViewById(R.id.terminal_toolbar_text_input); + 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); @@ -404,13 +563,20 @@ private void saveTerminalToolbarTextInput(Bundle savedInstanceState) { + private void setSettingsButtonView() { + ImageButton settingsButton = findViewById(R.id.settings_button); + settingsButton.setOnClickListener(v -> { + ActivityUtils.startActivity(this, new Intent(this, SettingsActivity.class)); + }); + } + private void setNewSessionButtonView() { View newSessionButton = findViewById(R.id.new_session_button); - newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionClient.addNewSession(false, null)); + newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionActivityClient.addNewSession(false, null)); newSessionButton.setOnLongClickListener(v -> { - DialogUtils.textInput(TermuxActivity.this, R.string.title_create_named_session, null, - R.string.action_create_named_session_confirm, text -> mTermuxTerminalSessionClient.addNewSession(false, text), - R.string.action_new_session_failsafe, text -> mTermuxTerminalSessionClient.addNewSession(true, text), + 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; }); @@ -418,8 +584,7 @@ private void setNewSessionButtonView() { private void setToggleKeyboardView() { findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { - InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); + mTermuxTerminalViewClient.onToggleSoftKeyboardRequest(); getDrawer().closeDrawers(); }); @@ -429,50 +594,6 @@ private void setToggleKeyboardView() { }); } - private void setSoftKeyboardState() { - // If soft keyboard is to disabled - if (!mPreferences.getSoftKeyboardEnabled()) { - getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); - } else { - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); - } - - // If soft keyboard is to be hidden on startup - if (mProperties.shouldSoftKeyboardBeHiddenOnStartup()) { - getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); - } - } - - - - private void setTermuxTerminalViewAndClients() { - // Set termux terminal view and session clients - mTermuxTerminalSessionClient = new TermuxTerminalSessionClient(this); - mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionClient); - - // Set termux terminal view - mTerminalView = findViewById(R.id.terminal_view); - mTerminalView.setTerminalViewClient(mTermuxTerminalViewClient); - - mTerminalView.setTextSize(mPreferences.getFontSize()); - mTerminalView.setKeepScreenOn(mPreferences.getKeepScreenOn()); - - // Set {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value - mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); - - mTerminalView.requestFocus(); - - mTermuxTerminalSessionClient.checkForFontAndColors(); - } - - 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); - } - @@ -510,21 +631,20 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn TerminalSession currentSession = getCurrentSession(); if (currentSession == null) return; - boolean addAutoFillMenu = false; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillManager autofillManager = getSystemService(AutofillManager.class); - if (autofillManager != null && autofillManager.isEnabled()) { - addAutoFillMenu = true; - } - } + 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 (addAutoFillMenu) menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.action_autofill_password); + 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.getKeepScreenOn()); + 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); @@ -548,11 +668,17 @@ public boolean onContextItemSelected(MenuItem item) { case CONTEXT_MENU_SHARE_TRANSCRIPT_ID: mTermuxTerminalViewClient.shareSessionTranscript(); return true; - case CONTEXT_MENU_AUTOFILL_ID: - requestAutoFill(); + case CONTEXT_MENU_SHARE_SELECTED_TEXT: + mTermuxTerminalViewClient.shareSelectedText(); + return true; + case CONTEXT_MENU_AUTOFILL_USERNAME: + mTerminalView.requestAutoFillUsername(); + return true; + case CONTEXT_MENU_AUTOFILL_PASSWORD: + mTerminalView.requestAutoFillPassword(); return true; case CONTEXT_MENU_RESET_TERMINAL_ID: - resetSession(session); + onResetTerminalSession(session); return true; case CONTEXT_MENU_KILL_PROCESS_ID: showKillSessionDialog(session); @@ -564,10 +690,10 @@ public boolean onContextItemSelected(MenuItem item) { toggleKeepScreenOn(); return true; case CONTEXT_MENU_HELP_ID: - startActivity(new Intent(this, HelpActivity.class)); + ActivityUtils.startActivity(this, new Intent(this, HelpActivity.class)); return true; case CONTEXT_MENU_SETTINGS_ID: - startActivity(new Intent(this, SettingsActivity.class)); + ActivityUtils.startActivity(this, new Intent(this, SettingsActivity.class)); return true; case CONTEXT_MENU_REPORT_ID: mTermuxTerminalViewClient.reportIssueFromTranscript(); @@ -577,6 +703,13 @@ public boolean onContextItemSelected(MenuItem item) { } } + @Override + public void onContextMenuClosed(Menu menu) { + super.onContextMenuClosed(menu); + // onContextMenuClosed() is triggered twice if back button is pressed to dismiss instead of tap for some reason + mTerminalView.onContextMenuClosed(menu); + } + private void showKillSessionDialog(TerminalSession session) { if (session == null) return; @@ -591,23 +724,28 @@ private void showKillSessionDialog(TerminalSession session) { b.show(); } - private void resetSession(TerminalSession session) { + 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.TERMUX_STYLING_ACTIVITY_NAME); + 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) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(TermuxConstants.TERMUX_STYLING_FDROID_PACKAGE_URL)))).setNegativeButton(android.R.string.cancel, null).show(); + .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() { @@ -620,37 +758,52 @@ private void toggleKeepScreenOn() { } } - private void requestAutoFill() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillManager autofillManager = getSystemService(AutofillManager.class); - if (autofillManager != null && autofillManager.isEnabled()) { - autofillManager.requestAutofill(mTerminalView); - } - } - } - /** - * For processes to access shared internal storage (/sdcard) we need this permission. + * 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 boolean ensureStoragePermissionGranted() { - if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { - return true; - } else { - Logger.logDebug(LOG_TAG, "Storage permission not granted, requesting permission."); - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUESTCODE_PERMISSION_STORAGE); - return false; + public void requestStoragePermission(boolean isPermissionCallback) { + new Thread() { + @Override + public void run() { + // Do not ask for permission again + int requestCode = isPermissionCallback ? -1 : PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION; + + // If permission is granted, then also setup storage symlinks. + if(PermissionUtils.checkAndRequestLegacyOrManageExternalStoragePermission( + TermuxActivity.this, requestCode, !isPermissionCallback)) { + if (isPermissionCallback) + Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG, + getString(com.termux.shared.R.string.msg_storage_permission_granted_on_request)); + + TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + } else { + if (isPermissionCallback) + Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG, + getString(com.termux.shared.R.string.msg_storage_permission_not_granted_on_request)); + } + } + }.start(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data)); + if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) { + requestStoragePermission(true); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == REQUESTCODE_PERMISSION_STORAGE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Logger.logDebug(LOG_TAG, "Storage permission granted by user on request."); - TermuxInstaller.setupStorageSymlinks(this); - } else { - Logger.logDebug(LOG_TAG, "Storage permission denied by user on request."); + 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); } } @@ -660,10 +813,22 @@ public int getNavBarHeight() { return mNavBarHeight; } + public TermuxActivityRootView getTermuxActivityRootView() { + return mTermuxActivityRootView; + } + + public View getTermuxActivityBottomSpaceView() { + return mTermuxActivityBottomSpaceView; + } + public ExtraKeysView getExtraKeysView() { return mExtraKeysView; } + public TermuxTerminalExtraKeys getTermuxTerminalExtraKeys() { + return mTermuxTerminalExtraKeys; + } + public void setExtraKeysView(ExtraKeysView extraKeysView) { mExtraKeysView = extraKeysView; } @@ -672,6 +837,24 @@ 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(); } @@ -680,6 +863,14 @@ public boolean isVisible() { return mIsVisible; } + public boolean isOnResumeAfterOnCreate() { + return mIsOnResumeAfterOnCreate; + } + + public boolean isActivityRecreated() { + return mIsActivityRecreated; + } + public TermuxService getTermuxService() { @@ -690,8 +881,12 @@ public TerminalView getTerminalView() { return mTerminalView; } - public TermuxTerminalSessionClient getTermuxTerminalSessionClient() { - return mTermuxTerminalSessionClient; + public TermuxTerminalViewClient getTermuxTerminalViewClient() { + return mTermuxTerminalViewClient; + } + + public TermuxTerminalSessionActivityClient getTermuxTerminalSessionClient() { + return mTermuxTerminalSessionActivityClient; } @Nullable @@ -713,25 +908,27 @@ public TermuxAppSharedProperties getProperties() { - public static void updateTermuxActivityStyling(Context context) { + 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_REQUEST_PERMISSIONS); + 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 unregisterTermuxActivityBroadcastReceiever() { + private void unregisterTermuxActivityBroadcastReceiver() { unregisterReceiver(mTermuxActivityBroadcastReceiver); } - private void fixTermuxActivityBroadcastReceieverIntent(Intent intent) { + private void fixTermuxActivityBroadcastReceiverIntent(Intent intent) { if (intent == null) return; String extraReloadStyle = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); @@ -747,17 +944,20 @@ public void onReceive(Context context, Intent intent) { if (intent == null) return; if (mIsVisible) { - fixTermuxActivityBroadcastReceieverIntent(intent); + fixTermuxActivityBroadcastReceiverIntent(intent); switch (intent.getAction()) { - case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS: - Logger.logDebug(LOG_TAG, "Received intent to request storage permissions"); - if (ensureStoragePermissionGranted()) - TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + 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"); - reloadTermuxActivityStyling(); + 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: } @@ -765,36 +965,43 @@ public void onReceive(Context context, Intent intent) { } } - private void reloadTermuxActivityStyling() { - if (mTermuxTerminalSessionClient != null) { - mTermuxTerminalSessionClient.checkForFontAndColors(); - } - - if (mProperties!= null) { - mProperties.loadTermuxPropertiesFromDisk(); + private void reloadActivityStyling(boolean recreateActivity) { + if (mProperties != null) { + reloadProperties(); if (mExtraKeysView != null) { - mExtraKeysView.reload(mProperties.getExtraKeysInfo()); + mExtraKeysView.setButtonTextAllCaps(mProperties.shouldExtraKeysTextBeAllCaps()); + mExtraKeysView.reload(mTermuxTerminalExtraKeys.getExtraKeysInfo(), mTerminalToolbarDefaultHeight); } + + // Update NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(mProperties.getNightMode()); } + setMargins(); setTerminalToolbarHeight(); - setSoftKeyboardState(); + 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. - // But this will destroy the activity, and will call the onCreate() again. - // We need to investigate if enabling this is wise, since all stored variables and - // views will be destroyed and bindService() will be called again. Extra keys input - // text will we restored since that has already been implemented. Terminal sessions - // and transcripts are also already preserved. Theme does change properly too. - // TermuxActivity.this.recreate(); + // 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) { - context.startActivity(newInstance(context)); + ActivityUtils.startActivity(context, newInstance(context)); } public static Intent newInstance(@NonNull final Context context) { diff --git a/app/src/main/java/com/termux/app/TermuxApplication.java b/app/src/main/java/com/termux/app/TermuxApplication.java index 3bc6689ca4..1123abf623 100644 --- a/app/src/main/java/com/termux/app/TermuxApplication.java +++ b/app/src/main/java/com/termux/app/TermuxApplication.java @@ -1,28 +1,85 @@ package com.termux.app; import android.app.Application; +import android.content.Context; -import com.termux.shared.crash.CrashHandler; -import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +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 - CrashHandler.setCrashHandler(this); + 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 log level for the app - setLogLevel(); + // 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); + } } - private void setLogLevel() { + 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 = new TermuxAppSharedPreferences(getApplicationContext()); + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; preferences.setLogLevel(null, preferences.getLogLevel()); - Logger.logDebug("Starting Application"); } -} +} diff --git a/app/src/main/java/com/termux/app/TermuxInstaller.java b/app/src/main/java/com/termux/app/TermuxInstaller.java index bc25838043..b14149993a 100644 --- a/app/src/main/java/com/termux/app/TermuxInstaller.java +++ b/app/src/main/java/com/termux/app/TermuxInstaller.java @@ -4,16 +4,24 @@ 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.Pair; import android.view.WindowManager; import com.termux.R; import com.termux.shared.file.FileUtils; +import com.termux.shared.termux.crash.TermuxCrashUtils; +import com.termux.shared.termux.file.TermuxFileUtils; +import com.termux.shared.interact.MessageDialogUtils; import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.android.PackageUtils; import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment; import java.io.BufferedReader; import java.io.ByteArrayInputStream; @@ -25,6 +33,11 @@ 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: *

@@ -50,24 +63,57 @@ final class 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) { - String bootstrapErrorMessage = activity.getString(R.string.bootstrap_error_not_primary_user_message, TermuxConstants.TERMUX_PREFIX_DIR_PATH); + 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); - new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(bootstrapErrorMessage) - .setOnDismissListener(dialog -> System.exit(0)).setPositiveButton(android.R.string.ok, null).show(); + sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage); + MessageDialogUtils.exitAppWithErrorMessage(activity, + activity.getString(R.string.bootstrap_error_title), + bootstrapErrorMessage); return; } - final File PREFIX_FILE = TermuxConstants.TERMUX_PREFIX_DIR; - 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 @@ -75,17 +121,37 @@ public void run() { try { Logger.logInfo(LOG_TAG, "Installing " + TermuxConstants.TERMUX_APP_NAME + " bootstrap packages."); - String errmsg; + Error error; + + // Delete prefix staging directory or any file at its destination + error = FileUtils.deleteFile("termux prefix staging directory", TERMUX_STAGING_PREFIX_DIR_PATH, true); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } + + // Delete prefix directory or any file at its destination + error = FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true); + if (error != null) { + showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error)); + return; + } - final String STAGING_PREFIX_PATH = TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH; - final File STAGING_PREFIX_FILE = new File(STAGING_PREFIX_PATH); + // 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; + } - errmsg = FileUtils.clearDirectory(activity, "prefix staging directory", STAGING_PREFIX_PATH); - if (errmsg != null) { - throw new RuntimeException(errmsg); + // 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 \"" + TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH + "\"."); + 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); @@ -102,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(activity, 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(activity, 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)) { @@ -120,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); } @@ -135,30 +210,22 @@ public void run() { Os.symlink(symlink.first, symlink.second); } - Logger.logInfo(LOG_TAG, "Moving prefix staging to prefix directory."); + Logger.logInfo(LOG_TAG, "Moving termux prefix staging to prefix directory."); - if (!STAGING_PREFIX_FILE.renameTo(PREFIX_FILE)) { - throw new RuntimeException("Moving prefix staging to prefix directory failed"); + 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) { - Logger.logStackTraceWithMessage(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.setupBootstrapIfNeeded(activity, whenDone); - }).show(); - } catch (WindowManager.BadTokenException e1) { - // Activity already dismissed - ignore. - } - }); + showBootstrapErrorDialog(activity, whenDone, Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e))); + } finally { activity.runOnUiThread(() -> { try { @@ -172,28 +239,72 @@ public void run() { }.start(); } + public static void showBootstrapErrorDialog(Activity activity, Runnable whenDone, String message) { + Logger.logErrorExtended(LOG_TAG, "Bootstrap Error:\n" + message); + + // Send a notification with the exception so that the user knows why bootstrap setup failed + sendBootstrapCrashReportNotification(activity, message); + + activity.runOnUiThread(() -> { + try { + new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_body) + .setNegativeButton(R.string.bootstrap_error_abort, (dialog, which) -> { + dialog.dismiss(); + activity.finish(); + }) + .setPositiveButton(R.string.bootstrap_error_try_again, (dialog, which) -> { + dialog.dismiss(); + FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true); + TermuxInstaller.setupBootstrapIfNeeded(activity, whenDone); + }).show(); + } catch (WindowManager.BadTokenException e1) { + // Activity already dismissed - ignore. + } + }); + } + + private static void sendBootstrapCrashReportNotification(Activity activity, String message) { + final String title = TermuxConstants.TERMUX_APP_NAME + " Bootstrap Error"; + + // Add info of all install Termux plugin apps as well since their target sdk or installation + // on external/portable sd card can affect Termux app files directory access or exec. + TermuxCrashUtils.sendCrashReportNotification(activity, LOG_TAG, + title, null, "## " + title + "\n\n" + message + "\n\n" + + TermuxUtils.getTermuxDebugMarkdownString(activity), + true, false, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES, true); + } + static void setupStorageSymlinks(final Context context) { final String LOG_TAG = "termux-storage"; + final String title = TermuxConstants.TERMUX_APP_NAME + " Setup Storage Error"; Logger.logInfo(LOG_TAG, "Setting up storage symlinks."); new Thread() { public void run() { try { - String errmsg; + Error error; File storageDir = TermuxConstants.TERMUX_STORAGE_HOME_DIR; - errmsg = FileUtils.clearDirectory(context, "~/storage", storageDir.getAbsolutePath()); - if (errmsg != null) { - Logger.logErrorAndShowToast(context, LOG_TAG, errmsg); + 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()); @@ -209,9 +320,25 @@ 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; @@ -220,21 +347,32 @@ public void run() { } } + // Create "Android/media/com.termux" symlinks + dirs = context.getExternalMediaDirs(); + if (dirs != null && dirs.length > 0) { + for (int i = 0; i < dirs.length; i++) { + File dir = dirs[i]; + if (dir == null) continue; + String symlinkName = "media-" + i; + Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\"."); + Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath()); + } + } + Logger.logInfo(LOG_TAG, "Storage symlinks created successfully."); } catch (Exception e) { - Logger.logStackTraceWithMessage(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 void ensureDirectoryExists(Context context, File directory) { - String errmsg; - - errmsg = FileUtils.createDirectoryFile(context, directory.getAbsolutePath()); - if (errmsg != null) { - throw new RuntimeException(errmsg); - } + private static Error ensureDirectoryExists(File directory) { + return FileUtils.createDirectoryFile(directory.getAbsolutePath()); } public static byte[] loadZipBytes() { diff --git a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java index c3609dbc2f..f27f922cab 100644 --- a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java +++ b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java @@ -13,7 +13,12 @@ import android.provider.MediaStore; import android.webkit.MimeTypeMap; +import com.termux.shared.termux.plugins.TermuxPluginUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.net.uri.UriUtils; import com.termux.shared.logger.Logger; +import com.termux.shared.net.uri.UriScheme; import com.termux.shared.termux.TermuxConstants; import java.io.File; @@ -30,11 +35,13 @@ public class TermuxOpenReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { final Uri data = intent.getData(); if (data == null) { - Logger.logError(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(); @@ -48,8 +55,8 @@ public void onReceive(Context context, Intent intent) { 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()); @@ -61,14 +68,21 @@ public void onReceive(Context context, Intent intent) { try { context.startActivity(urlIntent); } catch (ActivityNotFoundException e) { - Logger.logError(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())) { - Logger.logError(LOG_TAG, "termux-open: Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); + Logger.logError(LOG_TAG, "Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); return; } @@ -89,7 +103,8 @@ public void onReceive(Context context, Intent intent) { contentTypeToUse = contentTypeExtra; } - Uri uriToShare = Uri.parse("content://" + TermuxConstants.TERMUX_FILE_SHARE_URI_AUTHORITY + 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); @@ -105,12 +120,14 @@ public void onReceive(Context context, Intent intent) { try { context.startActivity(sendIntent); } catch (ActivityNotFoundException e) { - Logger.logError(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; @@ -155,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; } @@ -178,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(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/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index 64ca368524..8025d0bd2c 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -5,36 +5,46 @@ 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.widget.ArrayAdapter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.termux.R; -import com.termux.app.terminal.TermuxTerminalSessionClient; -import com.termux.app.utils.PluginUtils; +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.settings.preferences.TermuxAppSharedPreferences; -import com.termux.shared.shell.TermuxSession; -import com.termux.shared.terminal.TermuxTerminalSessionClientBase; +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.packages.PermissionUtils; -import com.termux.shared.shell.ShellUtils; +import com.termux.shared.android.PermissionUtils; import com.termux.shared.data.DataUtils; -import com.termux.shared.models.ExecutionCommand; -import com.termux.shared.shell.TermuxTask; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.shell.command.ExecutionCommand.Runner; +import com.termux.shared.shell.command.ExecutionCommand.ShellCreateMode; import com.termux.terminal.TerminalEmulator; import com.termux.terminal.TerminalSession; import com.termux.terminal.TerminalSessionClient; @@ -42,11 +52,9 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Nullable; - /** - * A service holding a list of {@link TermuxSession} in {@link #mTermuxSessions} and background {@link TermuxTask} - * in {@link #mTermuxTasks}, showing a foreground notification while running so that it is not terminated. + * 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. @@ -57,9 +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 TermuxTask.TermuxTaskClient, TermuxSession.TermuxSessionClient { - - private static int EXECUTION_ID = 1000; +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 { @@ -70,34 +76,27 @@ class LocalBinder extends Binder { private final Handler mHandler = new Handler(); - /** - * The foreground TermuxSessions which this service manages. - * Note that this list is observed by {@link TermuxActivity#mTermuxSessionListViewController}, - * so any changes must be made on the UI thread and followed by a call to - * {@link ArrayAdapter#notifyDataSetChanged()} }. - */ - final List mTermuxSessions = new ArrayList<>(); - - /** - * The background TermuxTasks which this service manages. - */ - final List mTermuxTasks = new ArrayList<>(); - - /** - * The pending plugin ExecutionCommands that have yet to be processed by this service. - */ - final List mPendingPluginExecutionCommands = new ArrayList<>(); /** 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. */ - TermuxTerminalSessionClient mTermuxTerminalSessionClient; + private TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient; /** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} - * that does not hold activity references. + * that does not hold activity references and only a service reference. + */ + private final TermuxTerminalSessionServiceClient mTermuxTerminalSessionServiceClient = new TermuxTerminalSessionServiceClient(this); + + /** + * Termux app shared properties manager, loaded from termux.properties + */ + private TermuxAppSharedProperties mProperties; + + /** + * Termux app shell manager */ - final TermuxTerminalSessionClientBase mTermuxTerminalSessionClientBase = new TermuxTerminalSessionClientBase(); + private TermuxShellManager mShellManager; /** The wake lock and wifi lock are always acquired and released together. */ private PowerManager.WakeLock mWakeLock; @@ -111,7 +110,16 @@ class LocalBinder extends Binder { @Override public void onCreate() { Logger.logVerbose(LOG_TAG, "onCreate"); + + // Get Termux app SharedProperties without loading from disk since TermuxApplication handles + // load and TermuxActivity handles reloads + mProperties = TermuxAppSharedProperties.getProperties(); + + mShellManager = TermuxShellManager.getShellManager(); + runStartForeground(); + + SystemEventReceiver.registerPackageUpdateEvents(this); } @SuppressLint("Wakelock") @@ -122,7 +130,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { // Run again in case service is already started and onCreate() is not called runStartForeground(); - String action = intent.getAction(); + String action = null; + if (intent != null) { + Logger.logVerboseExtended(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + action = intent.getAction(); + } if (action != null) { switch (action) { @@ -157,11 +169,16 @@ public int onStartCommand(Intent intent, int flags, int startId) { public void onDestroy() { Logger.logVerbose(LOG_TAG, "onDestroy"); - ShellUtils.clearTermuxTMPDIR(this); + TermuxShellUtils.clearTermuxTMPDIR(true); actionReleaseWakeLock(false); if (!mWantsToStop) killAllTermuxExecutionCommands(); + + TermuxShellManager.onAppExit(this); + + SystemEventReceiver.unregisterPackageUpdateEvents(this); + runStopForeground(); } @@ -178,7 +195,7 @@ public boolean onUnbind(Intent intent) { // 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 (mTermuxTerminalSessionClient != null) + if (mTermuxTerminalSessionActivityClient != null) unsetTermuxTerminalSessionClient(); return false; } @@ -246,28 +263,36 @@ private void actionStopService() { private synchronized void killAllTermuxExecutionCommands() { boolean processResult; - Logger.logDebug(LOG_TAG, "Killing TermuxSessions=" + mTermuxSessions.size() + ", TermuxTasks=" + mTermuxTasks.size() + ", PendingPluginExecutionCommands=" + mPendingPluginExecutionCommands.size()); + 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); - List termuxSessions = new ArrayList<>(mTermuxSessions); for (int i = 0; i < termuxSessions.size(); i++) { ExecutionCommand executionCommand = termuxSessions.get(i).getExecutionCommand(); - processResult = mWantsToStop || (executionCommand.isPluginExecutionCommand && executionCommand.pluginPendingIntent != null); + processResult = mWantsToStop || executionCommand.isPluginExecutionCommandWithPendingResult(); termuxSessions.get(i).killIfExecuting(this, processResult); + if (!processResult) + mShellManager.mTermuxSessions.remove(termuxSessions.get(i)); } - List termuxTasks = new ArrayList<>(mTermuxTasks); + for (int i = 0; i < termuxTasks.size(); i++) { ExecutionCommand executionCommand = termuxTasks.get(i).getExecutionCommand(); - if (executionCommand.isPluginExecutionCommand && executionCommand.pluginPendingIntent != null) + if (executionCommand.isPluginExecutionCommandWithPendingResult()) termuxTasks.get(i).killIfExecuting(this, true); + else + mShellManager.mTermuxTasks.remove(termuxTasks.get(i)); } - List pendingPluginExecutionCommands = new ArrayList<>(mPendingPluginExecutionCommands); for (int i = 0; i < pendingPluginExecutionCommands.size(); i++) { ExecutionCommand executionCommand = pendingPluginExecutionCommands.get(i); - if (!executionCommand.shouldNotProcessResults() && executionCommand.pluginPendingIntent != null) { - if (executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_CANCELED, this.getString(com.termux.shared.R.string.error_execution_cancelled), null)) { - PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + 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); } } } @@ -294,18 +319,8 @@ private void actionAcquireWakeLock() { mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, TermuxConstants.TERMUX_APP_NAME.toLowerCase()); 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) { - Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", e); - } + if (!PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) { + PermissionUtils.requestDisableBatteryOptimizations(this); } updateNotification(); @@ -347,35 +362,65 @@ private void actionServiceExecute(Intent intent) { return; } - ExecutionCommand executionCommand = new ExecutionCommand(getNextExecutionId()); + ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId()); executionCommand.executableUri = intent.getData(); - executionCommand.inBackground = intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, false); + executionCommand.isPluginExecutionCommand = true; + + // If EXTRA_RUNNER is passed, use that, otherwise check EXTRA_BACKGROUND and default to Runner.TERMINAL_SESSION + executionCommand.runner = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RUNNER, + (intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, false) ? Runner.APP_SHELL.getName() : Runner.TERMINAL_SESSION.getName())); + if (Runner.runnerOf(executionCommand.runner) == null) { + String errmsg = this.getString(R.string.error_termux_service_invalid_execution_command_runner, executionCommand.runner); + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return; + } if (executionCommand.executableUri != null) { - executionCommand.executable = executionCommand.executableUri.getPath(); - executionCommand.arguments = intent.getStringArrayExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS); - if (executionCommand.inBackground) - executionCommand.stdin = intent.getStringExtra(TERMUX_SERVICE.EXTRA_STDIN); + Logger.logVerbose(LOG_TAG, "uri: \"" + executionCommand.executableUri + "\", path: \"" + executionCommand.executableUri.getPath() + "\", fragment: \"" + executionCommand.executableUri.getFragment() + "\""); + + // Get full path including fragment (anything after last "#") + executionCommand.executable = UriUtils.getUriFilePathWithFragment(executionCommand.executableUri); + executionCommand.arguments = IntentUtils.getStringArrayExtraIfSet(intent, TERMUX_SERVICE.EXTRA_ARGUMENTS, null); + if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) + executionCommand.stdin = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_STDIN, null); + executionCommand.backgroundCustomLogLevel = IntentUtils.getIntegerExtraIfSet(intent, TERMUX_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, null); } - executionCommand.workingDirectory = intent.getStringExtra(TERMUX_SERVICE.EXTRA_WORKDIR); - executionCommand.isFailsafe = intent.getBooleanExtra(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); + 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.commandLabel = DataUtils.getDefaultIfNull(intent.getStringExtra(TERMUX_SERVICE.EXTRA_COMMAND_LABEL), "Execution Intent Command"); - executionCommand.commandDescription = intent.getStringExtra(TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION); - executionCommand.commandHelp = intent.getStringExtra(TERMUX_SERVICE.EXTRA_COMMAND_HELP); - executionCommand.pluginAPIHelp = intent.getStringExtra(TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP); - executionCommand.isPluginExecutionCommand = true; - executionCommand.pluginPendingIntent = intent.getParcelableExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT); + 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 - mPendingPluginExecutionCommands.add(executionCommand); + mShellManager.mPendingPluginExecutionCommands.add(executionCommand); - if (executionCommand.inBackground) { + if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) executeTermuxTaskCommand(executionCommand); - } else { + 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); } } @@ -383,57 +428,84 @@ private void actionServiceExecute(Intent intent) { - /** Execute a shell command in background {@link TermuxTask}. */ + /** 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"); - TermuxTask newTermuxTask = createTermuxTask(executionCommand); + // Transform executable path to shell/session name, e.g. "/bin/do-something.sh" => "do-something.sh". + if (executionCommand.shellName == null && executionCommand.executable != null) + executionCommand.shellName = ShellUtils.getExecutableBasename(executionCommand.executable); + + AppShell newTermuxTask = null; + ShellCreateMode shellCreateMode = processShellCreateMode(executionCommand); + if (shellCreateMode == null) return; + if (ShellCreateMode.NO_SHELL_WITH_NAME.equals(shellCreateMode)) { + newTermuxTask = getTermuxTaskForShellName(executionCommand.shellName); + if (newTermuxTask != null) + Logger.logVerbose(LOG_TAG, "Existing TermuxTask with \"" + executionCommand.shellName + "\" shell name found for shell create mode \"" + shellCreateMode.getMode() + "\""); + else + Logger.logVerbose(LOG_TAG, "No existing TermuxTask with \"" + executionCommand.shellName + "\" shell name found for shell create mode \"" + shellCreateMode.getMode() + "\""); + } + + if (newTermuxTask == null) + newTermuxTask = createTermuxTask(executionCommand); } - /** Create a {@link TermuxTask}. */ + /** Create a TermuxTask. */ @Nullable - public TermuxTask createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) { - return createTermuxTask(new ExecutionCommand(getNextExecutionId(), executablePath, arguments, stdin, workingDirectory, true, false)); + public AppShell createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) { + return createTermuxTask(new ExecutionCommand(TermuxShellManager.getNextShellId(), executablePath, + arguments, stdin, workingDirectory, Runner.APP_SHELL.getName(), false)); } - /** Create a {@link TermuxTask}. */ + /** Create a TermuxTask. */ @Nullable - public synchronized TermuxTask createTermuxTask(ExecutionCommand executionCommand) { + public synchronized AppShell createTermuxTask(ExecutionCommand executionCommand) { if (executionCommand == null) return null; Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); - if (!executionCommand.inBackground) { - Logger.logDebug(LOG_TAG, "Ignoring a foreground execution command passed to createTermuxTask()"); + if (!Runner.APP_SHELL.equalsRunner(executionCommand.runner)) { + Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxTask()"); return null; } + executionCommand.setShellCommandShellEnvironment = true; + if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) - Logger.logVerbose(LOG_TAG, executionCommand.toString()); + Logger.logVerboseExtended(LOG_TAG, executionCommand.toString()); - TermuxTask newTermuxTask = TermuxTask.execute(this, executionCommand, this, false); + AppShell newTermuxTask = AppShell.execute(this, executionCommand, this, + new TermuxShellEnvironment(), null,false); if (newTermuxTask == null) { Logger.logError(LOG_TAG, "Failed to execute new TermuxTask command for:\n" + executionCommand.getCommandIdAndLabelLogString()); + // If the execution command was started for a plugin, then process the error + if (executionCommand.isPluginExecutionCommand) + TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + else { + Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs"); + Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString()); + } return null; } - mTermuxTasks.add(newTermuxTask); + mShellManager.mTermuxTasks.add(newTermuxTask); // Remove the execution command from the pending plugin execution commands list since it has // now been processed if (executionCommand.isPluginExecutionCommand) - mPendingPluginExecutionCommands.remove(executionCommand); + mShellManager.mPendingPluginExecutionCommands.remove(executionCommand); updateNotification(); return newTermuxTask; } - /** Callback received when a {@link TermuxTask} finishes. */ + /** Callback received when a TermuxTask finishes. */ @Override - public void onTermuxTaskExited(final TermuxTask termuxTask) { + public void onAppShellExited(final AppShell termuxTask) { mHandler.post(() -> { if (termuxTask != null) { ExecutionCommand executionCommand = termuxTask.getExecutionCommand(); @@ -442,9 +514,9 @@ public void onTermuxTaskExited(final TermuxTask termuxTask) { // If the execution command was started for a plugin, then process the results if (executionCommand != null && executionCommand.isPluginExecutionCommand) - PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); - mTermuxTasks.remove(termuxTask); + mShellManager.mTermuxTasks.remove(termuxTask); } updateNotification(); @@ -461,14 +533,23 @@ private void executeTermuxSessionCommand(ExecutionCommand executionCommand) { Logger.logDebug(LOG_TAG, "Executing foreground \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); - String sessionName = null; - - // Transform executable path to session name, e.g. "/bin/do-something.sh" => "do something.sh". - if (executionCommand.executable != null) { - sessionName = ShellUtils.getExecutableBasename(executionCommand.executable).replace('-', ' '); + // 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() + "\""); } - TermuxSession newTermuxSession = createTermuxSession(executionCommand, sessionName); + if (newTermuxSession == null) + newTermuxSession = createTermuxSession(executionCommand); if (newTermuxSession == null) return; handleSessionAction(DataUtils.getIntFromString(executionCommand.sessionAction, @@ -478,51 +559,68 @@ private void executeTermuxSessionCommand(ExecutionCommand executionCommand) { /** * Create a {@link TermuxSession}. - * Currently called by {@link TermuxTerminalSessionClient#addNewSession(boolean, String)} to add a new {@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) { - return createTermuxSession(new ExecutionCommand(getNextExecutionId(), executablePath, arguments, stdin, workingDirectory, false, isFailSafe), sessionName); + public TermuxSession createTermuxSession(String executablePath, String[] arguments, String stdin, + String workingDirectory, boolean isFailSafe, String sessionName) { + ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId(), + executablePath, arguments, stdin, workingDirectory, Runner.TERMINAL_SESSION.getName(), isFailSafe); + executionCommand.shellName = sessionName; + return createTermuxSession(executionCommand); } /** Create a {@link TermuxSession}. */ @Nullable - public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand, String sessionName) { + public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand) { if (executionCommand == null) return null; Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); - if (executionCommand.inBackground) { - Logger.logDebug(LOG_TAG, "Ignoring a background execution command passed to createTermuxSession()"); + 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.logVerbose(LOG_TAG, executionCommand.toString()); + 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, sessionName, executionCommand.isPluginExecutionCommand); + 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; } - mTermuxSessions.add(newTermuxSession); + mShellManager.mTermuxSessions.add(newTermuxSession); // Remove the execution command from the pending plugin execution commands list since it has // now been processed if (executionCommand.isPluginExecutionCommand) - mPendingPluginExecutionCommands.remove(executionCommand); + mShellManager.mPendingPluginExecutionCommands.remove(executionCommand); // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if // activity in is foreground - if (mTermuxTerminalSessionClient != null) - mTermuxTerminalSessionClient.termuxSessionListNotifyUpdated(); + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated(); updateNotification(); - TermuxActivity.updateTermuxActivityStyling(this); + + // No need to recreate the activity since it likely just started and theme should already have applied + TermuxActivity.updateTermuxActivityStyling(this, false); return newTermuxSession; } @@ -532,7 +630,7 @@ public synchronized int removeTermuxSession(TerminalSession sessionToRemove) { int index = getIndexOfSession(sessionToRemove); if (index >= 0) - mTermuxSessions.get(index).finish(); + mShellManager.mTermuxSessions.get(index).finish(); return index; } @@ -547,14 +645,14 @@ public void onTermuxSessionExited(final TermuxSession termuxSession) { // If the execution command was started for a plugin, then process the results if (executionCommand != null && executionCommand.isPluginExecutionCommand) - PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); - mTermuxSessions.remove(termuxSession); + mShellManager.mTermuxSessions.remove(termuxSession); // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if // activity in is foreground - if (mTermuxTerminalSessionClient != null) - mTermuxTerminalSessionClient.termuxSessionListNotifyUpdated(); + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated(); } updateNotification(); @@ -564,6 +662,24 @@ public void onTermuxSessionExited(final TermuxSession termuxSession) { + private ShellCreateMode processShellCreateMode(@NonNull ExecutionCommand executionCommand) { + if (ShellCreateMode.ALWAYS.equalsMode(executionCommand.shellCreateMode)) + return ShellCreateMode.ALWAYS; // Default + else if (ShellCreateMode.NO_SHELL_WITH_NAME.equalsMode(executionCommand.shellCreateMode)) + if (DataUtils.isNullOrEmpty(executionCommand.shellName)) { + TermuxPluginUtils.setAndProcessPluginExecutionCommandError(this, LOG_TAG, executionCommand, false, + getString(R.string.error_termux_service_execution_command_shell_name_unset, executionCommand.shellCreateMode)); + return null; + } else { + return ShellCreateMode.NO_SHELL_WITH_NAME; + } + else { + TermuxPluginUtils.setAndProcessPluginExecutionCommandError(this, LOG_TAG, executionCommand, false, + getString(R.string.error_termux_service_unsupported_execution_command_shell_create_mode, executionCommand.shellCreateMode)); + return null; + } + } + /** Process session action for new session. */ private void handleSessionAction(int sessionAction, TerminalSession newTerminalSession) { Logger.logDebug(LOG_TAG, "Processing sessionAction \"" + sessionAction + "\" for session \"" + newTerminalSession.mSessionName + "\""); @@ -571,8 +687,8 @@ private void handleSessionAction(int sessionAction, TerminalSession newTerminalS switch (sessionAction) { case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY: setCurrentStoredTerminalSession(newTerminalSession); - if (mTermuxTerminalSessionClient != null) - mTermuxTerminalSessionClient.setCurrentSession(newTerminalSession); + if (mTermuxTerminalSessionActivityClient != null) + mTermuxTerminalSessionActivityClient.setCurrentSession(newTerminalSession); startTermuxActivity(); break; case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY: @@ -582,8 +698,8 @@ private void handleSessionAction(int sessionAction, TerminalSession newTerminalS break; case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY: setCurrentStoredTerminalSession(newTerminalSession); - if (mTermuxTerminalSessionClient != null) - mTermuxTerminalSessionClient.setCurrentSession(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) @@ -601,8 +717,13 @@ 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)) { + 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); } } @@ -612,35 +733,35 @@ private void startTermuxActivity() { /** 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 #mTermuxTerminalSessionClientBase}. Once {@link TermuxActivity} bind + * so we just return the {@link #mTermuxTerminalSessionServiceClient}. Once {@link TermuxActivity} bind * callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the - * {@link TermuxService#mTermuxTerminalSessionClient} so that further terminal sessions are directly - * passed the {@link TermuxTerminalSessionClient} object which fully implements 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 TermuxTerminalSessionClient} if {@link TermuxActivity} has bound with - * {@link TermuxService}, otherwise {@link TermuxTerminalSessionClientBase}. + * @return Returns the {@link TermuxTerminalSessionActivityClient} if {@link TermuxActivity} has bound with + * {@link TermuxService}, otherwise {@link TermuxTerminalSessionServiceClient}. */ public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() { - if (mTermuxTerminalSessionClient != null) - return mTermuxTerminalSessionClient; + if (mTermuxTerminalSessionActivityClient != null) + return mTermuxTerminalSessionActivityClient; else - return mTermuxTerminalSessionClientBase; + return mTermuxTerminalSessionServiceClient; } /** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the - * {@link TermuxService#mTermuxTerminalSessionClient} variable and update the {@link TerminalSession} - * and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionClientBase} + * {@link TermuxService#mTermuxTerminalSessionActivityClient} variable and update the {@link TerminalSession} + * and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionServiceClient} * earlier. * - * @param termuxTerminalSessionClient The {@link TermuxTerminalSessionClient} object that fully + * @param termuxTerminalSessionActivityClient The {@link TermuxTerminalSessionActivityClient} object that fully * implements the {@link TerminalSessionClient} interface. */ - public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionClient termuxTerminalSessionClient) { - mTermuxTerminalSessionClient = termuxTerminalSessionClient; + public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) { + mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient; - for (int i = 0; i < mTermuxSessions.size(); i++) - mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionClient); + 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)} @@ -648,10 +769,10 @@ public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionCli * clients do not hold an activity references. */ public synchronized void unsetTermuxTerminalSessionClient() { - for (int i = 0; i < mTermuxSessions.size(); i++) - mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionClientBase); + for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) + mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionServiceClient); - mTermuxTerminalSessionClient = null; + mTermuxTerminalSessionActivityClient = null; } @@ -663,12 +784,12 @@ private Notification buildNotification() { // Set pending intent to be launched when notification is clicked Intent notificationIntent = TermuxActivity.newInstance(this); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); // Set notification text int sessionCount = getTermuxSessionsSize(); - int taskCount = mTermuxTasks.size(); + int taskCount = mShellManager.mTermuxTasks.size(); String notificationText = sessionCount + " session" + (sessionCount == 1 ? "" : "s"); if (taskCount > 0) { notificationText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s"); @@ -687,8 +808,8 @@ private Notification buildNotification() { // Build the notification Notification.Builder builder = NotificationUtils.geNotificationBuilder(this, TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, priority, - getText(R.string.application_name), notificationText, null, - pendingIntent, NotificationUtils.NOTIFICATION_MODE_SILENT); + TermuxConstants.TERMUX_APP_NAME, notificationText, null, + contentIntent, null, NotificationUtils.NOTIFICATION_MODE_SILENT); if (builder == null) return null; // No need to show a timestamp: @@ -729,7 +850,7 @@ private void setupNotificationChannel() { /** Update the shown foreground service notification after making any changes that affect it. */ private synchronized void updateNotification() { - if (mWakeLock == null && mTermuxSessions.isEmpty() && mTermuxTasks.isEmpty()) { + 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 { @@ -741,40 +862,55 @@ private synchronized void updateNotification() { - private void setCurrentStoredTerminalSession(TerminalSession session) { - if (session == null) return; - // Make the newly created session the current one to be displayed: - TermuxAppSharedPreferences preferences = new TermuxAppSharedPreferences(this); - preferences.setCurrentSession(session.mHandle); + 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 mTermuxSessions.isEmpty(); + return mShellManager.mTermuxSessions.isEmpty(); } public synchronized int getTermuxSessionsSize() { - return mTermuxSessions.size(); + return mShellManager.mTermuxSessions.size(); } public synchronized List getTermuxSessions() { - return mTermuxSessions; + return mShellManager.mTermuxSessions; } @Nullable public synchronized TermuxSession getTermuxSession(int index) { - if (index >= 0 && index < mTermuxSessions.size()) - return mTermuxSessions.get(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 mTermuxSessions.isEmpty() ? null : mTermuxSessions.get(mTermuxSessions.size() - 1); + return mShellManager.mTermuxSessions.isEmpty() ? null : mShellManager.mTermuxSessions.get(mShellManager.mTermuxSessions.size() - 1); } public synchronized int getIndexOfSession(TerminalSession terminalSession) { - for (int i = 0; i < mTermuxSessions.size(); i++) { - if (mTermuxSessions.get(i).getTerminalSession().equals(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; @@ -782,20 +918,40 @@ public synchronized int getIndexOfSession(TerminalSession terminalSession) { public synchronized TerminalSession getTerminalSessionForHandle(String sessionHandle) { TerminalSession terminalSession; - for (int i = 0, len = mTermuxSessions.size(); i < len; i++) { - terminalSession = mTermuxSessions.get(i).getTerminalSession(); + 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 static synchronized int getNextExecutionId() { - return EXECUTION_ID++; + 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/activities/HelpActivity.java b/app/src/main/java/com/termux/app/activities/HelpActivity.java index fa32bfc4e2..a2e4d6a985 100644 --- a/app/src/main/java/com/termux/app/activities/HelpActivity.java +++ b/app/src/main/java/com/termux/app/activities/HelpActivity.java @@ -1,6 +1,5 @@ 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 HelpActivity 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/ReportActivity.java b/app/src/main/java/com/termux/app/activities/ReportActivity.java deleted file mode 100644 index 2491005a02..0000000000 --- a/app/src/main/java/com/termux/app/activities/ReportActivity.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.termux.app.activities; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; - -import com.termux.R; -import com.termux.shared.termux.TermuxConstants; -import com.termux.shared.markdown.MarkdownUtils; -import com.termux.shared.interact.ShareUtils; -import com.termux.app.models.ReportInfo; - -import org.commonmark.node.FencedCodeBlock; - -import io.noties.markwon.Markwon; -import io.noties.markwon.recycler.MarkwonAdapter; -import io.noties.markwon.recycler.SimpleEntry; - -public class ReportActivity extends AppCompatActivity { - - private static final String EXTRA_REPORT_INFO = "report_info"; - - ReportInfo mReportInfo; - String mReportMarkdownString; - String mReportActivityMarkdownString; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_report); - - Toolbar toolbar = findViewById(R.id.toolbar); - if (toolbar != null) { - setSupportActionBar(toolbar); - } - - Bundle bundle = null; - Intent intent = getIntent(); - if (intent != null) - bundle = intent.getExtras(); - else if (savedInstanceState != null) - bundle = savedInstanceState; - - updateUI(bundle); - - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - - if (intent != null) - updateUI(intent.getExtras()); - } - - private void updateUI(Bundle bundle) { - - if (bundle == null) { - finish(); - return; - } - - mReportInfo = (ReportInfo) bundle.getSerializable(EXTRA_REPORT_INFO); - - if (mReportInfo == null) { - finish(); - return; - } - - - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - if (mReportInfo.reportTitle != null) - actionBar.setTitle(mReportInfo.reportTitle); - else - actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report"); - } - - - RecyclerView recyclerView = findViewById(R.id.recycler_view); - - final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this); - - final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot(R.layout.activity_report_adapter_node_default) - .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.activity_report_adapter_node_code_block, R.id.code_text_view)) - .build(); - - recyclerView.setLayoutManager(new LinearLayoutManager(this)); - recyclerView.setAdapter(adapter); - - - generateReportActivityMarkdownString(); - adapter.setMarkdown(markwon, mReportActivityMarkdownString); - adapter.notifyDataSetChanged(); - } - - - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - - outState.putSerializable(EXTRA_REPORT_INFO, mReportInfo); - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - final MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_report, menu); - return true; - } - - @Override - public void onBackPressed() { - // Remove activity from recents menu on back button press - finishAndRemoveTask(); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - int id = item.getItemId(); - if (id == R.id.menu_item_share_report) { - if (mReportMarkdownString != null) - ShareUtils.shareText(this, getString(R.string.title_report_text), mReportMarkdownString); - } else if (id == R.id.menu_item_copy_report) { - if (mReportMarkdownString != null) - ShareUtils.copyTextToClipboard(this, mReportMarkdownString, null); - } - - return false; - } - - /** - * Generate the markdown {@link String} to be shown in {@link ReportActivity}. - */ - private void generateReportActivityMarkdownString() { - mReportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo); - - mReportActivityMarkdownString = ""; - if (mReportInfo.reportStringPrefix != null) - mReportActivityMarkdownString += mReportInfo.reportStringPrefix; - - mReportActivityMarkdownString += mReportMarkdownString; - - if (mReportInfo.reportStringSuffix != null) - mReportActivityMarkdownString += mReportInfo.reportStringSuffix; - } - - - - public static void startReportActivity(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { - context.startActivity(newInstance(context, reportInfo)); - } - - public static Intent newInstance(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { - Intent intent = new Intent(context, ReportActivity.class); - Bundle bundle = new Bundle(); - bundle.putSerializable(EXTRA_REPORT_INFO, reportInfo); - intent.putExtras(bundle); - - // Note that ReportActivity task has documentLaunchMode="intoExisting" set in AndroidManifest.xml - // which has equivalent behaviour to the following. The following dynamic way doesn't seem to - // work for notification pending intent, i.e separate task isn't created and activity is - // launched in the same task as TermuxActivity. - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT); - return intent; - } - -} diff --git a/app/src/main/java/com/termux/app/activities/SettingsActivity.java b/app/src/main/java/com/termux/app/activities/SettingsActivity.java index b30b1a5799..7ca7848335 100644 --- a/app/src/main/java/com/termux/app/activities/SettingsActivity.java +++ b/app/src/main/java/com/termux/app/activities/SettingsActivity.java @@ -1,18 +1,39 @@ package com.termux.app.activities; +import android.content.Context; import android.os.Bundle; +import android.os.Environment; -import androidx.appcompat.app.ActionBar; +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() @@ -20,11 +41,9 @@ protected void onCreate(Bundle savedInstanceState) { .replace(R.id.settings, new RootPreferencesFragment()) .commit(); } - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowHomeEnabled(true); - } + + AppCompatActivityUtils.setToolbar(this, com.termux.shared.R.id.toolbar); + AppCompatActivityUtils.setShowBackButtonInActionBar(this, true); } @Override @@ -36,7 +55,114 @@ public boolean onSupportNavigateUp() { 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/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java similarity index 67% rename from app/src/main/java/com/termux/app/fragments/settings/DebuggingPreferencesFragment.java rename to app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java index 09da15c3ed..8afd568fdb 100644 --- a/app/src/main/java/com/termux/app/fragments/settings/DebuggingPreferencesFragment.java +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java @@ -1,8 +1,10 @@ -package com.termux.app.fragments.settings; +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; @@ -11,27 +13,40 @@ import androidx.preference.PreferenceManager; import com.termux.R; -import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +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(getContext())); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_debugging_preferences, rootKey); - setPreferencesFromResource(R.xml.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; - if (loggingCategory != null) { - final ListPreference logLevelListPreference = setLogLevelListPreferenceData(findPreference("log_level"), getActivity()); + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel()); loggingCategory.addPreference(logLevelListPreference); } } - protected ListPreference setLogLevelListPreferenceData(ListPreference logLevelListPreference, Context context) { + public static ListPreference setLogLevelListPreferenceData(ListPreference logLevelListPreference, Context context, int logLevel) { if (logLevelListPreference == null) logLevelListPreference = new ListPreference(context); @@ -41,8 +56,8 @@ protected ListPreference setLogLevelListPreferenceData(ListPreference logLevelLi logLevelListPreference.setEntryValues(logLevels); logLevelListPreference.setEntries(logLevelLabels); - logLevelListPreference.setValue(String.valueOf(Logger.getLogLevel())); - logLevelListPreference.setDefaultValue(Logger.getLogLevel()); + logLevelListPreference.setValue(String.valueOf(logLevel)); + logLevelListPreference.setDefaultValue(Logger.DEFAULT_LOG_LEVEL); return logLevelListPreference; } @@ -58,12 +73,12 @@ class DebuggingPreferencesDataStore extends PreferenceDataStore { private DebuggingPreferencesDataStore(Context context) { mContext = context; - mPreferences = new TermuxAppSharedPreferences(context); + mPreferences = TermuxAppSharedPreferences.build(context, true); } public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { if (mInstance == null) { - mInstance = new DebuggingPreferencesDataStore(context.getApplicationContext()); + mInstance = new DebuggingPreferencesDataStore(context); } return mInstance; } @@ -73,6 +88,7 @@ public static synchronized DebuggingPreferencesDataStore getInstance(Context con @Override @Nullable public String getString(String key, @Nullable String defValue) { + if (mPreferences == null) return null; if (key == null) return null; switch (key) { @@ -85,6 +101,7 @@ public String getString(String key, @Nullable String defValue) { @Override public void putString(String key, @Nullable String value) { + if (mPreferences == null) return; if (key == null) return; switch (key) { @@ -102,6 +119,7 @@ public void putString(String key, @Nullable String value) { @Override public void putBoolean(String key, boolean value) { + if (mPreferences == null) return; if (key == null) return; switch (key) { @@ -121,13 +139,14 @@ public void putBoolean(String key, boolean value) { @Override public boolean getBoolean(String key, boolean defValue) { + if (mPreferences == null) return false; switch (key) { case "terminal_view_key_logging_enabled": - return mPreferences.getTerminalViewKeyLoggingEnabled(); + return mPreferences.isTerminalViewKeyLoggingEnabled(); case "plugin_error_notifications_enabled": - return mPreferences.getPluginErrorNotificationsEnabled(); + return mPreferences.arePluginErrorNotificationsEnabled(false); case "crash_report_notifications_enabled": - return mPreferences.getCrashReportNotificationsEnabled(); + return mPreferences.areCrashReportNotificationsEnabled(false); default: return false; } diff --git a/app/src/main/java/com/termux/app/fragments/settings/TerminalIOPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java similarity index 64% rename from app/src/main/java/com/termux/app/fragments/settings/TerminalIOPreferencesFragment.java rename to app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java index 987f9dadbe..f8504f43fd 100644 --- a/app/src/main/java/com/termux/app/fragments/settings/TerminalIOPreferencesFragment.java +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java @@ -1,23 +1,28 @@ -package com.termux.app.fragments.settings; +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.settings.preferences.TermuxAppSharedPreferences; +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(getContext())); + preferenceManager.setPreferenceDataStore(TerminalIOPreferencesDataStore.getInstance(context)); - setPreferencesFromResource(R.xml.terminal_io_preferences, rootKey); + setPreferencesFromResource(R.xml.termux_terminal_io_preferences, rootKey); } } @@ -31,12 +36,12 @@ class TerminalIOPreferencesDataStore extends PreferenceDataStore { private TerminalIOPreferencesDataStore(Context context) { mContext = context; - mPreferences = new TermuxAppSharedPreferences(context); + mPreferences = TermuxAppSharedPreferences.build(context, true); } public static synchronized TerminalIOPreferencesDataStore getInstance(Context context) { if (mInstance == null) { - mInstance = new TerminalIOPreferencesDataStore(context.getApplicationContext()); + mInstance = new TerminalIOPreferencesDataStore(context); } return mInstance; } @@ -45,12 +50,16 @@ public static synchronized TerminalIOPreferencesDataStore getInstance(Context co @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; } @@ -58,9 +67,13 @@ public void putBoolean(String key, boolean value) { @Override public boolean getBoolean(String key, boolean defValue) { + if (mPreferences == null) return false; + switch (key) { case "soft_keyboard_enabled": - return mPreferences.getSoftKeyboardEnabled(); + 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/ReportInfo.java b/app/src/main/java/com/termux/app/models/ReportInfo.java deleted file mode 100644 index 05c8853d20..0000000000 --- a/app/src/main/java/com/termux/app/models/ReportInfo.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.termux.app.models; - -import com.termux.shared.markdown.MarkdownUtils; -import com.termux.shared.termux.TermuxUtils; - -import java.io.Serializable; - -public class ReportInfo implements Serializable { - - /** The user action that was being processed for which the report was generated. */ - public final UserAction userAction; - /** The internal app component that sent the report. */ - public final String sender; - /** The report title. */ - public final String reportTitle; - /** The markdown report text prefix. Will not be part of copy and share operations, etc. */ - public final String reportStringPrefix; - /** The markdown report text. */ - public final String reportString; - /** The markdown report text suffix. Will not be part of copy and share operations, etc. */ - public final String reportStringSuffix; - /** If set to {@code true}, then report, app and device info will be added to the report when - * markdown is generated. - */ - public final boolean addReportInfoToMarkdown; - /** The timestamp for the report. */ - public final String reportTimestamp; - - public ReportInfo(UserAction userAction, String sender, String reportTitle, String reportStringPrefix, String reportString, String reportStringSuffix, boolean addReportInfoToMarkdown) { - this.userAction = userAction; - this.sender = sender; - this.reportTitle = reportTitle; - this.reportStringPrefix = reportStringPrefix; - this.reportString = reportString; - this.reportStringSuffix = reportStringSuffix; - this.addReportInfoToMarkdown = addReportInfoToMarkdown; - this.reportTimestamp = TermuxUtils.getCurrentTimeStamp(); - } - - /** - * Get a markdown {@link String} for {@link ReportInfo}. - * - * @param reportInfo The {@link ReportInfo} to convert. - * @return Returns the markdown {@link String}. - */ - public static String getReportInfoMarkdownString(final ReportInfo reportInfo) { - if (reportInfo == null) return "null"; - - StringBuilder markdownString = new StringBuilder(); - - if (reportInfo.addReportInfoToMarkdown) { - markdownString.append("## Report Info\n\n"); - markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User Action", reportInfo.userAction, "-")); - markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Sender", reportInfo.sender, "-")); - markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Report Timestamp", reportInfo.reportTimestamp, "-")); - markdownString.append("\n##\n\n"); - } - - markdownString.append(reportInfo.reportString); - - return markdownString.toString(); - } - -} diff --git a/app/src/main/java/com/termux/app/models/UserAction.java b/app/src/main/java/com/termux/app/models/UserAction.java index ad56fbefaf..1e82255e99 100644 --- a/app/src/main/java/com/termux/app/models/UserAction.java +++ b/app/src/main/java/com/termux/app/models/UserAction.java @@ -2,8 +2,7 @@ public enum UserAction { - PLUGIN_EXECUTION_COMMAND("plugin execution command"), - CRASH_REPORT("crash report"), + ABOUT("about"), REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript"); private final String name; diff --git a/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java b/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java deleted file mode 100644 index 2ad4209532..0000000000 --- a/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.termux.app.settings.properties; - -import android.content.Context; - -import com.termux.app.terminal.io.KeyboardShortcut; -import com.termux.app.terminal.io.extrakeys.ExtraKeysInfo; -import com.termux.shared.logger.Logger; -import com.termux.shared.settings.properties.SharedPropertiesParser; -import com.termux.shared.settings.properties.TermuxPropertyConstants; -import com.termux.shared.settings.properties.TermuxSharedProperties; - -import org.json.JSONException; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import javax.annotation.Nonnull; - -public class TermuxAppSharedProperties extends TermuxSharedProperties implements SharedPropertiesParser { - - private ExtraKeysInfo mExtraKeysInfo; - private List mSessionShortcuts = new ArrayList<>(); - - private static final String LOG_TAG = "TermuxAppSharedProperties"; - - public TermuxAppSharedProperties(@Nonnull Context context) { - super(context); - } - - /** - * Reload the termux properties from disk into an in-memory cache. - */ - @Override - public void loadTermuxPropertiesFromDisk() { - super.loadTermuxPropertiesFromDisk(); - - setExtraKeys(); - setSessionShortcuts(); - } - - /** - * 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) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true); - String extraKeysStyle = (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true); - mExtraKeysInfo = new ExtraKeysInfo(extrakeys, extraKeysStyle); - } catch (JSONException e) { - Logger.showToast(mContext, "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); - } catch (JSONException e2) { - Logger.showToast(mContext, "Can't create default extra keys",true); - Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e); - mExtraKeysInfo = null; - } - } - } - - /** - * Set the terminal sessions shortcuts. - */ - private void setSessionShortcuts() { - if (mSessionShortcuts == null) - mSessionShortcuts = new ArrayList<>(); - else - mSessionShortcuts.clear(); - - // 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) 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 List getSessionShortcuts() { - return mSessionShortcuts; - } - - public ExtraKeysInfo getExtraKeysInfo() { - return mExtraKeysInfo; - } - -} 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 index 76c7930912..bf914b977b 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -20,7 +20,9 @@ import com.termux.R; import com.termux.app.TermuxActivity; -import com.termux.shared.shell.TermuxSession; +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; @@ -55,9 +57,9 @@ public View getView(int position, View convertView, @NonNull ViewGroup parent) { return sessionRowView; } - boolean isUsingBlackUI = mActivity.getProperties().isUsingBlackUI(); + boolean shouldEnableDarkTheme = ThemeUtils.shouldEnableDarkTheme(mActivity, NightMode.getAppNightMode().getName()); - if (isUsingBlackUI) { + if (shouldEnableDarkTheme) { sessionTitleView.setBackground( ContextCompat.getDrawable(mActivity, R.drawable.session_background_black_selected) ); @@ -84,7 +86,7 @@ public View getView(int position, View convertView, @NonNull ViewGroup parent) { } else { sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); } - int defaultColor = isUsingBlackUI ? Color.WHITE : Color.BLACK; + int defaultColor = shouldEnableDarkTheme ? Color.WHITE : Color.BLACK; int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; sessionTitleView.setTextColor(color); return sessionRowView; diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java similarity index 51% rename from app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java rename to app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java index c1f1185b03..cd38163116 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java @@ -1,6 +1,7 @@ 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; @@ -12,18 +13,23 @@ import android.text.TextUtils; import android.widget.ListView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.termux.R; -import com.termux.shared.shell.TermuxSession; -import com.termux.shared.interact.DialogUtils; +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.terminal.TermuxTerminalSessionClientBase; +import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; import com.termux.shared.termux.TermuxConstants; import com.termux.app.TermuxService; -import com.termux.shared.settings.properties.TermuxPropertyConstants; -import com.termux.app.terminal.io.BellHandler; +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; @@ -31,35 +37,92 @@ import java.io.InputStream; import java.util.Properties; -public class TermuxTerminalSessionClient extends TermuxTerminalSessionClientBase { +/** 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 final SoundPool mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes( - new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build(); + private SoundPool mBellSoundPool; - private final int mBellSoundId; + private int mBellSoundId; - private static final String LOG_TAG = "TermuxTerminalSessionClient"; + private static final String LOG_TAG = "TermuxTerminalSessionActivityClient"; - public TermuxTerminalSessionClient(TermuxActivity activity) { + 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(); + } - mBellSoundId = mBellSoundPool.load(activity, R.raw.bell, 1); + /** + * Should be called when mActivity.reloadActivityStyling() is called + */ + public void onReloadActivityStyling() { + // Set terminal fonts and colors + checkForFontAndColors(); } + + @Override - public void onTextChanged(TerminalSession changedSession) { + public void onTextChanged(@NonNull TerminalSession changedSession) { if (!mActivity.isVisible()) return; if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated(); } @Override - public void onTitleChanged(TerminalSession updatedSession) { + public void onTitleChanged(@NonNull TerminalSession updatedSession) { if (!mActivity.isVisible()) return; if (updatedSession != mActivity.getCurrentSession()) { @@ -73,46 +136,68 @@ public void onTitleChanged(TerminalSession updatedSession) { } @Override - public void onSessionFinished(final TerminalSession finishedSession) { - if (mActivity.getTermuxService().wantsToStop()) { + 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. - int indexOfSession = mActivity.getTermuxService().getIndexOfSession(finishedSession); // Verify that session was not removed before we got told about it finishing: - if (indexOfSession >= 0) + 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 (mActivity.getTermuxService().getTermuxSessionsSize() > 1) { + 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) { + if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130 || isPluginExecutionCommandWithPendingResult) { removeFinishedSession(finishedSession); } } } @Override - public void onClipboardText(TerminalSession session, String text) { + 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; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(text))); + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + mActivity.getTerminalView().mEmulator.paste(text); } @Override - public void onBell(TerminalSession session) { + public void onBell(@NonNull TerminalSession session) { if (!mActivity.isVisible()) return; switch (mActivity.getProperties().getBellBehaviour()) { @@ -120,21 +205,88 @@ public void onBell(TerminalSession session) { BellHandler.getInstance(mActivity).doBell(); break; case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_BEEP: - mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f); + 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(TerminalSession changedSession) { + 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. */ @@ -155,12 +307,15 @@ public void setCurrentSession(TerminalSession session) { void notifyOfSessionChange() { if (!mActivity.isVisible()) return; - TerminalSession session = mActivity.getCurrentSession(); - mActivity.showToast(toToastTitle(session), false); + 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); @@ -177,7 +332,10 @@ public void switchToSession(boolean forward) { } public void switchToSession(int index) { - TermuxSession termuxSession = mActivity.getTermuxService().getTermuxSession(index); + TermuxService service = mActivity.getTermuxService(); + if (service == null) return; + + TermuxSession termuxSession = service.getTermuxSession(index); if (termuxSession != null) setCurrentSession(termuxSession.getTerminalSession()); } @@ -186,14 +344,28 @@ public void switchToSession(int index) { public void renameSession(final TerminalSession sessionToRename) { if (sessionToRename == null) return; - DialogUtils.textInput(mActivity, R.string.title_rename_session, sessionToRename.mSessionName, R.string.action_rename_session_confirm, text -> { - sessionToRename.mSessionName = text; + 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) { - if (mActivity.getTermuxService().getTermuxSessionsSize() >= MAX_SESSIONS) { + 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 { @@ -206,7 +378,7 @@ public void addNewSession(boolean isFailSafe, String sessionName) { workingDirectory = currentSession.getCwd(); } - TermuxSession newTermuxSession = mActivity.getTermuxService().createTermuxSession(null, null, null, workingDirectory, isFailSafe, sessionName); + TermuxSession newTermuxSession = service.createTermuxSession(null, null, null, workingDirectory, isFailSafe, sessionName); if (newTermuxSession == null) return; TerminalSession newTerminalSession = newTermuxSession.getTerminalSession(); @@ -226,14 +398,17 @@ public void setCurrentStoredSession() { /** The current session as stored or the last one if that does not exist. */ public TerminalSession getCurrentStoredSessionOrLast() { - TerminalSession stored = getCurrentStoredSession(mActivity); + 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 - TermuxSession termuxSession = mActivity.getTermuxService().getLastTermuxSession(); + TermuxService service = mActivity.getTermuxService(); + if (service == null) return null; + + TermuxSession termuxSession = service.getLastTermuxSession(); if (termuxSession != null) return termuxSession.getTerminalSession(); else @@ -241,7 +416,7 @@ public TerminalSession getCurrentStoredSessionOrLast() { } } - private TerminalSession getCurrentStoredSession(TermuxActivity context) { + private TerminalSession getCurrentStoredSession() { String sessionHandle = mActivity.getPreferences().getCurrentSession(); // If no session is stored in shared preferences @@ -249,16 +424,20 @@ private TerminalSession getCurrentStoredSession(TermuxActivity context) { return null; // Check if the session handle found matches one of the currently running sessions - return context.getTermuxService().getTerminalSessionForHandle(sessionHandle); + 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 = mActivity.getTermuxService().getTermuxSessionsSize(); + int size = service.getTermuxSessionsSize(); if (size == 0) { // There are no sessions to show, so finish the activity. mActivity.finishActivityIfNotFinishing(); @@ -278,7 +457,10 @@ public void termuxSessionListNotifyUpdated() { public void checkAndScrollToSession(TerminalSession session) { if (!mActivity.isVisible()) return; - final int indexOfSession = mActivity.getTermuxService().getIndexOfSession(session); + 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; @@ -290,7 +472,10 @@ public void checkAndScrollToSession(TerminalSession session) { String toToastTitle(TerminalSession session) { - final int indexOfSession = mActivity.getTermuxService().getIndexOfSession(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)) { 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 index e906b01cca..a3d09d3d52 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -2,45 +2,54 @@ import android.annotation.SuppressLint; import android.app.AlertDialog; -import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import android.content.Intent; import android.media.AudioManager; -import android.net.Uri; +import android.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.inputmethod.InputMethodManager; +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.terminal.TermuxTerminalViewClientBase; +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.app.activities.ReportActivity; -import com.termux.app.models.ReportInfo; +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.app.terminal.io.extrakeys.ExtraKeysView; -import com.termux.shared.settings.properties.TermuxPropertyConstants; +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; @@ -48,16 +57,119 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase { final TermuxActivity mActivity; - final TermuxTerminalSessionClient mTermuxTerminalSessionClient; + 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; - public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionClient termuxTerminalSessionClient) { + 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.mTermuxTerminalSessionClient = termuxTerminalSessionClient; + 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) { @@ -72,8 +184,26 @@ public float onScale(float scale) { @Override public void onSingleTapUp(MotionEvent e) { - InputMethodManager mgr = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); - mgr.showSoftInput(mActivity.getTerminalView(), InputMethodManager.SHOW_IMPLICIT); + 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 @@ -91,6 +221,11 @@ public boolean shouldUseCtrlSpaceWorkaround() { return mActivity.getProperties().isUsingCtrlSpaceWorkaround(); } + @Override + public boolean isTerminalViewSelected() { + return mActivity.getTerminalToolbarViewPager() == null || mActivity.isTerminalViewSelected() || mActivity.getTerminalView().hasFocus(); + } + @Override @@ -107,29 +242,29 @@ public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession if (handleVirtualKeys(keyCode, e, true)) return true; if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) { - mTermuxTerminalSessionClient.removeFinishedSession(currentSession); + mTermuxTerminalSessionActivityClient.removeFinishedSession(currentSession); return true; - } else if (e.isCtrlPressed() && e.isAltPressed()) { + } 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 */) { - mTermuxTerminalSessionClient.switchToSession(true); + mTermuxTerminalSessionActivityClient.switchToSession(true); } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) { - mTermuxTerminalSessionClient.switchToSession(false); + 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 */) { - InputMethodManager imm = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + onToggleSoftKeyboardRequest(); } else if (unicodeChar == 'm'/* menu */) { mActivity.getTerminalView().showContextMenu(); } else if (unicodeChar == 'r'/* rename */) { - mTermuxTerminalSessionClient.renameSession(currentSession); + mTermuxTerminalSessionActivityClient.renameSession(currentSession); } else if (unicodeChar == 'c'/* create */) { - mTermuxTerminalSessionClient.addNewSession(false, null); + mTermuxTerminalSessionActivityClient.addNewSession(false, null); } else if (unicodeChar == 'u' /* urls */) { showUrlSelection(); } else if (unicodeChar == 'v') { @@ -142,7 +277,7 @@ public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession changeFontSize(false); } else if (unicodeChar >= '1' && unicodeChar <= '9') { int index = unicodeChar - '1'; - mTermuxTerminalSessionClient.switchToSession(index); + mTermuxTerminalSessionActivityClient.switchToSession(index); } return true; } @@ -151,8 +286,17 @@ public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession } + + @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); } @@ -178,12 +322,32 @@ private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) { @Override public boolean readControlKey() { - return (mActivity.getExtraKeysView() != null && mActivity.getExtraKeysView().readSpecialButton(ExtraKeysView.SpecialButton.CTRL)) || mVirtualControlKeyDown; + return readExtraKeysSpecialButton(SpecialButton.CTRL) || mVirtualControlKeyDown; } @Override public boolean readAltKey() { - return (mActivity.getExtraKeysView() != null && mActivity.getExtraKeysView().readSpecialButton(ExtraKeysView.SpecialButton.ALT)); + 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 @@ -297,11 +461,11 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio return true; } else if (ctrlDown) { if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) { - mTermuxTerminalSessionClient.removeFinishedSession(session); + mTermuxTerminalSessionActivityClient.removeFinishedSession(session); return true; } - List shortcuts = mActivity.getProperties().getSessionShortcuts(); + List shortcuts = mSessionShortcuts; if (shortcuts != null && !shortcuts.isEmpty()) { int codePointLowerCase = Character.toLowerCase(codePoint); for (int i = shortcuts.size() - 1; i >= 0; i--) { @@ -309,16 +473,16 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio if (codePointLowerCase == shortcut.codePoint) { switch (shortcut.shortcutAction) { case TermuxPropertyConstants.ACTION_SHORTCUT_CREATE_SESSION: - mTermuxTerminalSessionClient.addNewSession(false, null); + mTermuxTerminalSessionActivityClient.addNewSession(false, null); return true; case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION: - mTermuxTerminalSessionClient.switchToSession(true); + mTermuxTerminalSessionActivityClient.switchToSession(true); return true; case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION: - mTermuxTerminalSessionClient.switchToSession(false); + mTermuxTerminalSessionActivityClient.switchToSession(false); return true; case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION: - mTermuxTerminalSessionClient.renameSession(mActivity.getCurrentSession()); + mTermuxTerminalSessionActivityClient.renameSession(mActivity.getCurrentSession()); return true; } } @@ -329,6 +493,27 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio 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) { @@ -338,6 +523,154 @@ public void changeFontSize(boolean increase) { + /** + * 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; @@ -345,17 +678,17 @@ public void shareSessionTranscript() { String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); if (transcriptText == null) return; - try { - // See https://github.com/termux/termux-app/issues/1166. - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim(); - intent.putExtra(Intent.EXTRA_TEXT, transcriptText); - intent.putExtra(Intent.EXTRA_SUBJECT, mActivity.getString(R.string.title_share_transcript)); - mActivity.startActivity(Intent.createChooser(intent, mActivity.getString(R.string.title_share_transcript_with))); - } catch (Exception e) { - Logger.logStackTraceWithMessage("Failed to get share session transcript of length " + transcriptText.length(), e); - } + // 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() { @@ -364,7 +697,7 @@ public void showUrlSelection() { String text = ShellUtils.getTerminalSessionTranscriptText(session, true, true); - LinkedHashSet urlSet = DataUtils.extractUrls(text); + LinkedHashSet urlSet = TermuxUrlUtils.extractUrls(text); if (urlSet.isEmpty()) { new AlertDialog.Builder(mActivity).setMessage(R.string.title_select_url_none_found).show(); return; @@ -376,9 +709,7 @@ public void showUrlSelection() { // Click to copy url to clipboard: final AlertDialog dialog = new AlertDialog.Builder(mActivity).setItems(urls, (di, which) -> { String url = (String) urls[which]; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(url))); - Toast.makeText(mActivity, R.string.msg_select_url_copied_to_clipboard, Toast.LENGTH_LONG).show(); + 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: @@ -387,13 +718,7 @@ public void showUrlSelection() { lv.setOnItemLongClickListener((parent, view, position, id) -> { dialog.dismiss(); String url = (String) urls[position]; - Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - try { - mActivity.startActivity(i, null); - } catch (ActivityNotFoundException e) { - // If no applications match, Android displays a system message. - mActivity.startActivity(Intent.createChooser(i, null)); - } + ShareUtils.openUrl(mActivity, url); return true; }); }); @@ -405,26 +730,63 @@ public void reportIssueFromTranscript() { TerminalSession session = mActivity.getCurrentSession(); if (session == null) return; - String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); + final String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); if (transcriptText == null) return; - transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim(); + 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"; - StringBuilder reportString = new StringBuilder(); + reportString.append("## Transcript\n"); + reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true)); + reportString.append("\n##\n"); - String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue"; + 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); + } - reportString.append("## Transcript\n"); - reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true)); + if (addTermuxDebugInfo) { + String termuxDebugInfo = TermuxUtils.getTermuxDebugMarkdownString(mActivity); + if (termuxDebugInfo != null) + reportString.append("\n\n").append(termuxDebugInfo); + } - reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, true)); - reportString.append("\n\n").append(TermuxUtils.getDeviceInfoMarkdownString(mActivity)); + String userActionName = UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName(); - String termuxAptInfo = TermuxUtils.geAPTInfoMarkdownString(mActivity); - if (termuxAptInfo != null) - reportString.append("\n\n").append(termuxAptInfo); + 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, new ReportInfo(UserAction.REPORT_ISSUE_FROM_TRANSCRIPT, TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title, null, reportString.toString(), "\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity), false)); + ReportActivity.startReportActivity(mActivity, reportInfo); + } + }.start(); } public void doPaste() { @@ -432,12 +794,9 @@ public void doPaste() { if (session == null) return; if (!session.isRunning()) return; - ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null) return; - CharSequence paste = clipData.getItemAt(0).coerceToText(mActivity); - if (!TextUtils.isEmpty(paste)) - session.getEmulator().paste(paste.toString()); + 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/TerminalToolbarViewPager.java b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java index 06bdfe19a9..a526570b09 100644 --- a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java +++ b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -11,7 +11,7 @@ import com.termux.R; import com.termux.app.TermuxActivity; -import com.termux.app.terminal.io.extrakeys.ExtraKeysView; +import com.termux.shared.termux.extrakeys.ExtraKeysView; import com.termux.terminal.TerminalSession; public class TerminalToolbarViewPager { @@ -44,8 +44,11 @@ public Object instantiateItem(@NonNull ViewGroup collection, int position) { 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.getProperties().getExtraKeysInfo()); + extraKeysView.reload(mActivity.getTermuxTerminalExtraKeys().getExtraKeysInfo(), + mActivity.getTerminalToolbarDefaultHeight()); // apply extra keys fix if enabled in prefs if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) { 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/app/terminal/io/extrakeys/ExtraKeyButton.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java deleted file mode 100644 index 9454081970..0000000000 --- a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.termux.app.terminal.io.extrakeys; - -import android.text.TextUtils; - -import androidx.annotation.Nullable; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.Arrays; -import java.util.stream.Collectors; - -public class ExtraKeyButton { - - /** - * The key that will be sent to the terminal, either a control character - * defined in ExtraKeysView.keyCodesForString (LEFT, RIGHT, PGUP...) or - * some text. - */ - private final String key; - - /** - * If the key is a macro, i.e. a sequence of keys separated by space. - */ - private final boolean macro; - - /** - * The text that will be shown on the button. - */ - private final String display; - - /** - * The information of the popup (triggered by swipe up). - */ - @Nullable - private ExtraKeyButton popup; - - public ExtraKeyButton(ExtraKeysInfo.CharDisplayMap charDisplayMap, JSONObject config) throws JSONException { - this(charDisplayMap, config, null); - } - - public ExtraKeyButton(ExtraKeysInfo.CharDisplayMap charDisplayMap, JSONObject config, @Nullable 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] = ExtraKeysInfo.replaceAlias(keys[i]); - } - - this.key = TextUtils.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/terminal/io/extrakeys/ExtraKeysInfo.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java deleted file mode 100644 index 26fec3d6a3..0000000000 --- a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.termux.app.terminal.io.extrakeys; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.HashMap; - -public class ExtraKeysInfo { - - /** - * Matrix of buttons displayed - */ - private final ExtraKeyButton[][] buttons; - - /** - * This corresponds to one of the CharMapDisplay below - */ - private String style; - - public ExtraKeysInfo(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); - } -} - diff --git a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java deleted file mode 100644 index da4cbeaf6d..0000000000 --- a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java +++ /dev/null @@ -1,382 +0,0 @@ -package com.termux.app.terminal.io.extrakeys; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.os.Build; -import android.provider.Settings; -import android.util.AttributeSet; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.ScheduledExecutorService; - -import java.util.Map; -import java.util.HashMap; -import java.util.Arrays; -import java.util.stream.Collectors; - -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 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); - }}; - - @SuppressLint("RtlHardcoded") - 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)) { - Integer keyCode = keyCodesForString.get(keyName); - if (keyCode == null) return; - 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; - boolean isActive = false; - List