diff --git a/.github/workflows/attach_debug_apks_to_release.yml b/.github/workflows/attach_debug_apks_to_release.yml index a17b326443..0c6e3f7635 100644 --- a/.github/workflows/attach_debug_apks_to_release.yml +++ b/.github/workflows/attach_debug_apks_to_release.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ env.GITHUB_REF }} diff --git a/.github/workflows/debug_build.yml b/.github/workflows/debug_build.yml index a1df15ae15..1e1cedccb1 100644 --- a/.github/workflows/debug_build.yml +++ b/.github/workflows/debug_build.yml @@ -19,7 +19,13 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Setup java 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' - name: Build APKs shell: bash {0} @@ -79,7 +85,7 @@ jobs: fi - name: Attach universal APK file - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_universal path: | @@ -87,7 +93,7 @@ jobs: ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach arm64-v8a APK file - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_arm64-v8a path: | @@ -95,7 +101,7 @@ jobs: ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach armeabi-v7a APK file - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_armeabi-v7a path: | @@ -103,7 +109,7 @@ jobs: ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach x86_64 APK file - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_x86_64 path: | @@ -111,7 +117,7 @@ jobs: ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach x86 APK file - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_x86 path: | @@ -119,7 +125,7 @@ jobs: ${{ env.APK_DIR_PATH }}/output-metadata.json - name: Attach sha256sums file - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ env.APK_BASENAME_PREFIX }}_sha256sums path: | diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 9d3978b13b..ae332ae17e 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -13,11 +13,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: 17 - name: Generate and submit dependency graph - uses: gradle/actions/dependency-submission@v4 + uses: gradle/actions/dependency-submission@v5 diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index b9306a157b..7d6e599272 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@v4 - - uses: gradle/wrapper-validation-action@v3 + - uses: actions/checkout@v6 + - uses: gradle/actions/wrapper-validation@5 diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index f8d33fd4fc..69de0254df 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -15,7 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 + - name: Setup java 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' - name: Execute tests run: | ./gradlew test diff --git a/app/build.gradle b/app/build.gradle index 3df93fef10..c2a712aeb0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,6 +13,8 @@ ext { } android { + namespace "com.termux" + compileSdkVersion project.properties.compileSdkVersion.toInteger() ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion def appVersionName = System.getenv("TERMUX_APP_VERSION_NAME") ?: "" @@ -21,24 +23,24 @@ android { 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.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.annotation:annotation:1.9.0" + implementation "androidx.core:core:1.13.1" + implementation "androidx.drawerlayout:drawerlayout:1.2.0" + implementation "androidx.preference:preference:1.2.1" implementation "androidx.viewpager:viewpager:1.0.0" - implementation "com.google.android.material:material:1.4.0" + implementation "com.google.android.material:material:1.12.0" implementation "com.google.guava:guava:24.1-jre" implementation "io.noties.markwon:core:$markwonVersion" implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" implementation "io.noties.markwon:linkify:$markwonVersion" implementation "io.noties.markwon:recycler:$markwonVersion" + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' implementation project(":terminal-view") implementation project(":termux-shared") } defaultConfig { - applicationId "com.termux" minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() versionCode 118 @@ -110,7 +112,7 @@ android { } } - lintOptions { + lint { disable 'ProtectedPermissions' } @@ -138,12 +140,15 @@ android { } } + buildFeatures { + buildConfig true + } } dependencies { testImplementation "junit:junit:4.13.2" testImplementation "org.robolectric:robolectric:4.10" - coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.2" } task versionName { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a01c038960..7365abb1dc 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -10,8 +10,3 @@ -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 7c566c8f0d..f45b30f69c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ 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 a2e4d6a985..7609659e3b 100644 --- a/app/src/main/java/com/termux/app/activities/HelpActivity.java +++ b/app/src/main/java/com/termux/app/activities/HelpActivity.java @@ -35,7 +35,6 @@ protected void onCreate(Bundle savedInstanceState) { mWebView = new WebView(this); WebSettings settings = mWebView.getSettings(); settings.setCacheMode(WebSettings.LOAD_NO_CACHE); - settings.setAppCacheEnabled(false); setContentView(progressLayout); mWebView.clearCache(true); diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java index cd38163116..bd789145f2 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java @@ -271,7 +271,7 @@ private synchronized void loadBellSoundPool() { .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build(); try { - mBellSoundId = mBellSoundPool.load(mActivity, R.raw.bell, 1); + mBellSoundId = mBellSoundPool.load(mActivity, com.termux.shared.R.raw.bell, 1); } catch (Exception e){ // Catch java.lang.RuntimeException: Unable to resume activity {com.termux/com.termux.app.TermuxActivity}: android.content.res.Resources$NotFoundException: File res/raw/bell.ogg from drawable resource ID Logger.logStackTraceWithMessage(LOG_TAG, "Failed to load bell sound pool", e); 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 a3d09d3d52..6aa4af0506 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -13,6 +13,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; +import android.view.accessibility.AccessibilityManager; import android.widget.EditText; import android.widget.ListView; import android.widget.Toast; @@ -60,7 +61,7 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase { 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; + boolean mVirtualControlActive, mVirtualFnActive; private Runnable mShowSoftKeyboardRunnable; @@ -309,20 +310,40 @@ private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) { // Do not steal dedicated buttons from a full external keyboard. return false; } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { - mVirtualControlKeyDown = down; + if (down) { + mVirtualControlActive = !mVirtualControlActive; + announceAssertively(mVirtualControlActive ? "Control On" : "Control Off"); + } return true; } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - mVirtualFnKeyDown = down; + if (down) { + mVirtualFnActive = !mVirtualFnActive; + announceAssertively(mVirtualFnActive ? "Fn On" : "Fn Off"); + } return true; } return false; } + private void announceAssertively(String text) { + AccessibilityManager am = (AccessibilityManager) mActivity.getSystemService(Context.ACCESSIBILITY_SERVICE); + if (am != null && am.isEnabled()) { + am.interrupt(); + } + mActivity.getTerminalView().announceForAccessibility(text); + } + @Override public boolean readControlKey() { - return readExtraKeysSpecialButton(SpecialButton.CTRL) || mVirtualControlKeyDown; + if (readExtraKeysSpecialButton(SpecialButton.CTRL)) return true; + if (mVirtualControlActive) { + mVirtualControlActive = false; + announceAssertively("Control Off"); + return true; + } + return false; } @Override @@ -337,7 +358,13 @@ public boolean readShiftKey() { @Override public boolean readFnKey() { - return readExtraKeysSpecialButton(SpecialButton.FN); + if (readExtraKeysSpecialButton(SpecialButton.FN)) return true; + if (mVirtualFnActive) { + mVirtualFnActive = false; + announceAssertively("Fn Off"); + return true; + } + return false; } public boolean readExtraKeysSpecialButton(SpecialButton specialButton) { @@ -359,7 +386,10 @@ public boolean onLongPress(MotionEvent event) { @Override public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) { - if (mVirtualFnKeyDown) { + if (mVirtualFnActive) { + mVirtualFnActive = false; + announceAssertively("Fn Off"); + int resultingKeyCode = -1; int resultingCodePoint = -1; boolean altDown = false; @@ -448,7 +478,7 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio case 'q': case 'k': mActivity.toggleTerminalToolbar(); - mVirtualFnKeyDown=false; // force disable fn key down to restore keyboard input into terminal view, fixes termux/termux-app#1420 + // mVirtualFnActive=false; // Keep Manual Toggle break; } @@ -735,8 +765,8 @@ public void reportIssueFromTranscript() { 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), + mActivity.getString(com.termux.shared.R.string.action_yes), (dialog, which) -> reportIssueFromTranscript(transcriptText, true), + mActivity.getString(com.termux.shared.R.string.action_no), (dialog, which) -> reportIssueFromTranscript(transcriptText, false), null); } diff --git a/build.gradle b/build.gradle index 80ddd4bde1..65f0e42715 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { google() } dependencies { - classpath "com.android.tools.build:gradle:4.2.2" + classpath "com.android.tools.build:gradle:8.13.2" } } @@ -15,7 +15,3 @@ allprojects { maven { url "https://jitpack.io" } } } - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/gradle.properties b/gradle.properties index 90de122196..8d4bdd26be 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,8 +22,7 @@ android.useAndroidX=true minSdkVersion=21 targetSdkVersion=28 -ndkVersion=22.1.7171670 -compileSdkVersion=30 +ndkVersion=29.0.14206865 +compileSdkVersion=36 markwonVersion=4.6.2 - diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023..f8e1ee3125 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a0f7639f7d..23449a2b54 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c81..adff685a03 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd32c4..c4bdd3ab8e 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/jitpack.yml b/jitpack.yml index 900c8ffd65..922f8ccccd 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,4 +1,4 @@ jdk: - - openjdk11 + - openjdk17 env: - JITPACK_NDK_VERSION: "21.1.6352462" + JITPACK_NDK_VERSION: "29.0.14206865" diff --git a/terminal-emulator/build.gradle b/terminal-emulator/build.gradle index afc57e9f7e..dbaa4cb1e9 100644 --- a/terminal-emulator/build.gradle +++ b/terminal-emulator/build.gradle @@ -2,6 +2,8 @@ apply plugin: 'com.android.library' apply plugin: 'maven-publish' android { + namespace "com.termux.emulator" + compileSdkVersion project.properties.compileSdkVersion.toInteger() ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion @@ -50,13 +52,13 @@ tasks.withType(Test) { } dependencies { - implementation "androidx.annotation:annotation:1.3.0" + implementation "androidx.annotation:annotation:1.9.0" testImplementation "junit:junit:4.13.2" } task sourceJar(type: Jar) { from android.sourceSets.main.java.srcDirs - classifier "sources" + archiveClassifier = "sources" } afterEvaluate { @@ -64,7 +66,7 @@ afterEvaluate { publications { // Creates a Maven publication called "release". release(MavenPublication) { - from components.release + from components.findByName('release') groupId = 'com.termux' artifactId = 'terminal-emulator' version = '0.118.0' diff --git a/terminal-emulator/src/main/AndroidManifest.xml b/terminal-emulator/src/main/AndroidManifest.xml index a293cb643b..bdae66c8f5 100644 --- a/terminal-emulator/src/main/AndroidManifest.xml +++ b/terminal-emulator/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - + diff --git a/terminal-view/build.gradle b/terminal-view/build.gradle index d603da2880..e74e0ce293 100644 --- a/terminal-view/build.gradle +++ b/terminal-view/build.gradle @@ -2,10 +2,13 @@ apply plugin: 'com.android.library' apply plugin: 'maven-publish' android { + namespace "com.termux.view" compileSdkVersion project.properties.compileSdkVersion.toInteger() dependencies { - implementation "androidx.annotation:annotation:1.3.0" + implementation "androidx.annotation:annotation:1.9.0" + implementation "androidx.core:core:1.13.1" + implementation "androidx.customview:customview:1.1.0" api project(":terminal-emulator") } @@ -34,7 +37,7 @@ dependencies { task sourceJar(type: Jar) { from android.sourceSets.main.java.srcDirs - classifier "sources" + archiveClassifier = "sources" } afterEvaluate { @@ -42,7 +45,7 @@ afterEvaluate { publications { // Creates a Maven publication called "release". release(MavenPublication) { - from components.release + from components.findByName('release') groupId = 'com.termux' artifactId = 'terminal-view' version = '0.118.0' @@ -51,3 +54,4 @@ afterEvaluate { } } } + diff --git a/terminal-view/src/main/AndroidManifest.xml b/terminal-view/src/main/AndroidManifest.xml index f2b0725df4..bdae66c8f5 100644 --- a/terminal-view/src/main/AndroidManifest.xml +++ b/terminal-view/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - + diff --git a/terminal-view/src/main/java/com/termux/view/TerminalAccessibilityHelper.java b/terminal-view/src/main/java/com/termux/view/TerminalAccessibilityHelper.java new file mode 100644 index 0000000000..f7fcf4d7ce --- /dev/null +++ b/terminal-view/src/main/java/com/termux/view/TerminalAccessibilityHelper.java @@ -0,0 +1,125 @@ +package com.termux.view; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Bundle; +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.inputmethod.InputMethodManager; + +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.customview.widget.ExploreByTouchHelper; + +import com.termux.terminal.TerminalEmulator; + +import java.util.List; + +public class TerminalAccessibilityHelper extends ExploreByTouchHelper { + private final TerminalView mView; + + public TerminalAccessibilityHelper(@NonNull TerminalView view) { + super(view); + mView = view; + } + + @Override + protected int getVirtualViewAt(float x, float y) { + if (mView.mEmulator == null || mView.mRenderer == null) return HOST_ID; + int row = (int) (y / mView.mRenderer.getFontLineSpacing()); + if (row >= 0 && row < mView.mEmulator.mRows) { + // Find the start of the logical block (paragraph) this row belongs to + int startRow = row; + while (startRow > 0 && mView.mEmulator.getScreen().getLineWrap(mView.mTopRow + startRow - 1)) { + startRow--; + } + return startRow; + } + return HOST_ID; + } + + @Override + protected void getVisibleVirtualViews(List virtualViewIds) { + if (mView.mEmulator == null) return; + for (int i = 0; i < mView.mEmulator.mRows; ) { + virtualViewIds.add(i); + int current = i; + while (current < mView.mEmulator.mRows - 1 && + mView.mEmulator.getScreen().getLineWrap(mView.mTopRow + current)) { + current++; + } + i = current + 1; + } + } + + @Override + protected void onPopulateNodeForVirtualView(int virtualViewId, @NonNull AccessibilityNodeInfoCompat node) { + if (mView.mEmulator == null || mView.mRenderer == null) { + node.setText(""); + node.setBoundsInParent(new Rect(0, 0, 1, 1)); + return; + } + + int startRow = virtualViewId; + int endRow = startRow; + while (endRow < mView.mEmulator.mRows - 1 && + mView.mEmulator.getScreen().getLineWrap(mView.mTopRow + endRow)) { + endRow++; + } + + // Get text for the logical block + int externalStartRow = mView.mTopRow + startRow; + int externalEndRow = mView.mTopRow + endRow; + + // getSelectedText(..., Y2) is inclusive in TerminalBuffer + String text = mView.mEmulator.getScreen().getSelectedText(0, externalStartRow, mView.mEmulator.mColumns, externalEndRow, true, false).trim(); + if (text.isEmpty()) text = "Blank"; + + node.setText(text); + node.setContentDescription(text); + + // Bounds: from top of startRow to bottom of endRow + int top = startRow * mView.mRenderer.getFontLineSpacing(); + int bottom = (endRow + 1) * mView.mRenderer.getFontLineSpacing(); + int width = mView.getWidth(); + node.setBoundsInParent(new Rect(0, top, width, bottom)); + + node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK); + } + + @Override + protected boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) { + if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) { + // 1. Request Focus & Show Keyboard + mView.requestFocus(); + InputMethodManager imm = (InputMethodManager) mView.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(mView, 0); + } + + // 2. Simulate Mouse Click to move cursor (if supported by app like Emacs) + // We aim for the start of the line (x=5 pixels buffer) vertically centered in the row + // Check if mouse tracking is active to avoid sending escape codes to shells that don't support it. + if (mView.mEmulator.isMouseTrackingActive()) { + int row = virtualViewId; + float y = (row + 0.5f) * mView.mRenderer.getFontLineSpacing(); + float x = 5.0f; + + long downTime = SystemClock.uptimeMillis(); + MotionEvent eventDown = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0); + eventDown.setSource(InputDevice.SOURCE_MOUSE); + mView.sendMouseEventCode(eventDown, TerminalEmulator.MOUSE_LEFT_BUTTON, true); + eventDown.recycle(); + + MotionEvent eventUp = MotionEvent.obtain(downTime, downTime + 10, MotionEvent.ACTION_UP, x, y, 0); + eventUp.setSource(InputDevice.SOURCE_MOUSE); + mView.sendMouseEventCode(eventUp, TerminalEmulator.MOUSE_LEFT_BUTTON, false); + eventUp.recycle(); + } + + return true; + } + return false; + } +} diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index 435c102515..7ffd5e3c5b 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -26,16 +26,20 @@ import android.view.View; import android.view.ViewConfiguration; import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; import android.view.autofill.AutofillManager; import android.view.autofill.AutofillValue; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; import android.widget.Scroller; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.core.view.ViewCompat; import com.termux.terminal.KeyHandler; import com.termux.terminal.TerminalEmulator; @@ -125,6 +129,13 @@ public final class TerminalView extends View { private final boolean mAccessibilityEnabled; + private String mLastScreenAccessibilityText = ""; + private int mLastCursorRow = -1; + private int mLastCursorCol = -1; + private String mLastAnnouncedText = ""; + private boolean mPendingBackspace = false; + private final TerminalAccessibilityHelper mAccessibilityHelper; + /** The {@link KeyEvent} is generated from a virtual keyboard, like manually with the {@link KeyEvent#KeyEvent(int, int)} constructor. */ public final static int KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD = KeyCharacterMap.VIRTUAL_KEYBOARD; // -1 @@ -259,6 +270,9 @@ public void onLongPress(MotionEvent event) { mScroller = new Scroller(context); AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); mAccessibilityEnabled = am.isEnabled(); + + mAccessibilityHelper = new TerminalAccessibilityHelper(this); + ViewCompat.setAccessibilityDelegate(this, mAccessibilityHelper); } @@ -310,7 +324,14 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { // initially started with the alternate view or if activity is returned to from another app // and the alternate view was the one selected the last time. if (mClient.isTerminalViewSelected()) { - if (mClient.shouldEnforceCharBasedInput()) { + if (mAccessibilityEnabled) { + // If accessibility is enabled, we force a standard text type so services like + // TalkBack Braille Keyboard recognize it as a valid editor. + // We use VISIBLE_PASSWORD to disable auto-correct/composition on Gboard. + // We add MULTI_LINE to ensure Braille Keyboard enables the Return key. + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_MULTI_LINE; + outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE | EditorInfo.IME_FLAG_NO_FULLSCREEN; + } else if (mClient.shouldEnforceCharBasedInput()) { // Some keyboards seems do not reset the internal state on TYPE_NULL. // Affects mostly Samsung stock keyboards. // https://github.com/termux/termux-app/issues/686 @@ -334,9 +355,12 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL; } - // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen - // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221). - outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; + if (!mAccessibilityEnabled) { + // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen + // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221). + // We only apply this check if NOT in accessibility mode, as accessibility needs explicit actions. + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; + } return new BaseInputConnection(this, true) { @@ -367,6 +391,7 @@ public boolean commitText(CharSequence text, int newCursorPosition) { @Override public boolean deleteSurroundingText(int leftLength, int rightLength) { + mPendingBackspace = true; if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { mClient.logInfo(LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")"); } @@ -454,6 +479,104 @@ public void onScreenUpdated() { onScreenUpdated(false); } + private final Runnable mAccessibilityBufferRunnable = () -> { + if (mEmulator == null) return; + processAccessibilityEvent(); + }; + + private void processAccessibilityEvent() { + String newText = getText().toString(); + String oldText = mLastScreenAccessibilityText; + int newCursorRow = mEmulator.getCursorRow(); + int newCursorCol = mEmulator.getCursorCol(); + + int oldLen = oldText.length(); + int newLen = newText.length(); + int minLen = Math.min(oldLen, newLen); + + int commonPrefixLen = 0; + while (commonPrefixLen < minLen && oldText.charAt(commonPrefixLen) == newText.charAt(commonPrefixLen)) { + commonPrefixLen++; + } + + int commonSuffixLen = 0; + while (commonSuffixLen < minLen - commonPrefixLen && + oldText.charAt(oldLen - 1 - commonSuffixLen) == newText.charAt(newLen - 1 - commonSuffixLen)) { + commonSuffixLen++; + } + + int addedLen = newLen - commonPrefixLen - commonSuffixLen; + int removedLen = oldLen - commonPrefixLen - commonSuffixLen; + + boolean isLargeDiff = addedLen > 50; + boolean isSmallCursorMove = false; + if (mLastCursorRow != -1) { + isSmallCursorMove = Math.abs(newCursorRow - mLastCursorRow) < 3; + } + + boolean isScreenContentMaintained = false; + boolean isContentStatic = false; + if (isLargeDiff && oldLen > 100 && newLen > 100) { + int sampleLen = 50; + int start = Math.max(0, oldLen / 2 - sampleLen / 2); + int end = Math.min(oldLen, start + sampleLen); + if (end > start) { + String sample = oldText.substring(start, end); + int newIndex = newText.indexOf(sample); + if (newIndex != -1) { + isScreenContentMaintained = true; + // Heuristic: If content moves significantly (> 100 chars), it's likely a scroll rather than jitter. + isContentStatic = Math.abs(start - newIndex) < 100; + } + } + } + + if (isLargeDiff && isSmallCursorMove && isScreenContentMaintained && isContentStatic) { + announceCursorMovement(newCursorRow, newCursorCol); + } else if (addedLen > 0) { + String addedContent = newText.substring(commonPrefixLen, commonPrefixLen + addedLen); + + int cursorIndex = getCursorIndex(); + boolean isOutputAfterCursor = commonPrefixLen > cursorIndex; + boolean isCursorMoved = newCursorRow != mLastCursorRow || newCursorCol != mLastCursorCol; + + if (isOutputAfterCursor && isCursorMoved) { + // Prioritize announcing the cursor position over background updates (e.g. status bar in Emacs). + announceCursorMovement(newCursorRow, newCursorCol); + } else { + // Suppress single character additions to avoid double-speech with TalkBack's keyboard echo. + if (addedLen > 1 && !addedContent.trim().isEmpty()) { + announceForAccessibility(sanitizeAccessibilityText(addedContent)); + } + } + } else if (removedLen > 0) { + String removedContent = oldText.substring(commonPrefixLen, commonPrefixLen + removedLen); + if (removedContent.equals("\n")) removedContent = "New line"; + else if (removedContent.equals(" ")) removedContent = "Space"; + else if (removedContent.equals("\t")) removedContent = "Tab"; + announceForAccessibility(sanitizeAccessibilityText(removedContent) + " deleted"); + } else if (mPendingBackspace && removedLen == 0 && newCursorRow == mLastCursorRow && newCursorCol == mLastCursorCol - 1) { + announceForAccessibility("Space deleted"); + } else if (newCursorRow != mLastCursorRow || newCursorCol != mLastCursorCol) { + announceCursorMovement(newCursorRow, newCursorCol); + } + + mPendingBackspace = false; + + // Notify IMM + if (newCursorRow != mLastCursorRow || newCursorCol != mLastCursorCol) { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + int cursorIndex = getCursorIndex(); + imm.updateSelection(this, cursorIndex, cursorIndex, -1, -1); + } + } + + mLastScreenAccessibilityText = newText; + mLastCursorRow = newCursorRow; + mLastCursorCol = newCursorCol; + } + public void onScreenUpdated(boolean skipScrolling) { if (mEmulator == null) return; @@ -495,7 +618,96 @@ public void onScreenUpdated(boolean skipScrolling) { mEmulator.clearScrollCounter(); invalidate(); - if (mAccessibilityEnabled) setContentDescription(getText()); + if (mAccessibilityEnabled) { + // Buffer all updates to handle complex output and debounce navigation/typing. + // This ensures consistent diffing logic and avoids race conditions or event conflicts. + getHandler().removeCallbacks(mAccessibilityBufferRunnable); + getHandler().postDelayed(mAccessibilityBufferRunnable, 100); + } + } + + private void announceCursorMovement(int targetRow, int targetCol) { + // Simple immediate announcement, no delay needed here because buffering handles the delay + if (targetRow != mLastCursorRow) { // Note: mLastCursorRow here is the OLD state before processAccessibilityEvent updates it? + // Wait, processAccessibilityEvent calls this BEFORE updating mLastCursorRow. + // So targetRow (new) != mLast (old). Correct. + // Fix: selY2 is inclusive, so we pass mTopRow + targetRow to select only that row. + String lineText = mEmulator.getScreen().getSelectedText(0, mTopRow + targetRow, mEmulator.mColumns, mTopRow + targetRow, false, false).trim(); + if (lineText.isEmpty()) lineText = "Blank"; + announceText(lineText); + } else { + // Fix: selY2 is inclusive. selX2 is also used to calculate limit (x2 = selX2 + 1). + // To get a single char at targetCol, we pass targetCol as both start and end X. + String charText = mEmulator.getScreen().getSelectedText(targetCol, mTopRow + targetRow, targetCol, mTopRow + targetRow, false, false); + if (charText.isEmpty() || charText.equals(" ")) charText = "Space"; + announceText(charText); + } + } + + private String sanitizeAccessibilityText(String text) { + if (text == null) return ""; + // Sanitize for accessibility: Remove line continuation backslashes + text = text.replaceAll("\\\\\\n", ""); + if (text.endsWith("\\")) { + text = text.substring(0, text.length() - 1); + } + return text; + } + + private void announceText(String text) { + text = sanitizeAccessibilityText(text); + + if (text.isEmpty() || text.equals(mLastAnnouncedText)) { + return; + } + mLastAnnouncedText = text; + announceForAccessibility(text); + } + + private int getCursorIndex() { + if (mEmulator == null) return 0; + int rowStart = mTopRow; + int rowEnd = mTopRow + mEmulator.getCursorRow(); + // Get text before the current line + String textBefore = mEmulator.getScreen().getSelectedText(0, rowStart, mEmulator.mColumns, rowEnd, true, true); + // Get text of the current line + String currentRowText = mEmulator.getScreen().getSelectedText(0, rowEnd, mEmulator.mColumns, rowEnd + 1, true, true); + + int colIndex = mEmulator.getCursorCol(); + // Clamp column index to the length of the line representation + if (colIndex > currentRowText.length()) { + colIndex = currentRowText.length(); + } + + return textBefore.length() + colIndex; + } + + private String getAddedText(String oldText, String newText) { + // Keep helper for potential future use or debugging, though logic is now inline for events + if (oldText == null || oldText.isEmpty()) return newText; + if (newText == null || newText.isEmpty()) return ""; + if (newText.equals(oldText)) return ""; + + int m = newText.length(); + int[] pi = new int[m]; + int k = 0; + for (int q = 1; q < m; q++) { + while (k > 0 && newText.charAt(k) != newText.charAt(q)) + k = pi[k-1]; + if (newText.charAt(k) == newText.charAt(q)) + k++; + pi[q] = k; + } + + int q = 0; + int n = oldText.length(); + for (int i = 0; i < n; i++) { + while (q > 0 && (q == m || newText.charAt(q) != oldText.charAt(i))) + q = pi[q-1]; + if (q < m && newText.charAt(q) == oldText.charAt(i)) + q++; + } + return newText.substring(q); } /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)} @@ -642,6 +854,25 @@ public boolean onTouchEvent(MotionEvent event) { return true; } + @Override + public void sendAccessibilityEvent(int eventType) { + // Suppress WINDOW_CONTENT_CHANGED events to prevent TalkBack from automatically + // reading the entire screen when the content changes (e.g. status bar clock updates). + // We handle relevant updates manually via announceForAccessibility or TYPE_VIEW_TEXT_CHANGED. + if (eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) { + return; + } + super.sendAccessibilityEvent(eventType); + } + + @Override + public boolean dispatchHoverEvent(MotionEvent event) { + if (mAccessibilityHelper != null && mAccessibilityHelper.dispatchHoverEvent(event)) { + return true; + } + return super.dispatchHoverEvent(event); + } + @Override public boolean onKeyPreIme(int keyCode, KeyEvent event) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) @@ -767,6 +998,7 @@ public boolean onKeyPreIme(int keyCode, KeyEvent event) { */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { + mPendingBackspace = (keyCode == KeyEvent.KEYCODE_DEL); if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")"); if (mEmulator == null) return true; diff --git a/termux-shared/build.gradle b/termux-shared/build.gradle index 30e9536282..6576a50a72 100644 --- a/termux-shared/build.gradle +++ b/termux-shared/build.gradle @@ -2,14 +2,16 @@ apply plugin: 'com.android.library' apply plugin: 'maven-publish' android { + namespace = "com.termux.shared" + compileSdkVersion project.properties.compileSdkVersion.toInteger() ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion dependencies { - implementation "androidx.appcompat:appcompat:1.3.1" - implementation "androidx.annotation:annotation:1.3.0" - implementation "androidx.core:core:1.6.0" - implementation "com.google.android.material:material:1.4.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.annotation:annotation:1.9.0" + implementation "androidx.core:core:1.13.1" + implementation "com.google.android.material:material:1.12.0" implementation "com.google.guava:guava:24.1-jre" implementation "io.noties.markwon:core:$markwonVersion" implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" @@ -17,9 +19,7 @@ android { implementation "io.noties.markwon:recycler:$markwonVersion" implementation "org.lsposed.hiddenapibypass:hiddenapibypass:6.1" - // Do not increment version higher than 1.0.0-alpha09 since it will break ViewUtils and needs to be looked into - // noinspection GradleDependency - implementation "androidx.window:window:1.0.0-alpha09" + implementation "androidx.window:window:1.1.0" // Do not increment version higher than 2.5 or there // will be runtime exceptions on android < 8 @@ -32,6 +32,7 @@ android { } defaultConfig { + compileSdkVersion project.properties.compileSdkVersion.toInteger() minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -56,6 +57,7 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + externalNativeBuild { ndkBuild { path file('src/main/cpp/Android.mk') @@ -65,14 +67,13 @@ android { dependencies { testImplementation "junit:junit:4.13.2" - androidTestImplementation "androidx.test.ext:junit:1.1.3" - androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" - coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" + androidTestImplementation "androidx.test.ext:junit:1.1.5" + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.2" } task sourceJar(type: Jar) { from android.sourceSets.main.java.srcDirs - classifier "sources" + archiveClassifier = "sources" } afterEvaluate { @@ -80,7 +81,7 @@ afterEvaluate { publications { // Creates a Maven publication called "release". release(MavenPublication) { - from components.release + from components.findByName('release') groupId = 'com.termux' artifactId = 'termux-shared' version = '0.118.0' diff --git a/termux-shared/src/main/AndroidManifest.xml b/termux-shared/src/main/AndroidManifest.xml index 81789f3904..042e61a707 100644 --- a/termux-shared/src/main/AndroidManifest.xml +++ b/termux-shared/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java b/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java index d7543fc7c9..9ecb4400e1 100644 --- a/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java @@ -51,7 +51,7 @@ public static void showMessage(Context context, String titleText, String message final DialogInterface.OnClickListener onNegativeButton, final DialogInterface.OnDismissListener onDismiss) { - AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.Theme_AppCompat_Light_Dialog); + AlertDialog.Builder builder = new AlertDialog.Builder(context, androidx.appcompat.R.style.Theme_AppCompat_Light_Dialog); LayoutInflater inflater = (LayoutInflater) context.getSystemService( Context.LAYOUT_INFLATER_SERVICE ); View view = inflater.inflate(R.layout.dialog_show_message, null); diff --git a/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java b/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java index 4fbbf1e171..8701780253 100644 --- a/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java +++ b/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java @@ -412,6 +412,13 @@ public void reload(ExtraKeysInfo extraKeysInfo, float heightPx) { button.setText(buttonInfo.getDisplay()); button.setTextColor(mButtonTextColor); button.setAllCaps(mButtonTextAllCaps); + + // Set accessible labels for extra keys (e.g. "Up" instead of emoji) + String contentDescription = getContentDescriptionForKey(buttonInfo.getKey()); + if (contentDescription != null) { + button.setContentDescription(contentDescription); + } + button.setPadding(0, 0, 0, 0); button.setOnClickListener(view -> { @@ -419,6 +426,7 @@ public void reload(ExtraKeysInfo extraKeysInfo, float heightPx) { onAnyExtraKeyButtonClick(view, buttonInfo, button); }); + button.setOnTouchListener((view, event) -> { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: @@ -678,4 +686,37 @@ public static int maximumLength(Object[][] matrix) { return m; } + private String getContentDescriptionForKey(String key) { + if (key == null) return null; + switch (key) { + case "UP": return "Up"; + case "DOWN": return "Down"; + case "LEFT": return "Left"; + case "RIGHT": return "Right"; + case "ENTER": return "Enter"; + case "BKSP": return "Backspace"; + case "DEL": return "Delete"; + case "TAB": return "Tab"; + case "HOME": return "Home"; + case "END": return "End"; + case "PGUP": return "Page Up"; + case "PGDN": return "Page Down"; + case "ESC": return "Escape"; + case "CTRL": return "Control"; + case "ALT": return "Alt"; + case "FN": return "Function"; + case "DRAWER": return "Drawer"; + case "KEYBOARD": return "Keyboard"; + case "SPACE": return "Space"; + case "PASTE": return "Paste"; + case "SCROLL": return "Scroll"; + case "-": return "Hyphen"; + case "/": return "Slash"; + case "APOSTROPHE": return "Apostrophe"; + case "QUOTE": return "Quote"; + case "BACKSLASH": return "Backslash"; + default: return null; + } + } + } diff --git a/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java b/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java index 606dcc4f5e..f9b58ac543 100644 --- a/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java +++ b/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/SpecialButtonState.java @@ -40,12 +40,17 @@ public void setIsActive(boolean value) { isActive = value; for (MaterialButton button : buttons) { button.setTextColor(value ? mExtraKeysView.getButtonActiveTextColor() : mExtraKeysView.getButtonTextColor()); + String text = button.getText().toString(); + String stateText = value ? (isLocked ? " Locked" : " On") : " Off"; + button.setContentDescription(text + stateText); } } /** Set {@link #isLocked}. */ public void setIsLocked(boolean value) { isLocked = value; + // Update appearance/description + setIsActive(isActive); } } diff --git a/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java b/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java index 8aa77772f8..5b5d2ec6ab 100644 --- a/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java +++ b/termux-shared/src/main/java/com/termux/shared/termux/terminal/io/TerminalExtraKeys.java @@ -26,6 +26,10 @@ public TerminalExtraKeys(@NonNull TerminalView terminalView) { @Override public void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, MaterialButton button) { + // Ensure TerminalView has focus so that the keyboard input goes to it, + // and so that the keyboard remains open if it was already open. + mTerminalView.requestFocus(); + if (buttonInfo.isMacro()) { String[] keys = buttonInfo.getKey().split(" "); boolean ctrlDown = false; diff --git a/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java b/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java index 2f62d74d3c..b245e79b93 100644 --- a/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java @@ -185,12 +185,11 @@ public static int getDisplayOrientation(@NonNull Context context) { public static Point getDisplaySize( @NonNull Context context, boolean activitySize) { // android.view.WindowManager.getDefaultDisplay() and Display.getSize() are deprecated in // API 30 and give wrong values in API 30 for activitySize=false in multi-window - androidx.window.WindowManager windowManager = new androidx.window.WindowManager(context); - androidx.window.WindowMetrics windowMetrics; + androidx.window.layout.WindowMetrics windowMetrics; if (activitySize) - windowMetrics = windowManager.getCurrentWindowMetrics(); + windowMetrics = androidx.window.layout.WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context); else - windowMetrics = windowManager.getMaximumWindowMetrics(); + windowMetrics = androidx.window.layout.WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(context); return new Point(windowMetrics.getBounds().width(), windowMetrics.getBounds().height()); }