diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d370b695b..305cac117 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,6 +30,7 @@ A clear and concise description of what you expected to happen. **Additional information** -* Termux application version: +* termux-api application version: +* termux-api package version (installed through apt): * Android OS version: * Device model: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..a83ef3851 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: github-actions + directory: / + schedule: + interval: daily diff --git a/.github/workflows/attach_debug_apks_to_release.yml b/.github/workflows/attach_debug_apks_to_release.yml deleted file mode 100644 index 05540dcf0..000000000 --- a/.github/workflows/attach_debug_apks_to_release.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Attach Debug APKs To Release - -on: - release: - types: - - published - -jobs: - attach-apks: - runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Clone repository - uses: actions/checkout@v2 - with: - ref: ${{ env.GITHUB_REF }} - - name: Set vars - run: | - 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 - echo "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." - exit 1 - fi - echo "TERMUX_API_RELEASE_TAG=$RELEASE_VERSION_NAME" >> $GITHUB_ENV - echo "TERMUX_API_APK_VERSION_TAG=$RELEASE_VERSION_NAME+github-debug" >> $GITHUB_ENV # Used by app/build.gradle - - name: Echo release - run: echo "Attaching debug APK to '${{ env.TERMUX_API_RELEASE_TAG }}' release" - - name: Build - run: ./gradlew assembleDebug - - name: Attach APKs to release - run: >- - hub release edit - -m "" - -a "./app/build/outputs/apk/debug/termux-api_${{ env.TERMUX_API_APK_VERSION_TAG }}.apk" - "${{ env.TERMUX_API_RELEASE_TAG }}" diff --git a/.github/workflows/debug_build.yml b/.github/workflows/debug_build.yml deleted file mode 100644 index 239a65dde..000000000 --- a/.github/workflows/debug_build.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build - -on: push - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Clone repository - uses: actions/checkout@v2 - - name: Set vars - run: | - # Set NEW_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/")" - NEW_VERSION_NAME="$CURRENT_VERSION_NAME+${GITHUB_SHA:0:7}" - echo "TERMUX_WIDGET_APP_VERSION_NAME=$NEW_VERSION_NAME" >> $GITHUB_ENV # Used by app/build.gradle - echo "TERMUX_WIDGET_APK_VERSION_TAG=v$NEW_VERSION_NAME-github-debug" >> $GITHUB_ENV # Used by app/build.gradle - - name: Echo version - run: echo "Building APK for '$TERMUX_WIDGET_APP_VERSION_NAME'" - - name: Build - run: ./gradlew assembleDebug - - name: Store generated APK file - uses: actions/upload-artifact@v2 - with: - name: termux-api - path: | - ./app/build/outputs/apk/debug/termux-api_${{ env.TERMUX_WIDGET_APK_VERSION_TAG }}.apk - ./app/build/outputs/apk/debug/output-metadata.json diff --git a/.github/workflows/github_action_build.yml b/.github/workflows/github_action_build.yml new file mode 100644 index 000000000..4fa6ad7b1 --- /dev/null +++ b/.github/workflows/github_action_build.yml @@ -0,0 +1,75 @@ +name: GitHub Action Build + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: "15 0 1 */2 *" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Build + shell: bash {0} + 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 release version '${RELEASE_VERSION_NAME/v/}' generated from current version '$CURRENT_VERSION_NAME' 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.github.debug" # Note the ".", GITHUB_SHA will already have "+" before it + APK_BASENAME_PREFIX="termux-api-app_$APK_VERSION_TAG" + + # Used by upload step 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 APK file for '$RELEASE_VERSION_NAME' release with '$APK_VERSION_TAG' tag" + export TERMUX_API_APP__BUILD__APP_VERSION_NAME="${RELEASE_VERSION_NAME/v/}" # Used by app/build.gradle + export TERMUX_API_APP__BUILD__APK_VERSION_TAG="$APK_VERSION_TAG" # Used by app/build.gradle + if ! ./gradlew assembleDebug; then + exit_on_error "Build failed for '$RELEASE_VERSION_NAME' release with '$APK_VERSION_TAG' tag." + fi + + echo "Validating APK file" + if ! test -f "$APK_DIR_PATH/${APK_BASENAME_PREFIX}.apk"; then + files_found="$(ls "$APK_DIR_PATH")" + exit_on_error "Failed to find built APK file at '$APK_DIR_PATH/${APK_BASENAME_PREFIX}.apk'. Files found: "$'\n'"$files_found" + fi + + echo "Generating checksums-sha256.txt file" + if ! (cd "$APK_DIR_PATH"; sha256sum "${APK_BASENAME_PREFIX}.apk" > checksums-sha256.txt); then + exit_on_error "Generate checksums-sha256.txt file failed for '$RELEASE_VERSION_NAME' release." + fi + echo "checksums-sha256.txt:"$'\n```\n'"$(cat "$APK_DIR_PATH/checksums-sha256.txt")"$'\n```' + + - name: Upload files to action + uses: actions/upload-artifact@v4 + with: + name: ${{ env.APK_BASENAME_PREFIX }} + path: | + ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}.apk + ${{ env.APK_DIR_PATH }}/checksums-sha256.txt + ${{ env.APK_DIR_PATH }}/output-metadata.json diff --git a/.github/workflows/github_release_build.yml b/.github/workflows/github_release_build.yml new file mode 100644 index 000000000..b438db9ec --- /dev/null +++ b/.github/workflows/github_release_build.yml @@ -0,0 +1,64 @@ +name: GitHub Release Build + +on: + release: + types: + - published + +jobs: + build: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + ref: ${{ env.GITHUB_REF }} + + - name: Build and upload files to release + shell: bash {0} + 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 release version '${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+github.debug" + APK_BASENAME_PREFIX="termux-api-app_$APK_VERSION_TAG" + + echo "Building APK file for '$RELEASE_VERSION_NAME' release with '$APK_VERSION_TAG' tag" + export TERMUX_API_APP__BUILD__APK_VERSION_TAG="$APK_VERSION_TAG" # Used by app/build.gradle + if ! ./gradlew assembleDebug; then + exit_on_error "Build failed for '$RELEASE_VERSION_NAME' release with '$APK_VERSION_TAG' tag." + fi + + echo "Validating APK file" + if ! test -f "$APK_DIR_PATH/${APK_BASENAME_PREFIX}.apk"; then + files_found="$(ls "$APK_DIR_PATH")" + exit_on_error "Failed to find built APK file at '$APK_DIR_PATH/${APK_BASENAME_PREFIX}.apk'. Files found: "$'\n'"$files_found" + fi + + echo "Generating checksums-sha256.txt file" + if ! (cd "$APK_DIR_PATH"; sha256sum "${APK_BASENAME_PREFIX}.apk" > checksums-sha256.txt); then + exit_on_error "Generate checksums-sha256.txt file failed for '$RELEASE_VERSION_NAME' release." + fi + echo "checksums-sha256.txt:"$'\n```\n'"$(cat "$APK_DIR_PATH/checksums-sha256.txt")"$'\n```' + + echo "Uploading files to release" + if ! gh release upload "$RELEASE_VERSION_NAME" \ + "$APK_DIR_PATH/${APK_BASENAME_PREFIX}.apk" \ + "$APK_DIR_PATH/checksums-sha256.txt" \ + ; then + exit_on_error "Upload files to release failed for '$RELEASE_VERSION_NAME' release." + fi diff --git a/.gitignore b/.gitignore index 247b4bcbc..002f6725b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ local.properties .Trashes ehthumbs.db Thumbs.db + +# Temp/backup files +*~ diff --git a/README.md b/README.md index b7eb269f3..2fc25b279 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,18 @@ allowed to call the API methods in this app). ## Installation +Latest version is `v0.51.0`. + Termux:API application can be obtained from [F-Droid](https://f-droid.org/en/packages/com.termux.api/). 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-api/actions) +from one of the workflow runs listed on [Github Actions](https://github.com/termux/termux-api/actions/workflows/github_action_build.yml?query=branch%3Amaster+event%3Apush) page. 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. +all currently installed plugins. Check https://github.com/termux/termux-app#Installation for more info. ## License diff --git a/app/build.gradle b/app/build.gradle index 758bbd9ce..a50127dcc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,18 @@ apply plugin: 'com.android.application' android { - compileSdkVersion project.properties.compileSdkVersion.toInteger() - def appVersionName = System.getenv("TERMUX_API_APP_VERSION_NAME") ?: "" - def apkVersionTag = System.getenv("TERMUX_API_APK_VERSION_TAG") ?: "" + namespace "com.termux.api" + + compileSdk project.properties.compileSdkVersion.toInteger() + def appVersionName = System.getenv("TERMUX_API_APP__BUILD__APP_VERSION_NAME") ?: "" + def apkVersionTag = System.getenv("TERMUX_API_APP__BUILD__APK_VERSION_TAG") ?: "" defaultConfig { applicationId "com.termux.api" - minSdkVersion project.properties.minSdkVersion.toInteger() - targetSdkVersion project.properties.targetSdkVersion.toInteger() - versionCode 51 - versionName "0.50.1" + minSdk project.properties.minSdkVersion.toInteger() + targetSdk project.properties.targetSdkVersion.toInteger() + versionCode 1000 + versionName "0.51.0" if (appVersionName) versionName = appVersionName validateVersionName(versionName) @@ -22,7 +24,7 @@ android { signingConfigs { debug { - storeFile file('dev_keystore.jks') + storeFile file('testkey_untrusted.jks') keyAlias 'alias' storePassword 'xrj45yWGLbsO7W0v' keyPassword 'xrj45yWGLbsO7W0v' @@ -42,30 +44,42 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } applicationVariants.all { variant -> variant.outputs.all { output -> - if (variant.buildType.name == "debug") { - outputFileName = new File("termux-api_" + (apkVersionTag ? apkVersionTag : "debug") + ".apk") - } else if (variant.buildType.name == "release") { - outputFileName = new File("termux-api_" + (apkVersionTag ? apkVersionTag : "release") + ".apk") - } + outputFileName = new File("termux-api-app_" + + (apkVersionTag ? apkVersionTag : "v" + versionName + "+" + variant.buildType.name) + ".apk") } } - lintOptions { - disable 'ExpiredTargetSdkVersion' + packagingOptions { + // Remove terminal-emulator and termux-shared JNI libs added via termux-shared dependency + exclude "lib/*/libtermux.so" + exclude "lib/*/liblocal-socket.so" } } dependencies { - implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.biometric:biometric:1.2.0-alpha03' - implementation 'androidx.media:media:1.4.3' - implementation 'com.termux.termux-app:termux-shared:f3ffc36bfd' + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" + + implementation "com.google.android.material:material:1.12.0" + implementation "androidx.biometric:biometric:1.2.0-alpha05" + implementation "androidx.media:media:1.7.0" + implementation "androidx.preference:preference:1.2.1" + + implementation "com.termux.termux-app:termux-shared:9ee1c9d5ad" + // Use if below libraries are published locally by termux-app with `./gradlew publishReleasePublicationToMavenLocal` and used with `mavenLocal()`. + // If updates are done, republish there and sync project with gradle files here + // https://github.com/termux/termux-app/wiki/Termux-Libraries + //implementation "com.termux:termux-shared:0.118.0" + + implementation "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava" } task versionName { @@ -74,7 +88,8 @@ task versionName { } } -def validateVersionName(String versionName) { +@SuppressWarnings("UnnecessaryQualifiedReference") +static 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)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91cce47c3..beb27e832 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,38 +1,49 @@ - - - - - - - - - + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - @@ -43,12 +54,11 @@ + - - + - - - + + + + + + + + + + + + + + + + + + + + + @@ -68,33 +123,71 @@ + + + - - - - - - - + + + + + + + + + - - - + android:exported="true" /> + + + + + + + + + + + + + + + + + + @@ -103,14 +196,22 @@ - - + + + + + + + + + diff --git a/app/src/main/java/com/termux/api/App.java b/app/src/main/java/com/termux/api/App.java deleted file mode 100644 index 82b85b0be..000000000 --- a/app/src/main/java/com/termux/api/App.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.termux.api; - -import android.app.Application; -import android.content.Intent; -import android.net.LocalServerSocket; -import android.net.LocalSocket; - -import com.termux.api.util.TermuxApiLogger; - -import java.io.BufferedWriter; -import java.io.DataInputStream; -import java.io.OutputStreamWriter; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class App extends Application -{ - public static final String LISTEN_ADDRESS = "com.termux.api://listen"; - private static final Pattern EXTRA_STRING = Pattern.compile("(-e|--es|--esa) +([^ ]+) +\"(.*?)(? { - try (LocalServerSocket listen = new LocalServerSocket(LISTEN_ADDRESS)) { - while (true) { - try (LocalSocket con = listen.accept(); - DataInputStream in = new DataInputStream(con.getInputStream()); - BufferedWriter out = new BufferedWriter(new OutputStreamWriter(con.getOutputStream()))) { - // only accept connections from Termux programs - if (con.getPeerCredentials().getUid() != getApplicationInfo().uid) { - continue; - } - try { - //System.out.println("connection"); - int length = in.readUnsignedShort(); - byte[] b = new byte[length]; - in.readFully(b); - String cmdline = new String(b, StandardCharsets.UTF_8); - - Intent intent = new Intent(getApplicationContext(), TermuxApiReceiver.class); - //System.out.println(cmdline.replaceAll("--es socket_input \".*?\"","").replaceAll("--es socket_output \".*?\"","")); - HashMap stringExtras = new HashMap<>(); - HashMap stringArrayExtras = new HashMap<>(); - HashMap booleanExtras = new HashMap<>(); - HashMap intExtras = new HashMap<>(); - HashMap floatExtras = new HashMap<>(); - HashMap intArrayExtras = new HashMap<>(); - HashMap longArrayExtras = new HashMap<>(); - boolean err = false; - - // extract and remove the string extras first, so another argument embedded in a string isn't counted as an argument - Matcher m = EXTRA_STRING.matcher(cmdline); - while (m.find()) { - String option = m.group(1); - if ("-e".equals(option) || "--es".equals(option)) { - // unescape " - stringExtras.put(m.group(2), Objects.requireNonNull(m.group(3)).replaceAll("\\\\\"","\"")); - } else { - // split the list - String[] list = Objects.requireNonNull(m.group(3)).split("(? e : stringExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - for (Map.Entry e : stringArrayExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - for (Map.Entry e : intExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - for (Map.Entry e : booleanExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - for (Map.Entry e : floatExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - for (Map.Entry e : intArrayExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - for (Map.Entry e : longArrayExtras.entrySet()) { - intent.putExtra(e.getKey(), e.getValue()); - } - getApplicationContext().sendOrderedBroadcast(intent, null); - // send a null byte as a sign that the arguments have been successfully received, parsed and the broadcast receiver is called - con.getOutputStream().write(0); - con.getOutputStream().flush(); - } catch (Exception e) { - TermuxApiLogger.error("Error parsing arguments", e); - out.write("Exception in the plugin\n"); - out.flush(); - } - } - } - } catch (Exception e) { - TermuxApiLogger.error("Error listening for connections", e); - } - }).start(); - } - -} diff --git a/app/src/main/java/com/termux/api/AudioAPI.java b/app/src/main/java/com/termux/api/AudioAPI.java deleted file mode 100644 index ebb4c2dee..000000000 --- a/app/src/main/java/com/termux/api/AudioAPI.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.termux.api; - -import android.content.Context; -import android.content.Intent; -import android.media.AudioAttributes; -import android.media.AudioManager; -import android.media.AudioTrack; -import android.os.Build; -import android.util.JsonWriter; - -import com.termux.api.util.ResultReturner; - -public class AudioAPI { - - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { - AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - final String SampleRate = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE); - final String framesPerBuffer = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER); - final boolean bluetootha2dp = am.isBluetoothA2dpOn(); - final boolean wiredhs = am.isWiredHeadsetOn(); - - final int sr, bs, sr_ll, bs_ll, sr_ps, bs_ps, nosr; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - nosr = 0; - AudioTrack at; - at = new AudioTrack.Builder() - .setBufferSizeInBytes(4) // one 16bit 2ch frame - .build(); - sr = at.getSampleRate(); - bs = at.getBufferSizeInFrames(); - at.release(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - at = new AudioTrack.Builder() - .setBufferSizeInBytes(4) // one 16bit 2ch frame - .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) - .build(); - } else { - AudioAttributes aa = new AudioAttributes.Builder() - .setFlags(AudioAttributes.FLAG_LOW_LATENCY) - .build(); - at = new AudioTrack.Builder() - .setAudioAttributes(aa) - .setBufferSizeInBytes(4) // one 16bit 2ch frame - .build(); - } - sr_ll = at.getSampleRate(); - bs_ll = at.getBufferSizeInFrames(); - at.release(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - at = new AudioTrack.Builder() - .setBufferSizeInBytes(4) // one 16bit 2ch frame - .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_POWER_SAVING) - .build(); - sr_ps = at.getSampleRate(); - bs_ps = at.getBufferSizeInFrames(); - at.release(); - } else { - sr_ps = sr; - bs_ps = bs; - } - } else { - sr = bs = sr_ll = bs_ll = sr_ps = bs_ps = 0; - nosr = AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC); - } - - ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { - public void writeJson(JsonWriter out) throws Exception { - out.beginObject(); - out.name("PROPERTY_OUTPUT_SAMPLE_RATE").value(SampleRate); - out.name("PROPERTY_OUTPUT_FRAMES_PER_BUFFER").value(framesPerBuffer); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - out.name("AUDIOTRACK_SAMPLE_RATE").value(sr); - out.name("AUDIOTRACK_BUFFER_SIZE_IN_FRAMES").value(bs); - if (sr_ll != sr || bs_ll != bs) { // all or nothing - out.name("AUDIOTRACK_SAMPLE_RATE_LOW_LATENCY").value(sr_ll); - out.name("AUDIOTRACK_BUFFER_SIZE_IN_FRAMES_LOW_LATENCY").value(bs_ll); - } - if (sr_ps != sr || bs_ps != bs) { // all or nothing - out.name("AUDIOTRACK_SAMPLE_RATE_POWER_SAVING").value(sr_ps); - out.name("AUDIOTRACK_BUFFER_SIZE_IN_FRAMES_POWER_SAVING").value(bs_ps); - } - } else { - out.name("AUDIOTRACK_NATIVE_OUTPUT_SAMPLE_RATE").value(nosr); - } - out.name("BLUETOOTH_A2DP_IS_ON").value(bluetootha2dp); - out.name("WIREDHEADSET_IS_CONNECTED").value(wiredhs); - out.endObject(); - } - }); - } - -} diff --git a/app/src/main/java/com/termux/api/BatteryStatusAPI.java b/app/src/main/java/com/termux/api/BatteryStatusAPI.java deleted file mode 100644 index d51143f6a..000000000 --- a/app/src/main/java/com/termux/api/BatteryStatusAPI.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.termux.api; - -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.BatteryManager; -import android.util.JsonWriter; - -import com.termux.api.util.ResultReturner; -import com.termux.api.util.ResultReturner.ResultJsonWriter; -import com.termux.api.util.TermuxApiLogger; - -public class BatteryStatusAPI { - - public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { - ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { - @Override - public void writeJson(JsonWriter out) throws Exception { - Intent batteryStatus = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); - - int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); - int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); - final int batteryPercentage = (level * 100) / scale; - - int health = batteryStatus.getIntExtra(BatteryManager.EXTRA_HEALTH, -1); - String batteryHealth; - switch (health) { - case BatteryManager.BATTERY_HEALTH_COLD: - batteryHealth = "COLD"; - break; - case BatteryManager.BATTERY_HEALTH_DEAD: - batteryHealth = "DEAD"; - break; - case BatteryManager.BATTERY_HEALTH_GOOD: - batteryHealth = "GOOD"; - break; - case BatteryManager.BATTERY_HEALTH_OVERHEAT: - batteryHealth = "OVERHEAT"; - break; - case BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE: - batteryHealth = "OVER_VOLTAGE"; - break; - case BatteryManager.BATTERY_HEALTH_UNKNOWN: - batteryHealth = "UNKNOWN"; - break; - case BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE: - batteryHealth = "UNSPECIFIED_FAILURE"; - break; - default: - batteryHealth = Integer.toString(health); - } - - // BatteryManager.EXTRA_PLUGGED: "Extra for ACTION_BATTERY_CHANGED: integer indicating whether the - // device is plugged in to a power source; 0 means it is on battery, other constants are different types - // of power sources." - int pluggedInt = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); - String batteryPlugged; - switch (pluggedInt) { - case 0: - batteryPlugged = "UNPLUGGED"; - break; - case BatteryManager.BATTERY_PLUGGED_AC: - batteryPlugged = "PLUGGED_AC"; - break; - case BatteryManager.BATTERY_PLUGGED_USB: - batteryPlugged = "PLUGGED_USB"; - break; - case BatteryManager.BATTERY_PLUGGED_WIRELESS: - batteryPlugged = "PLUGGED_WIRELESS"; - break; - default: - batteryPlugged = "PLUGGED_" + pluggedInt; - } - - double batteryTemperature = batteryStatus.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) / 10.f; - - String batteryStatusString; - int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); - switch (status) { - case BatteryManager.BATTERY_STATUS_CHARGING: - batteryStatusString = "CHARGING"; - break; - case BatteryManager.BATTERY_STATUS_DISCHARGING: - batteryStatusString = "DISCHARGING"; - break; - case BatteryManager.BATTERY_STATUS_FULL: - batteryStatusString = "FULL"; - break; - case BatteryManager.BATTERY_STATUS_NOT_CHARGING: - batteryStatusString = "NOT_CHARGING"; - break; - case BatteryManager.BATTERY_STATUS_UNKNOWN: - batteryStatusString = "UNKNOWN"; - break; - default: - TermuxApiLogger.error("Invalid BatteryManager.EXTRA_STATUS value: " + status); - batteryStatusString = "UNKNOWN"; - } - - BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); - - out.beginObject(); - out.name("health").value(batteryHealth); - out.name("percentage").value(batteryPercentage); - out.name("plugged").value(batteryPlugged); - out.name("status").value(batteryStatusString); - out.name("temperature").value(batteryTemperature); - out.name("current").value(batteryManager.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)); - out.endObject(); - } - }); - - } -} diff --git a/app/src/main/java/com/termux/api/DialogActivity.java b/app/src/main/java/com/termux/api/DialogActivity.java deleted file mode 100644 index 25f5ddd56..000000000 --- a/app/src/main/java/com/termux/api/DialogActivity.java +++ /dev/null @@ -1,1049 +0,0 @@ -package com.termux.api; - -import android.Manifest; -import android.app.Activity; -import android.app.ActivityManager; -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.os.Build; -import android.os.Bundle; -import android.speech.RecognitionListener; -import android.speech.RecognizerIntent; -import android.speech.SpeechRecognizer; -import android.util.Log; -import androidx.annotation.NonNull; -import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import androidx.core.widget.NestedScrollView; -import androidx.appcompat.app.AppCompatActivity; -import android.text.InputType; -import android.util.JsonWriter; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.view.inputmethod.InputMethodManager; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.CheckBox; -import android.widget.DatePicker; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.RadioButton; -import android.widget.RadioGroup; -import android.widget.ScrollView; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.TimePicker; -import android.widget.Toast; - -import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiPermissionActivity; - -import java.nio.charset.StandardCharsets; -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStreamReader; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Properties; - -import static com.termux.shared.termux.TermuxConstants.TERMUX_PROPERTIES_PRIMARY_FILE_PATH; -import static com.termux.shared.termux.TermuxConstants.TERMUX_PROPERTIES_SECONDARY_FILE_PATH; - -/** - * API that allows receiving user input interactively in a variety of different ways - */ -public class DialogActivity extends AppCompatActivity { - - private boolean resultReturned = false; - - protected boolean getBlackUI() { - File propsFile = new File(TERMUX_PROPERTIES_PRIMARY_FILE_PATH); - - if (!propsFile.exists()) - propsFile = new File(TERMUX_PROPERTIES_SECONDARY_FILE_PATH); - - boolean mUseBlackUi = false; - - if (propsFile.exists()) { - Properties props = new Properties(); - try { - if (propsFile.isFile() && propsFile.canRead()) { - try (FileInputStream in = new FileInputStream(propsFile)) { - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - } - } - mUseBlackUi = props.getProperty("use-black-ui").equals("true"); - } catch (Exception e) { - Log.e("termux-api", "Error loading props", e); - } - } - - return mUseBlackUi; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - final Intent intent = getIntent(); - final Context context = this; - - - String methodType = intent.hasExtra("input_method") ? intent.getStringExtra("input_method") : ""; - - if (getBlackUI()) - this.setTheme(R.style.DialogTheme_Dark); - - InputMethod method = InputMethodFactory.get(methodType, this); - method.create(this, result -> { - postResult(context, result); - finish(); - }); - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (!resultReturned) { - postResult(this, null); - } - } - - /** - * Extract value extras from intent into String array - */ - static String[] getInputValues(Intent intent) { - String[] items = new String[] { }; - - if (intent != null && intent.hasExtra("input_values")) { - String[] temp = intent.getStringExtra("input_values").split("(? -1) { - out.name("index").value(result.index); - } - if (result.values.size() > 0) { - out.name("values"); - out.beginArray(); - for (Value value : result.values) { - out.beginObject(); - out.name("index").value(value.index); - out.name("text").value(value.text); - out.endObject(); - } - out.endArray(); - } - if (!result.error.equals("")) { - out.name("error").value(result.error); - } - - out.endObject(); - out.flush(); - resultReturned = true; - } - }); - } - - - /** - * Factory for returning proper input method type that we received in our incoming intent - */ - static class InputMethodFactory { - - public static InputMethod get(final String type, final AppCompatActivity activity) { - - switch (type == null ? "" : type) { - case "confirm": - return new ConfirmInputMethod(activity); - case "checkbox": - return new CheckBoxInputMethod(activity); - case "counter": - return new CounterInputMethod(activity); - case "date": - return new DateInputMethod(activity); - case "radio": - return new RadioInputMethod(activity); - case "sheet": - return new BottomSheetInputMethod(); - case "speech": - return new SpeechInputMethod(activity); - case "spinner": - return new SpinnerInputMethod(activity); - case "text": - return new TextInputMethod(activity); - case "time": - return new TimeInputMethod(activity); - default: - return (activity1, resultListener) -> { - InputResult result = new InputResult(); - result.error = "Unknown Input Method: " + type; - resultListener.onResult(result); - }; - } - } - } - - - /** - * Interface for creating an input method type - */ - interface InputMethod { - void create(AppCompatActivity activity, InputResultListener resultListener); - } - - - /** - * Callback interface for receiving an InputResult - */ - interface InputResultListener { - void onResult(InputResult result); - } - - - /** - * Simple POJO to store the result of input methods - */ - static class InputResult { - public String text = ""; - public String error = ""; - public int code = 0; - public static int index = -1; - public List values = new ArrayList<>(); - } - - - public static class Value { - public int index = -1; - public String text = ""; - } - - /* - * -------------------------------------- - * InputMethod Implementations - * -------------------------------------- - */ - - - /** - * CheckBox InputMethod - * Allow users to select multiple options from a range of values - */ - static class CheckBoxInputMethod extends InputDialog { - - CheckBoxInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - LinearLayout createWidgetView(AppCompatActivity activity) { - LinearLayout layout = new LinearLayout(activity); - layout.setOrientation(LinearLayout.VERTICAL); - - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - layoutParams.topMargin = 32; - layoutParams.bottomMargin = 32; - - String[] values = getInputValues(activity.getIntent()); - - for (int j = 0; j < values.length; ++j) { - String value = values[j]; - - CheckBox checkBox = new CheckBox(activity); - checkBox.setText(value); - checkBox.setId(j); - checkBox.setTextSize(18); - checkBox.setPadding(16, 16, 16, 16); - checkBox.setLayoutParams(layoutParams); - - layout.addView(checkBox); - } - return layout; - } - - @Override - String getResult() { - int checkBoxCount = widgetView.getChildCount(); - - List values = new ArrayList<>(); - StringBuilder sb = new StringBuilder(); - sb.append("["); - - for (int j = 0; j < checkBoxCount; ++j) { - CheckBox box = widgetView.findViewById(j); - if (box.isChecked()) { - Value value = new Value(); - value.index = j; - value.text = box.getText().toString(); - values.add(value); - sb.append(box.getText().toString()).append(", "); - } - } - inputResult.values = values; - // remove trailing comma and add closing bracket - return sb.toString().replaceAll(", $", "") + "]"; - } - } - - - /** - * Confirm InputMethod - * Allow users to confirm YES or NO. - */ - static class ConfirmInputMethod extends InputDialog { - - ConfirmInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - InputResult onDialogClick(int button) { - inputResult.text = button == Dialog.BUTTON_POSITIVE ? "yes" : "no"; - return inputResult; - } - - @Override - TextView createWidgetView(AppCompatActivity activity) { - TextView textView = new TextView(activity); - final Intent intent = activity.getIntent(); - - String text = intent.hasExtra("input_hint") ? intent.getStringExtra("input_hint") : "Confirm"; - textView.setText(text); - return textView; - } - - @Override - String getNegativeButtonText() { - return "No"; - } - - @Override - String getPositiveButtonText() { - return "Yes"; - } - } - - - /** - * Counter InputMethod - * Allow users to increment or decrement a number in a given range - */ - static class CounterInputMethod extends InputDialog { - static final int DEFAULT_MIN = 0; - static final int DEFAULT_MAX = 100; - static final int RANGE_LENGTH = 3; - - int min; - int max; - int counter; - - TextView counterLabel; - - CounterInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - View createWidgetView(AppCompatActivity activity) { - View layout = View.inflate(activity, R.layout.dialog_counter, null); - counterLabel = layout.findViewById(R.id.counterTextView); - - final Button incrementButton = layout.findViewById(R.id.incrementButton); - incrementButton.setOnClickListener(view -> increment()); - - final Button decrementButton = layout.findViewById(R.id.decrementButton); - decrementButton.setOnClickListener(view -> decrement()); - updateCounterRange(); - - return layout; - } - - void updateCounterRange() { - final Intent intent = activity.getIntent(); - - if (intent.hasExtra("input_range")) { - int[] values = intent.getIntArrayExtra("input_range"); - if (values.length != RANGE_LENGTH) { - inputResult.error = "Invalid range! Must be 3 int values!"; - postCanceledResult(); - dialog.dismiss(); - } else { - min = Math.min(values[0], values[1]); - max = Math.max(values[0], values[1]); - counter = values[2]; - } - } else { - min = DEFAULT_MIN; - max = DEFAULT_MAX; - - // halfway - counter = (DEFAULT_MAX - DEFAULT_MIN) / 2; - } - updateLabel(); - } - - @Override - String getResult() { - return counterLabel.getText().toString(); - } - - void updateLabel() { - counterLabel.setText(String.valueOf(counter)); - } - - void increment() { - if ((counter + 1) <= max) { - ++counter; - updateLabel(); - } - } - - void decrement() { - if ((counter - 1) >= min) { - --counter; - updateLabel(); - } - } - } - - - /** - * Date InputMethod - * Allow users to pick a specific date - */ - static class DateInputMethod extends InputDialog { - - DateInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - String getResult() { - int month = widgetView.getMonth(); - int day = widgetView.getDayOfMonth(); - int year = widgetView.getYear(); - - Calendar calendar = Calendar.getInstance(); - calendar.set(year, month, day, 0, 0, 0); - - final Intent intent = activity.getIntent(); - if (intent.hasExtra("date_format")) { - String date_format = intent.getStringExtra("date_format"); - try { - SimpleDateFormat dateFormat = new SimpleDateFormat(date_format); - dateFormat.setTimeZone(calendar.getTimeZone()); - return dateFormat.format(calendar.getTime()); - } catch (Exception e) { - inputResult.error = e.toString(); - postCanceledResult(); - } - } - return calendar.getTime().toString(); - } - - @Override - DatePicker createWidgetView(AppCompatActivity activity) { - return new DatePicker(activity); - } - } - - - /** - * Text InputMethod - * Allow users to enter plaintext or a password - */ - static class TextInputMethod extends InputDialog { - - TextInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - String getResult() { - return widgetView.getText().toString(); - } - - @Override - EditText createWidgetView(AppCompatActivity activity) { - final Intent intent = activity.getIntent(); - EditText editText = new EditText(activity); - - if (intent.hasExtra("input_hint")) { - editText.setHint(intent.getStringExtra("input_hint")); - } - - boolean multiLine = intent.getBooleanExtra("multiple_lines", false); - boolean numeric = intent.getBooleanExtra("numeric", false); - boolean password = intent.getBooleanExtra("password", false); - - int flags = InputType.TYPE_CLASS_TEXT; - - if (password) { - flags = numeric ? (flags | InputType.TYPE_NUMBER_VARIATION_PASSWORD) : (flags | InputType.TYPE_TEXT_VARIATION_PASSWORD); - } - - if (multiLine) { - flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE; - editText.setLines(4); - } - - if (numeric) { - flags &= ~InputType.TYPE_CLASS_TEXT; // clear to allow only numbers - flags |= InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL; - } - - editText.setInputType(flags); - - return editText; - } - } - - - /** - * Time InputMethod - * Allow users to pick a specific time - */ - static class TimeInputMethod extends InputDialog { - - TimeInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - String getResult() { - String result = String.format(Locale.getDefault(), "%02d:%02d", widgetView.getHour(), widgetView.getMinute()); - return result; - } - - @Override - TimePicker createWidgetView(AppCompatActivity activity) { - return new TimePicker(activity); - } - } - - - /** - * Radio InputMethod - * Allow users to confirm from radio button options - */ - static class RadioInputMethod extends InputDialog { - RadioGroup radioGroup; - - RadioInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - RadioGroup createWidgetView(AppCompatActivity activity) { - radioGroup = new RadioGroup(activity); - radioGroup.setPadding(16, 16, 16, 16); - - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - layoutParams.topMargin = 32; - layoutParams.bottomMargin = 32; - - String[] values = getInputValues(activity.getIntent()); - - for (int j = 0; j < values.length; ++j) { - String value = values[j]; - - RadioButton button = new RadioButton(activity); - button.setText(value); - button.setId(j); - button.setTextSize(18); - button.setPadding(16, 16, 16, 16); - button.setLayoutParams(layoutParams); - - radioGroup.addView(button); - } - return radioGroup; - } - - @Override - String getResult() { - int radioIndex = radioGroup.indexOfChild(widgetView.findViewById(radioGroup.getCheckedRadioButtonId())); - RadioButton radioButton = (RadioButton) radioGroup.getChildAt(radioIndex); - InputResult.index = radioIndex; - return (radioButton != null) ? radioButton.getText().toString() : ""; - } - } - - - /** - * BottomSheet InputMethod - * Allow users to select from a variety of options in a bottom sheet dialog - */ - public static class BottomSheetInputMethod extends BottomSheetDialogFragment implements InputMethod { - private InputResultListener resultListener; - - - @Override - public void create(AppCompatActivity activity, InputResultListener resultListener) { - this.resultListener = resultListener; - show(activity.getSupportFragmentManager(), "BOTTOM_SHEET"); - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - // create custom BottomSheetDialog that has friendlier dismissal behavior - return new BottomSheetDialog(getActivity(), getTheme()) { - @Override - public void onBackPressed() { - super.onBackPressed(); - // make it so that user only has to hit back key one time to get rid of bottom sheet - getActivity().onBackPressed(); - postCanceledResult(); - } - - @Override - public void cancel() { - super.cancel(); - - if (isCurrentAppTermux()) { - showKeyboard(); - } - // dismiss on single touch outside of dialog - getActivity().onBackPressed(); - postCanceledResult(); - } - }; - } - - @Override - public void setupDialog(final Dialog dialog, int style) { - LinearLayout layout = new LinearLayout(getContext()); - layout.setMinimumHeight(100); - layout.setPadding(16, 16, 16, 16); - layout.setOrientation(LinearLayout.VERTICAL); - - NestedScrollView scrollView = new NestedScrollView(getContext()); - final String[] values = getInputValues(Objects.requireNonNull(getActivity()).getIntent()); - - for (int i = 0; i < values.length; ++i) { - final int j = i; - final TextView textView = new TextView(getContext()); - textView.setText(values[j]); - textView.setTextSize(20); - textView.setPadding(56, 56, 56, 56); - textView.setOnClickListener(view -> { - InputResult result = new InputResult(); - result.text = values[j]; - result.index = j; - dialog.dismiss(); - resultListener.onResult(result); - }); - - layout.addView(textView); - } - scrollView.addView(layout); - dialog.setContentView(scrollView); - hideKeyboard(); - } - - /** - * These keyboard methods exist to work around inconsistent show / hide behavior - * from canceling BottomSheetDialog and produces the desired result of hiding keyboard - * on creation of dialog and showing it after a selection or cancellation, as long as - * we are still within the Termux application - */ - - protected void hideKeyboard() { - getDialog().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); - } - - protected void showKeyboard() { - getInputMethodManager().showSoftInput(getView(), InputMethodManager.SHOW_FORCED); - } - - protected InputMethodManager getInputMethodManager() { - return (InputMethodManager) Objects.requireNonNull(getContext()).getSystemService(Context.INPUT_METHOD_SERVICE); - } - - /** - * Checks to see if foreground application is Termux - */ - protected boolean isCurrentAppTermux() { - final ActivityManager activityManager = (ActivityManager) Objects.requireNonNull(getContext()).getSystemService(Context.ACTIVITY_SERVICE); - final List runningProcesses = Objects.requireNonNull(activityManager).getRunningAppProcesses(); - for (final ActivityManager.RunningAppProcessInfo processInfo : runningProcesses) { - if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { - for (final String activeProcess : processInfo.pkgList) { - if (activeProcess.equals("com.termux")) { - return true; - } - } - } - } - return false; - } - - protected void postCanceledResult() { - InputResult result = new InputResult(); - result.code = Dialog.BUTTON_NEGATIVE; - resultListener.onResult(result); - } - } - - - /** - * Spinner InputMethod - * Allow users to make a selection based on a list of specified values - */ - static class SpinnerInputMethod extends InputDialog { - - SpinnerInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - String getResult() { - InputResult.index = widgetView.getSelectedItemPosition(); - return widgetView.getSelectedItem().toString(); - } - - @Override - Spinner createWidgetView(AppCompatActivity activity) { - Spinner spinner = new Spinner(activity); - - final Intent intent = activity.getIntent(); - final String[] items = getInputValues(intent); - final ArrayAdapter adapter = new ArrayAdapter<>(activity, R.layout.spinner_item, items); - - spinner.setAdapter(adapter); - return spinner; - } - } - - - /** - * Speech InputMethod - * Allow users to use the built in microphone to get text from speech - */ - static class SpeechInputMethod extends InputDialog { - - SpeechInputMethod(AppCompatActivity activity) { - super(activity); - } - - @Override - TextView createWidgetView(AppCompatActivity activity) { - TextView textView = new TextView(activity); - final Intent intent = activity.getIntent(); - - String text = intent.hasExtra("input_hint") ? intent.getStringExtra("input_hint") : "Listening for speech..."; - - textView.setText(text); - textView.setTextSize(20); - return textView; - } - - @Override - public void create(final AppCompatActivity activity, final InputResultListener resultListener) { - // Since we're using the microphone, we need to make sure we have proper permission - if (!TermuxApiPermissionActivity.checkAndRequestPermissions(activity, activity.getIntent(), Manifest.permission.RECORD_AUDIO)) { - activity.finish(); - } - - if (!hasSpeechRecognizer(activity)) { - Toast.makeText(activity, "No voice recognition found!", Toast.LENGTH_SHORT).show(); - activity.finish(); - } - - - Intent speechIntent = createSpeechIntent(); - final SpeechRecognizer recognizer = createSpeechRecognizer(activity, resultListener); - - // create intermediate InputResultListener so that we can stop our speech listening - // if user hits the cancel button - DialogInterface.OnClickListener clickListener = getClickListener(result -> { - recognizer.stopListening(); - resultListener.onResult(result); - }); - - Dialog dialog = getDialogBuilder(activity, clickListener) - .setPositiveButton(null, null) - .setOnDismissListener(null) - .create(); - - dialog.setCanceledOnTouchOutside(false); - dialog.show(); - - recognizer.startListening(speechIntent); - } - - private boolean hasSpeechRecognizer(Context context) { - List installList = context.getPackageManager().queryIntentActivities(new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), 0); - return !installList.isEmpty(); - } - - private Intent createSpeechIntent() { - Intent speechIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); - speechIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1); - speechIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM); - return speechIntent; - } - - private SpeechRecognizer createSpeechRecognizer(AppCompatActivity activity, final InputResultListener listener) { - SpeechRecognizer recognizer = SpeechRecognizer.createSpeechRecognizer(activity); - recognizer.setRecognitionListener(new RecognitionListener() { - - @Override - public void onResults(Bundle results) { - List voiceResults = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); - - if (voiceResults != null && voiceResults.size() > 0) { - inputResult.text = voiceResults.get(0); - } - listener.onResult(inputResult); - } - - /** - * Get string description for error code - */ - @Override - public void onError(int error) { - String errorDescription; - - switch (error) { - case SpeechRecognizer.ERROR_AUDIO: - errorDescription = "ERROR_AUDIO"; - break; - case SpeechRecognizer.ERROR_CLIENT: - errorDescription = "ERROR_CLIENT"; - break; - case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS: - errorDescription = "ERROR_INSUFFICIENT_PERMISSIONS"; - break; - case SpeechRecognizer.ERROR_NETWORK: - errorDescription = "ERROR_NETWORK"; - break; - case SpeechRecognizer.ERROR_NETWORK_TIMEOUT: - errorDescription = "ERROR_NETWORK_TIMEOUT"; - break; - case SpeechRecognizer.ERROR_SPEECH_TIMEOUT: - errorDescription = "ERROR_SPEECH_TIMEOUT"; - break; - default: - errorDescription = "ERROR_UNKNOWN"; - break; - } - inputResult.error = errorDescription; - listener.onResult(inputResult); - } - - - // unused - @Override - public void onEndOfSpeech() { } - - @Override - public void onReadyForSpeech(Bundle bundle) { } - - @Override - public void onBeginningOfSpeech() { } - - @Override - public void onRmsChanged(float v) { } - - @Override - public void onBufferReceived(byte[] bytes) { } - - @Override - public void onPartialResults(Bundle bundle) { } - - @Override - public void onEvent(int i, Bundle bundle) { } - }); - return recognizer; - } - } - - - /** - * Base Dialog class to extend from for adding specific views / widgets to a Dialog interface - * @param Main view type that will be displayed within dialog - */ - abstract static class InputDialog implements InputMethod { - // result that belongs to us - InputResult inputResult = new InputResult(); - - // listener for our input result - InputResultListener resultListener; - - // view that will be placed in our dialog - T widgetView; - - // dialog that holds everything - Dialog dialog; - - // our activity context - AppCompatActivity activity; - - - // method to be implemented that handles creating view that is placed in our dialog - abstract T createWidgetView(AppCompatActivity activity); - - // method that should be implemented that handles returning a result obtained through user input - String getResult() { - return null; - } - - - InputDialog(AppCompatActivity activity) { - this.activity = activity; - widgetView = createWidgetView(activity); - initActivityDisplay(activity); - } - - - @Override - public void create(AppCompatActivity activity, final InputResultListener resultListener) { - this.resultListener = resultListener; - - // Handle OK and Cancel button clicks - DialogInterface.OnClickListener clickListener = getClickListener(resultListener); - - // Dialog interface that will display to user - dialog = getDialogBuilder(activity, clickListener).create(); - dialog.show(); - } - - void postCanceledResult() { - inputResult.code = Dialog.BUTTON_NEGATIVE; - resultListener.onResult(inputResult); - } - - void initActivityDisplay(Activity activity) { - activity.setFinishOnTouchOutside(false); - activity.requestWindowFeature(Window.FEATURE_NO_TITLE); - } - - /** - * Places our generic widget view type inside a FrameLayout - */ - View getLayoutView(AppCompatActivity activity, T view) { - FrameLayout layout = getFrameLayout(activity); - ViewGroup.LayoutParams params = layout.getLayoutParams(); - - view.setLayoutParams(params); - layout.addView(view); - layout.setScrollbarFadingEnabled(false); - - // wrap everything in scrollview - ScrollView scrollView = new ScrollView(activity); - scrollView.addView(layout); - - return scrollView; - } - - DialogInterface.OnClickListener getClickListener(final InputResultListener listener) { - return (dialogInterface, button) -> { - InputResult result = onDialogClick(button); - listener.onResult(result); - }; - } - - DialogInterface.OnDismissListener getDismissListener() { - return dialogInterface -> { - // force dismiss behavior on single tap outside of dialog - activity.onBackPressed(); - onDismissed(); - }; - } - - /** - * Creates a dialog builder to initialize a dialog w/ a view and button click listeners - */ - AlertDialog.Builder getDialogBuilder(AppCompatActivity activity, DialogInterface.OnClickListener clickListener) { - final Intent intent = activity.getIntent(); - final View layoutView = getLayoutView(activity, widgetView); - - return new AlertDialog.Builder(activity) - .setTitle(intent.hasExtra("input_title") ? intent.getStringExtra("input_title") : "") - .setNegativeButton(getNegativeButtonText(), clickListener) - .setPositiveButton(getPositiveButtonText(), clickListener) - .setOnDismissListener(getDismissListener()) - .setView(layoutView); - - } - - String getNegativeButtonText() { - return "Cancel"; - } - - String getPositiveButtonText() { - return "OK"; - } - - void onDismissed() { - postCanceledResult(); - } - - /** - * Create a basic frame layout that will add a margin around our main widget view - */ - FrameLayout getFrameLayout(AppCompatActivity activity) { - FrameLayout layout = new FrameLayout(activity); - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - - final int margin = 56; - params.setMargins(margin, margin, margin, margin); - - params.setMargins(56, 56, 56, 56); - layout.setLayoutParams(params); - return layout; - } - - /** - * Returns an InputResult containing code of our button and the text if we hit OK - */ - InputResult onDialogClick(int button) { - // receive indication of whether the OK or CANCEL button is clicked - inputResult.code = button; - - // OK clicked - if (button == Dialog.BUTTON_POSITIVE) { - inputResult.text = getResult(); - } - return inputResult; - } - } -} diff --git a/app/src/main/java/com/termux/api/KeepAliveService.java b/app/src/main/java/com/termux/api/KeepAliveService.java index 666ca27aa..30b6eacc3 100644 --- a/app/src/main/java/com/termux/api/KeepAliveService.java +++ b/app/src/main/java/com/termux/api/KeepAliveService.java @@ -6,10 +6,16 @@ import androidx.annotation.Nullable; -public class KeepAliveService extends Service -{ +import com.termux.shared.logger.Logger; + +public class KeepAliveService extends Service { + + private static final String LOG_TAG = "KeepAliveService"; + @Override public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + return Service.START_STICKY; } diff --git a/app/src/main/java/com/termux/api/NfcActivity.java b/app/src/main/java/com/termux/api/NfcActivity.java deleted file mode 100644 index f305b8e1c..000000000 --- a/app/src/main/java/com/termux/api/NfcActivity.java +++ /dev/null @@ -1,301 +0,0 @@ -package com.termux.api; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.app.PendingIntent; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.nfc.NdefMessage; -import android.nfc.NdefRecord; -import android.nfc.NfcAdapter; -import android.nfc.Tag; -import android.nfc.tech.Ndef; -import android.os.Bundle; -import android.os.Parcelable; -import android.text.Layout; -import android.util.JsonWriter; -import android.util.Log; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import com.termux.api.util.ResultReturner; - - -public class NfcActivity extends AppCompatActivity{ - private NfcAdapter adapter; - static String socket_input; - static String socket_output; - String mode; - String param; - String value; - //Check for NFC - protected void errorNfc(final Context context, Intent intent, String error) { - ResultReturner.returnData(context, intent, new ResultReturner.ResultJsonWriter() { - @Override - public void writeJson(JsonWriter out) throws Exception { - NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context); - out.beginObject(); - if (error.length() > 0) - out.name("error").value(error); - out.name("nfcPresent").value(null != adapter); - if(null!=adapter) - out.name("nfcActive").value(adapter.isEnabled()); - out.endObject(); - } - }); - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent intent = this.getIntent(); - if (intent != null) { - mode = intent.getStringExtra("mode"); - if (null == mode) - mode = "noData"; - param =intent.getStringExtra("param"); - if (null == param) - param = "noData"; - value=intent.getStringExtra("value"); - if (null == socket_input) socket_input = intent.getStringExtra("socket_input"); - if (null == socket_output) socket_output = intent.getStringExtra("socket_output"); - if (mode == "noData") { - errorNfc(this, intent,""); - finish(); - } - } - - NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this); - if((null==adapter)||(!adapter.isEnabled())){ - errorNfc(this,intent,""); - finish(); - } - } - - @Override - protected void onResume() { - super.onResume(); - adapter = NfcAdapter.getDefaultAdapter(this); - Intent intentNew = new Intent(this, NfcActivity.class).addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intentNew, 0); - IntentFilter[] intentFilter = new IntentFilter[]{ - new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED), - new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED), - new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)}; - adapter.enableForegroundDispatch(this, pendingIntent, intentFilter, null); - } - - @Override - protected void onNewIntent(Intent intent) { - intent.putExtra("socket_input", socket_input); - intent.putExtra("socket_output", socket_output); - - if (NfcAdapter.ACTION_TAG_DISCOVERED.equals(intent.getAction())) { - try { - postResult(this, intent); - } - catch (Exception e) - { - Log.e("Termix-api.NfcAction",e.getMessage()); - } - finish(); - } - super.onNewIntent(intent); - } - - @Override - protected void onPause() { - adapter.disableForegroundDispatch(this); - super.onPause(); - } - - @Override - protected void onDestroy() { - socket_input = null; - socket_output = null; - super.onDestroy(); - } - - protected void postResult(final Context context, Intent intent) { - ResultReturner.returnData(context, intent, new ResultReturner.ResultJsonWriter() { - @Override - public void writeJson(JsonWriter out) throws Exception { - Log.e("NFC","postResult"); - try - { - switch (mode) { - case "write": - switch (param) { - case "text": - Log.e("NFC","-->write"); - onReceiveNfcWrite(context, intent); - Log.e("NFC","<--write"); - break; - default: - onUnexpectedAction(out, "Wrong Params", "Should be text for TAG"); - break; - } - break; - case "read": - switch (param){ - case "short": - readNDEFTag(intent,out); - break; - case "full": - readFullNDEFTag(intent,out); - break; - case "noData": - readNDEFTag(intent,out); - break; - default: - onUnexpectedAction(out, "Wrong Params", "Should be correct param value"); - break; - } - break; - default: - onUnexpectedAction(out, "Wrong Params", "Should be correct mode value "); - break; - } - } - catch (Exception e){ - onUnexpectedAction(out, "exception", e.getMessage()); - } - } - }); - } - public void onReceiveNfcWrite( final Context context, Intent intent) throws Exception { - { - Log.e("NFC","---->onReceiveNfcWrite"); - NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context); - Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); - NdefRecord record = NdefRecord.createTextRecord("en", value); - NdefMessage msg = new NdefMessage(new NdefRecord[]{record}); - Ndef ndef = Ndef.get(tag); - ndef.connect(); - ndef.writeNdefMessage(msg); - ndef.close(); - } - } - - - public void readNDEFTag(Intent intent, JsonWriter out) throws Exception { - NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this); - Parcelable[] msgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES); - Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); - Ndef ndefTag = Ndef.get(tag); - boolean bNdefPresent = false; - String strs[] = tag.getTechList(); - for (String s: strs){ - if (s.equals("android.nfc.tech.Ndef")) - bNdefPresent = true; - } - if (!bNdefPresent){ - onUnexpectedAction(out, "Wrong Technology","termux API support only NFEF Tag"); - return; - } - NdefMessage[] nmsgs = new NdefMessage[msgs.length]; - if (msgs.length == 1) { - nmsgs[0] = (NdefMessage) msgs[0]; - NdefRecord records[] = nmsgs[0].getRecords(); - out.beginObject(); - if (records.length >0 ) { - { - out.name("Record"); - if (records.length > 1) - out.beginArray(); - for (NdefRecord record: records){ - out.beginObject(); - int pos = 1 + record.getPayload()[0]; - pos = (NdefRecord.TNF_WELL_KNOWN==record.getTnf())?(int)record.getPayload()[0]+1:0; - int len = record.getPayload().length - pos; - byte msg[] = new byte[len]; - System.arraycopy(record.getPayload(), pos, msg, 0, len); - out.name("Payload").value(new String(msg)); - out.endObject(); - } - if (records.length > 1) - out.endArray(); - } - } - out.endObject(); - } - } - - public void readFullNDEFTag(Intent intent, JsonWriter out) throws Exception { - NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this); - Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); - Ndef ndefTag = Ndef.get(tag); - Parcelable[] msgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES); - - String strs[] = tag.getTechList(); - boolean bNdefPresent = false; - for (String s: strs){ - if (s.equals("android.nfc.tech.Ndef")) - bNdefPresent = true; - } - if (!bNdefPresent){ - onUnexpectedAction(out, "Wrong Technology","termux API support only NFEF Tag"); - return; - } - NdefMessage[] nmsgs = new NdefMessage[msgs.length]; - out.beginObject(); - { - byte[] tagID = tag.getId(); - StringBuilder sp = new StringBuilder(); - for (byte tagIDpart : tagID) { sp.append(String.format("%02x", tagIDpart)); } - out.name("id").value(sp.toString()); - out.name("typeTag").value(ndefTag.getType()); - out.name("maxSize").value(ndefTag.getMaxSize()); - out.name("techList"); - { - out.beginArray(); - String[] tlist = tag.getTechList(); - for (String str : tlist) { - out.value(str); - } - out.endArray(); - } - if (msgs.length == 1) { - Log.e("NFC", "-->> readFullNDEFTag - 06"); - nmsgs[0] = (NdefMessage) msgs[0]; - NdefRecord records[] = nmsgs[0].getRecords(); - { - out.name("record"); - if (records.length > 1) - out.beginArray(); - for (NdefRecord record : records) { - out.beginObject(); - out.name("type").value(new String(record.getType())); - out.name("tnf").value(record.getTnf()); - if (records[0].toUri() != null) out.name("URI").value(record.toUri().toString()); - out.name("mime").value(record.toMimeType()); - int pos = 1 + record.getPayload()[0]; - pos = (NdefRecord.TNF_WELL_KNOWN==record.getTnf())?(int)record.getPayload()[0]+1:0; - int len = record.getPayload().length - pos; - byte msg[] = new byte[len]; - System.arraycopy(record.getPayload(), pos, msg, 0, len); - out.name("payload").value(new String(msg)); - out.endObject(); - } - if (records.length > 1) out.endArray(); - } - } - - } - out.endObject(); - } - - protected void onUnexpectedAction(JsonWriter out,String error, String description) throws Exception { - out.beginObject(); - out.name("error").value(error); - out.name("description").value(description); - out.endObject(); - out.flush(); - } -} diff --git a/app/src/main/java/com/termux/api/NotificationService.java b/app/src/main/java/com/termux/api/NotificationService.java deleted file mode 100644 index 541217864..000000000 --- a/app/src/main/java/com/termux/api/NotificationService.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.termux.api; - -import android.service.notification.NotificationListenerService; - -public class NotificationService extends NotificationListenerService { - static NotificationService _this; - - public static NotificationService get() { - return _this; - } - - @Override - public void onListenerConnected() { - _this = this; - } - - @Override - public void onListenerDisconnected() { - _this = null; - } -} diff --git a/app/src/main/java/com/termux/api/SchedulerJobService.java b/app/src/main/java/com/termux/api/SchedulerJobService.java deleted file mode 100644 index 65594a7c6..000000000 --- a/app/src/main/java/com/termux/api/SchedulerJobService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.termux.api; -import android.app.job.JobParameters; -import android.app.job.JobService; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.PersistableBundle; -import android.util.Log; - -public class SchedulerJobService extends JobService { - - private static final String LOG_TAG = "TermuxAPISchedulerJob"; - public static final String SCRIPT_FILE_PATH = "com.termux.api.jobscheduler_script_path"; - - // Constants from TermuxService. - private static final String TERMUX_SERVICE = "com.termux.app.TermuxService"; - private static final String ACTION_EXECUTE = "com.termux.service_execute"; - private static final String EXTRA_EXECUTE_IN_BACKGROUND = "com.termux.execute.background"; - - @Override - public boolean onStartJob(JobParameters params) { - - Log.i(LOG_TAG, "Starting job " + params.toString()); - PersistableBundle extras = params.getExtras(); - String filePath = extras.getString(SCRIPT_FILE_PATH); - - Uri scriptUri = new Uri.Builder().scheme("com.termux.file").path(filePath).build(); - Intent executeIntent = new Intent(ACTION_EXECUTE, scriptUri); - executeIntent.setClassName("com.termux", TERMUX_SERVICE); - executeIntent.putExtra(EXTRA_EXECUTE_IN_BACKGROUND, true); - - Context context = getApplicationContext(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // https://developer.android.com/about/versions/oreo/background.html - context.startForegroundService(executeIntent); - } else { - context.startService(executeIntent); - } - - Log.i(LOG_TAG, "Started job " + params.toString()); - return false; - } - - @Override - public boolean onStopJob(JobParameters params) { - Log.i(LOG_TAG, "Stopped job " + params.toString()); - return false; - } -} diff --git a/app/src/main/java/com/termux/api/SmsInboxAPI.java b/app/src/main/java/com/termux/api/SmsInboxAPI.java deleted file mode 100644 index baad316b0..000000000 --- a/app/src/main/java/com/termux/api/SmsInboxAPI.java +++ /dev/null @@ -1,195 +0,0 @@ -package com.termux.api; - -import android.annotation.SuppressLint; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.provider.ContactsContract.PhoneLookup; -import android.provider.Telephony; -import android.provider.Telephony.Sms; -import android.provider.Telephony.Sms.Conversations; -import android.provider.Telephony.TextBasedSmsColumns; -import android.util.JsonWriter; -import android.util.Log; - -import com.termux.api.util.ResultReturner; -import com.termux.api.util.ResultReturner.ResultJsonWriter; - -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; - -import static android.provider.Telephony.TextBasedSmsColumns.ADDRESS; -import static android.provider.Telephony.TextBasedSmsColumns.BODY; -import static android.provider.Telephony.TextBasedSmsColumns.DATE; -import static android.provider.Telephony.TextBasedSmsColumns.READ; -import static android.provider.Telephony.TextBasedSmsColumns.THREAD_ID; -import static android.provider.Telephony.TextBasedSmsColumns.TYPE; - -public class SmsInboxAPI { - - private static final String[] DISPLAY_NAME_PROJECTION = {PhoneLookup.DISPLAY_NAME}; - - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { - final int offset = intent.getIntExtra("offset", 0); - final int limit = intent.getIntExtra("limit", 10); - final String number = intent.hasExtra("from") ? intent.getStringExtra("from"):""; - final boolean conversation_list = intent.getBooleanExtra("conversation-list", false); - final Uri contentURI = conversation_list ? typeToContentURI(0) : - typeToContentURI(number==null || number.isEmpty() ? - intent.getIntExtra("type", TextBasedSmsColumns.MESSAGE_TYPE_INBOX): 0); - - ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { - @Override - public void writeJson(JsonWriter out) throws Exception { - if (conversation_list) getConversations(context, out, offset, limit); - else getAllSms(context, out, offset, limit, number, contentURI); - } - }); - } - - @SuppressLint("SimpleDateFormat") - public static void getConversations(Context context, JsonWriter out, int offset, int limit) throws IOException { - ContentResolver cr = context.getContentResolver(); - String sortOrder = "date DESC"; - try (Cursor c = cr.query(Conversations.CONTENT_URI, null, null, null , sortOrder)) { - c.moveToLast(); - - Map nameCache = new HashMap<>(); - - out.beginArray(); - for (int i = 0, count = c.getCount(); i < count; i++) { - int id = c.getInt(c.getColumnIndex(THREAD_ID)); - - Cursor cc = cr.query(Sms.CONTENT_URI, null, - THREAD_ID + " == '" + id +"'", - null, "date DESC"); - if (cc.getCount() == 0) { - c.moveToNext(); - continue; - } - cc.moveToFirst(); - writeElement(cc, out, nameCache, context); - cc.close(); - c.moveToPrevious(); - } - out.endArray(); - } - } - - @SuppressLint("SimpleDateFormat") - private static void writeElement(Cursor c, JsonWriter out, Map nameCache, Context context) throws IOException { - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - - int threadID = c.getInt(c.getColumnIndexOrThrow(THREAD_ID)); - String smsAddress = c.getString(c.getColumnIndexOrThrow(ADDRESS)); - String smsBody = c.getString(c.getColumnIndexOrThrow(BODY)); - boolean read = (c.getInt(c.getColumnIndex(READ)) != 0); - long smsReceivedDate = c.getLong(c.getColumnIndexOrThrow(DATE)); - // long smsSentDate = c.getLong(c.getColumnIndexOrThrow(TextBasedSmsColumns.DATE_SENT)); - int smsID = c.getInt(c.getColumnIndexOrThrow("_id")); - - String smsSenderName = getContactNameFromNumber(nameCache, context, smsAddress); - String messageType = getMessageType(c.getInt(c.getColumnIndexOrThrow(TYPE))); - - out.beginObject(); - out.name("threadid").value(threadID); - out.name("type").value(messageType); - out.name("read").value(read); - - if (smsSenderName != null) { - if (messageType.equals("inbox")) { - out.name("sender").value(smsSenderName); - } else { - out.name("sender").value("You"); - } - } - - out.name("number").value(smsAddress); - - out.name("received").value(dateFormat.format(new Date(smsReceivedDate))); - // if (Math.abs(smsReceivedDate - smsSentDate) >= 60000) { - // out.write(" (sent "); - // out.write(dateFormat.format(new Date(smsSentDate))); - // out.write(")"); - // } - out.name("body").value(smsBody); - out.name("_id").value(smsID); - - out.endObject(); - } - - - @SuppressLint("SimpleDateFormat") - public static void getAllSms(Context context, JsonWriter out, int offset, int limit, String number, Uri contentURI) throws IOException { - ContentResolver cr = context.getContentResolver(); - String sortOrder = "date DESC LIMIT + " + limit + " OFFSET " + offset; - try (Cursor c = cr.query(contentURI, null, - ADDRESS + " LIKE '%" + number + "%'", null, sortOrder)) { - c.moveToLast(); - - new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - Map nameCache = new HashMap<>(); - - out.beginArray(); - for (int i = 0, count = c.getCount(); i < count; i++) { - writeElement(c, out, nameCache, context); - c.moveToPrevious(); - } - out.endArray(); - } - } - - private static String getContactNameFromNumber(Map cache, Context context, String number) { - if (cache.containsKey(number)) - return cache.get(number); - Uri contactUri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); - try (Cursor c = context.getContentResolver().query(contactUri, DISPLAY_NAME_PROJECTION, null, null, null)) { - String name = c.moveToFirst() ? c.getString(c.getColumnIndex(PhoneLookup.DISPLAY_NAME)) : null; - cache.put(number, name); - return name; - } - } - - private static String getMessageType(int type) { - switch (type) - { - case TextBasedSmsColumns.MESSAGE_TYPE_INBOX: - return "inbox"; - case TextBasedSmsColumns.MESSAGE_TYPE_SENT: - return "sent"; - case TextBasedSmsColumns.MESSAGE_TYPE_DRAFT: - return "draft"; - case TextBasedSmsColumns.MESSAGE_TYPE_FAILED: - return "failed"; - case TextBasedSmsColumns.MESSAGE_TYPE_OUTBOX: - return "outbox"; - default: - return ""; - } - } - - private static Uri typeToContentURI(int type) { - switch (type) { - case TextBasedSmsColumns.MESSAGE_TYPE_SENT: - return Sms.Sent.CONTENT_URI; - case TextBasedSmsColumns.MESSAGE_TYPE_DRAFT: - return Sms.Draft.CONTENT_URI; - case TextBasedSmsColumns.MESSAGE_TYPE_OUTBOX: - return Sms.Outbox.CONTENT_URI; - case TextBasedSmsColumns.MESSAGE_TYPE_INBOX: - return Sms.Inbox.CONTENT_URI; - case TextBasedSmsColumns.MESSAGE_TYPE_ALL: - default: - return Sms.CONTENT_URI; - } - } - - -} diff --git a/app/src/main/java/com/termux/api/SocketListener.java b/app/src/main/java/com/termux/api/SocketListener.java new file mode 100644 index 000000000..ed41ac7d8 --- /dev/null +++ b/app/src/main/java/com/termux/api/SocketListener.java @@ -0,0 +1,262 @@ +package com.termux.api; + +import android.app.Application; +import android.content.Intent; +import android.net.LocalServerSocket; +import android.net.LocalSocket; + +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; + +import java.io.BufferedWriter; +import java.io.DataInputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SocketListener { + + public static final String LISTEN_ADDRESS = TermuxConstants.TERMUX_API_PACKAGE_NAME + "://listen"; + private static final Pattern EXTRA_STRING = Pattern.compile("(-e|--es|--esa) +([^ ]+) +\"(.*?)(? { + try (LocalServerSocket listen = new LocalServerSocket(LISTEN_ADDRESS)) { + while (true) { + try (LocalSocket con = listen.accept(); + DataInputStream in = new DataInputStream(con.getInputStream()); + BufferedWriter out = new BufferedWriter(new OutputStreamWriter(con.getOutputStream()))) { + // only accept connections from Termux programs + if (con.getPeerCredentials().getUid() != app.getApplicationInfo().uid) { + continue; + } + try { + //System.out.println("connection"); + int length = in.readUnsignedShort(); + byte[] b = new byte[length]; + in.readFully(b); + String cmdline = new String(b, StandardCharsets.UTF_8); + + Intent intent = new Intent(app.getApplicationContext(), TermuxApiReceiver.class); + //System.out.println(cmdline.replaceAll("--es socket_input \".*?\"","").replaceAll("--es socket_output \".*?\"","")); + HashMap stringExtras = new HashMap<>(); + HashMap stringArrayExtras = new HashMap<>(); + HashMap booleanExtras = new HashMap<>(); + HashMap intExtras = new HashMap<>(); + HashMap floatExtras = new HashMap<>(); + HashMap intArrayExtras = new HashMap<>(); + HashMap longArrayExtras = new HashMap<>(); + boolean err = false; + + // extract and remove the string extras first, so another argument embedded in a string isn't counted as an argument + Matcher m = EXTRA_STRING.matcher(cmdline); + while (m.find()) { + String option = m.group(1); + if ("-e".equals(option) || "--es".equals(option)) { + // unescape " + stringExtras.put(m.group(2), Objects.requireNonNull(m.group(3)).replaceAll("\\\\\"", "\"")); + } + else { + // split the list + String[] list = Objects.requireNonNull(m.group(3)).split("(? e : stringExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + for (Map.Entry e : stringArrayExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + for (Map.Entry e : intExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + for (Map.Entry e : booleanExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + for (Map.Entry e : floatExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + for (Map.Entry e : intArrayExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + for (Map.Entry e : longArrayExtras.entrySet()) { + intent.putExtra(e.getKey(), e.getValue()); + } + app.getApplicationContext().sendOrderedBroadcast(intent, null); + // send a null byte as a sign that the arguments have been successfully received, parsed and the broadcast receiver is called + con.getOutputStream().write(0); + con.getOutputStream().flush(); + } + catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error parsing arguments", e); + out.write("Exception in the plugin\n"); + out.flush(); + } + } + catch (java.io.IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Connection error", e); + } + } + } + catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error listening for connections", e); + } + }); + listener.start(); + } + } + +} diff --git a/app/src/main/java/com/termux/api/TermuxAPIApplication.java b/app/src/main/java/com/termux/api/TermuxAPIApplication.java index 2184070eb..30f70b8b2 100644 --- a/app/src/main/java/com/termux/api/TermuxAPIApplication.java +++ b/app/src/main/java/com/termux/api/TermuxAPIApplication.java @@ -2,27 +2,40 @@ import android.app.Application; import android.content.Context; +import android.util.Log; -import com.termux.shared.crash.TermuxCrashUtils; +import com.termux.api.util.ResultReturner; import com.termux.shared.logger.Logger; -import com.termux.shared.settings.preferences.TermuxAPIAppSharedPreferences; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.crash.TermuxCrashUtils; +import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; public class TermuxAPIApplication extends Application { + public static final String LOG_TAG = "TermuxAPIApplication"; + public void onCreate() { super.onCreate(); + Log.i(LOG_TAG, "AppInit"); + + Context context = getApplicationContext(); + // Set crash handler for the app - TermuxCrashUtils.setCrashHandler(this); + TermuxCrashUtils.setCrashHandler(context); + + ResultReturner.setContext(this); // Set log config for the app - setLogLevel(getApplicationContext(), true); + setLogConfig(context, true); - Logger.logDebug("Starting Application"); + SocketListener.createSocketListener(this); } - public static void setLogLevel(Context context, boolean commitToFile) { + public static void setLogConfig(Context context, boolean commitToFile) { + Logger.setDefaultLogTag(TermuxConstants.TERMUX_API_APP_NAME.replaceAll("[: ]", "")); + // Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL} TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context); if (preferences == null) return; diff --git a/app/src/main/java/com/termux/api/TermuxAPIConstants.java b/app/src/main/java/com/termux/api/TermuxAPIConstants.java new file mode 100644 index 000000000..e27e5b987 --- /dev/null +++ b/app/src/main/java/com/termux/api/TermuxAPIConstants.java @@ -0,0 +1,17 @@ +package com.termux.api; + +import com.termux.shared.termux.TermuxConstants; +import static com.termux.shared.termux.TermuxConstants.TERMUX_API_PACKAGE_NAME; +import static com.termux.shared.termux.TermuxConstants.TERMUX_PACKAGE_NAME; + +public class TermuxAPIConstants { + + /** + * Termux:API Receiver name. + */ + public static final String TERMUX_API_RECEIVER_NAME = TERMUX_API_PACKAGE_NAME + ".TermuxApiReceiver"; // Default to "com.termux.api.TermuxApiReceiver" + + /** The Uri authority for Termux:API app file shares */ + public static final String TERMUX_API_FILE_SHARE_URI_AUTHORITY = TERMUX_PACKAGE_NAME + ".sharedfiles"; // Default: "com.termux.sharedfiles" + +} diff --git a/app/src/main/java/com/termux/api/TermuxApiReceiver.java b/app/src/main/java/com/termux/api/TermuxApiReceiver.java index fbfcee992..b752864be 100644 --- a/app/src/main/java/com/termux/api/TermuxApiReceiver.java +++ b/app/src/main/java/com/termux/api/TermuxApiReceiver.java @@ -5,31 +5,81 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.nfc.NfcAdapter; -import android.os.Build; import android.provider.Settings; import android.widget.Toast; -import com.termux.api.util.TermuxApiLogger; -import com.termux.api.util.TermuxApiPermissionActivity; +import com.termux.api.apis.AudioAPI; +import com.termux.api.apis.BatteryStatusAPI; +import com.termux.api.apis.BrightnessAPI; +import com.termux.api.apis.CallLogAPI; +import com.termux.api.apis.CameraInfoAPI; +import com.termux.api.apis.CameraPhotoAPI; +import com.termux.api.apis.ClipboardAPI; +import com.termux.api.apis.ContactListAPI; +import com.termux.api.apis.DialogAPI; +import com.termux.api.apis.DownloadAPI; +import com.termux.api.apis.FingerprintAPI; +import com.termux.api.apis.InfraredAPI; +import com.termux.api.apis.JobSchedulerAPI; +import com.termux.api.apis.KeystoreAPI; +import com.termux.api.apis.LocationAPI; +import com.termux.api.apis.MediaPlayerAPI; +import com.termux.api.apis.MediaScannerAPI; +import com.termux.api.apis.MicRecorderAPI; +import com.termux.api.apis.NfcAPI; +import com.termux.api.apis.NotificationAPI; +import com.termux.api.apis.NotificationListAPI; +import com.termux.api.apis.SAFAPI; +import com.termux.api.apis.SensorAPI; +import com.termux.api.apis.ShareAPI; +import com.termux.api.apis.SmsInboxAPI; +import com.termux.api.apis.SmsSendAPI; +import com.termux.api.apis.SpeechToTextAPI; +import com.termux.api.apis.StorageGetAPI; +import com.termux.api.apis.TelephonyAPI; +import com.termux.api.apis.TextToSpeechAPI; +import com.termux.api.apis.ToastAPI; +import com.termux.api.apis.TorchAPI; +import com.termux.api.apis.UsbAPI; +import com.termux.api.apis.VibrateAPI; +import com.termux.api.apis.VolumeAPI; +import com.termux.api.apis.WallpaperAPI; +import com.termux.api.apis.WifiAPI; +import com.termux.api.activities.TermuxApiPermissionActivity; +import com.termux.api.util.ResultReturner; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.plugins.TermuxPluginUtils; public class TermuxApiReceiver extends BroadcastReceiver { + private static final String LOG_TAG = "TermuxApiReceiver"; + @Override public void onReceive(Context context, Intent intent) { + TermuxAPIApplication.setLogConfig(context, false); + Logger.logDebug(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + try { doWork(context, intent); - } catch (Exception e) { + } catch (Throwable t) { + String message = "Error in " + LOG_TAG; // Make sure never to throw exception from BroadCastReceiver to avoid "process is bad" // behaviour from the Android system. - TermuxApiLogger.error("Error in TermuxApiReceiver", e); + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + + TermuxPluginUtils.sendPluginCommandErrorNotification(context, LOG_TAG, + TermuxConstants.TERMUX_API_APP_NAME + " Error", message, t); + + ResultReturner.noteDone(this, intent); } } private void doWork(Context context, Intent intent) { String apiMethod = intent.getStringExtra("api_method"); if (apiMethod == null) { - TermuxApiLogger.error("Missing 'api_method' extra"); + Logger.logError(LOG_TAG, "Missing 'api_method' extra"); return; } @@ -57,7 +107,7 @@ private void doWork(Context context, Intent intent) { break; case "CameraPhoto": if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.CAMERA)) { - PhotoAPI.onReceive(this, context, intent); + CameraPhotoAPI.onReceive(this, context, intent); } break; case "CallLog": @@ -74,7 +124,7 @@ private void doWork(Context context, Intent intent) { } break; case "Dialog": - context.startActivity(new Intent(context, DialogActivity.class).putExtras(intent.getExtras()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + DialogAPI.onReceive(context, intent); break; case "Download": DownloadAPI.onReceive(this, context, intent); @@ -115,10 +165,10 @@ private void doWork(Context context, Intent intent) { } break; case "Nfc": - context.startActivity(new Intent(context, NfcActivity.class).putExtras(intent.getExtras()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + NfcAPI.onReceive(context, intent); break; case "NotificationList": - ComponentName cn = new ComponentName(context, NotificationService.class); + ComponentName cn = new ComponentName(context, NotificationListAPI.NotificationService.class); String flat = Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners"); final boolean NotificationServiceEnabled = flat != null && flat.contains(cn.flattenToString()); if (!NotificationServiceEnabled) { @@ -131,12 +181,18 @@ private void doWork(Context context, Intent intent) { case "Notification": NotificationAPI.onReceiveShowNotification(this, context, intent); break; + case "NotificationChannel": + NotificationAPI.onReceiveChannel(this, context, intent); + break; case "NotificationRemove": NotificationAPI.onReceiveRemoveNotification(this, context, intent); break; case "NotificationReply": NotificationAPI.onReceiveReplyToNotification(this, context, intent); break; + case "SAF": + SAFAPI.onReceive(this, context, intent); + break; case "Sensor": SensorAPI.onReceive(context, intent); break; @@ -149,7 +205,7 @@ private void doWork(Context context, Intent intent) { } break; case "SmsSend": - if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.SEND_SMS)) { + if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.READ_PHONE_STATE, Manifest.permission.SEND_SMS)) { SmsSendAPI.onReceive(this, context, intent); } break; @@ -186,7 +242,7 @@ private void doWork(Context context, Intent intent) { TorchAPI.onReceive(this, context, intent); break; case "Usb": - UsbAPI.onReceive(this, context, intent); + UsbAPI.onReceive(context, intent); break; case "Vibrate": VibrateAPI.onReceive(this, context, intent); @@ -209,7 +265,7 @@ private void doWork(Context context, Intent intent) { WifiAPI.onReceiveWifiEnable(this, context, intent); break; default: - TermuxApiLogger.error("Unrecognized 'api_method' extra: '" + apiMethod + "'"); + Logger.logError(LOG_TAG, "Unrecognized 'api_method' extra: '" + apiMethod + "'"); } } diff --git a/app/src/main/java/com/termux/api/UsbAPI.java b/app/src/main/java/com/termux/api/UsbAPI.java deleted file mode 100644 index d71f1181b..000000000 --- a/app/src/main/java/com/termux/api/UsbAPI.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.termux.api; - -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; -import android.hardware.usb.UsbManager; -import android.os.Looper; -import android.util.JsonWriter; -import android.util.SparseArray; - -import com.termux.api.util.ResultReturner; - -import java.io.IOException; -import java.io.PrintWriter; -import java.util.Iterator; -import java.util.HashMap; - -import androidx.annotation.NonNull; - -public class UsbAPI { - - private static SparseArray openDevices = new SparseArray<>(); - - static void onReceive(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { - UsbDevice device; - String action = intent.getAction(); - if (action == null) { - ResultReturner.returnData(apiReceiver, intent, out -> out.append("Missing action\n")); - } else { - switch (action) { - case "list": - ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { - @Override - public void writeJson(JsonWriter out) throws Exception { - listDevices(context, out); - } - }); - break; - case "permission": - device = getDevice(apiReceiver, context, intent); - if (device == null) return; - ResultReturner.returnData(apiReceiver, intent, out -> { - boolean result = getPermission(device, context, intent); - out.append(result ? "yes\n" : "no\n"); - }); - break; - case "open": - device = getDevice(apiReceiver, context, intent); - if (device == null) return; - ResultReturner.returnData(apiReceiver, intent, new ResultReturner.WithAncillaryFd() { - @Override - public void writeResult(PrintWriter out) { - if (getPermission(device, context, intent)) { - int result = open(device, context); - if (result < 0) { - out.append("Failed to open device\n"); - } else { - this.setFd(result); - out.append("@"); // has to be non-empty - } - } else out.append("No permission\n"); - } - }); - - break; - default: - ResultReturner.returnData(apiReceiver, intent, out -> out.append("Invalid action\n")); - } - } - - } - - private static void listDevices(final Context context, JsonWriter out) throws IOException { - final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - HashMap deviceList = usbManager.getDeviceList(); - Iterator deviceIterator = deviceList.keySet().iterator(); - out.beginArray(); - while (deviceIterator.hasNext()) { - out.value(deviceIterator.next()); - } - out.endArray(); - } - - private static UsbDevice getDevice(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { - String deviceName = intent.getStringExtra("device"); - final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - HashMap deviceList = usbManager.getDeviceList(); - UsbDevice device = deviceList.get(deviceName); - if (device == null) { - ResultReturner.returnData(apiReceiver, intent, out -> out.append("No such device\n")); - } - return device; - } - - private static boolean hasPermission(final @NonNull UsbDevice device, final Context context) { - final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - return usbManager.hasPermission(device); - } - - private static boolean requestPermission(final @NonNull UsbDevice device, final Context context) { - Looper.prepare(); - Looper looper = Looper.myLooper(); - final boolean[] result = new boolean[1]; - - final String ACTION_USB_PERMISSION = "com.termux.api.USB_PERMISSION"; - final BroadcastReceiver usbReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context usbContext, final Intent usbIntent) { - String action = usbIntent.getAction(); - if (ACTION_USB_PERMISSION.equals(action)) { - synchronized (this) { - UsbDevice device = usbIntent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - if (usbIntent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - if (device != null) { - result[0] = true; - if (looper != null) looper.quit(); - } - } else { - result[0] = false; - if (looper != null) looper.quit(); - } - } - - } - } - }; - - final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - PendingIntent permissionIntent = PendingIntent.getBroadcast(context, 0, - new Intent(ACTION_USB_PERMISSION), 0); - IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION); - context.getApplicationContext().registerReceiver(usbReceiver, filter); - usbManager.requestPermission(device, permissionIntent); - Looper.loop(); - return result[0]; - } - - private static boolean getPermission(final @NonNull UsbDevice device, final Context context, final Intent intent) { - boolean request = intent.getBooleanExtra("request", false); - if(request) { - return requestPermission(device, context); - } else { - return hasPermission(device, context); - } - } - - private static int open(final @NonNull UsbDevice device, final Context context) { - final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - UsbDeviceConnection connection = usbManager.openDevice(device); - if (connection == null) - return -2; - int fd = connection.getFileDescriptor(); - if (fd == -1) { - connection.close(); - return -1; - } - openDevices.put(fd, connection); - return fd; - } - -} diff --git a/app/src/main/java/com/termux/api/VibrateAPI.java b/app/src/main/java/com/termux/api/VibrateAPI.java deleted file mode 100644 index 3c2cb88f5..000000000 --- a/app/src/main/java/com/termux/api/VibrateAPI.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.termux.api; - -import android.content.Context; -import android.content.Intent; -import android.media.AudioManager; -import android.os.Vibrator; - -import com.termux.api.util.ResultReturner; - -public class VibrateAPI { - - static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { - Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); - int milliseconds = intent.getIntExtra("duration_ms", 1000); - boolean force = intent.getBooleanExtra("force", false); - - AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - if (am.getRingerMode() == AudioManager.RINGER_MODE_SILENT && !force) { - // Not vibrating since in silent mode and -f/--force option not used. - } else { - vibrator.vibrate(milliseconds); - } - - ResultReturner.noteDone(apiReceiver, intent); - } - -} diff --git a/app/src/main/java/com/termux/api/activities/TermuxAPIMainActivity.java b/app/src/main/java/com/termux/api/activities/TermuxAPIMainActivity.java new file mode 100644 index 000000000..e4f596da6 --- /dev/null +++ b/app/src/main/java/com/termux/api/activities/TermuxAPIMainActivity.java @@ -0,0 +1,217 @@ +package com.termux.api.activities; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Button; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import com.termux.api.TermuxAPIApplication; +import com.termux.api.settings.activities.TermuxAPISettingsActivity; +import com.termux.api.util.ViewUtils; +import com.termux.shared.activity.ActivityUtils; +import com.termux.shared.activity.media.AppCompatActivityUtils; +import com.termux.shared.android.PackageUtils; +import com.termux.shared.android.PermissionUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.theme.TermuxThemeUtils; +import com.termux.shared.theme.NightMode; +import com.termux.api.R; + +public class TermuxAPIMainActivity extends AppCompatActivity { + + private TextView mBatteryOptimizationNotDisabledWarning; + private TextView mDisplayOverOtherAppsPermissionNotGrantedWarning; + + private Button mDisableBatteryOptimization; + private Button mGrantDisplayOverOtherAppsPermission; + + public static final String LOG_TAG = "TermuxAPIMainActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_termux_api_main); + + // Set NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(this); + AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true); + + AppCompatActivityUtils.setToolbar(this, com.termux.shared.R.id.toolbar); + AppCompatActivityUtils.setToolbarTitle(this, com.termux.shared.R.id.toolbar, TermuxConstants.TERMUX_API_APP_NAME, 0); + + TextView pluginInfo = findViewById(R.id.textview_plugin_info); + pluginInfo.setText(getString(R.string.plugin_info, TermuxConstants.TERMUX_GITHUB_REPO_URL, + TermuxConstants.TERMUX_API_GITHUB_REPO_URL, TermuxConstants.TERMUX_API_APT_PACKAGE_NAME, + TermuxConstants.TERMUX_API_APT_GITHUB_REPO_URL)); + + mBatteryOptimizationNotDisabledWarning = findViewById(R.id.textview_battery_optimization_not_disabled_warning); + mDisableBatteryOptimization = findViewById(R.id.btn_disable_battery_optimizations); + mDisableBatteryOptimization.setOnClickListener(v -> requestDisableBatteryOptimizations()); + + mDisplayOverOtherAppsPermissionNotGrantedWarning = findViewById(R.id.textview_display_over_other_apps_not_granted_warning); + mGrantDisplayOverOtherAppsPermission = findViewById(R.id.button_grant_display_over_other_apps_permission); + mGrantDisplayOverOtherAppsPermission.setOnClickListener(v -> requestDisplayOverOtherAppsPermission()); + } + + @Override + protected void onResume() { + super.onResume(); + + // Set log level for the app + TermuxAPIApplication.setLogConfig(this, false); + + Logger.logVerbose(LOG_TAG, "onResume"); + + checkIfBatteryOptimizationNotDisabled(); + checkIfDisplayOverOtherAppsPermissionNotGranted(); + setChangeLauncherActivityStateViews(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.activity_termux_api_main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.menu_settings) { + openSettings(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + + + private void checkIfBatteryOptimizationNotDisabled() { + if (mBatteryOptimizationNotDisabledWarning == null) return; + + // If battery optimizations not disabled + if (!PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) { + ViewUtils.setWarningTextViewAndButtonState(this, mBatteryOptimizationNotDisabledWarning, + mDisableBatteryOptimization, true, getString(R.string.action_disable_battery_optimizations)); + } else { + ViewUtils.setWarningTextViewAndButtonState(this, mBatteryOptimizationNotDisabledWarning, + mDisableBatteryOptimization, false, getString(R.string.action_already_disabled)); + } + } + + private void requestDisableBatteryOptimizations() { + Logger.logDebug(LOG_TAG, "Requesting to disable battery optimizations"); + PermissionUtils.requestDisableBatteryOptimizations(this, PermissionUtils.REQUEST_DISABLE_BATTERY_OPTIMIZATIONS); + } + + + + private void checkIfDisplayOverOtherAppsPermissionNotGranted() { + if (mDisplayOverOtherAppsPermissionNotGrantedWarning == null) return; + + // If display over other apps permission not granted + if (!PermissionUtils.checkDisplayOverOtherAppsPermission(this)) { + ViewUtils.setWarningTextViewAndButtonState(this, mDisplayOverOtherAppsPermissionNotGrantedWarning, + mGrantDisplayOverOtherAppsPermission, true, getString(R.string.action_grant_display_over_other_apps_permission)); + } else { + ViewUtils.setWarningTextViewAndButtonState(this, mDisplayOverOtherAppsPermissionNotGrantedWarning, + mGrantDisplayOverOtherAppsPermission, false, getString(R.string.action_already_granted)); + } + } + + private void requestDisplayOverOtherAppsPermission() { + Logger.logDebug(LOG_TAG, "Requesting to grant display over other apps permission"); + PermissionUtils.requestDisplayOverOtherAppsPermission(this, PermissionUtils.REQUEST_GRANT_DISPLAY_OVER_OTHER_APPS_PERMISSION); + } + + + + private void setChangeLauncherActivityStateViews() { + String packageName = TermuxConstants.TERMUX_API_PACKAGE_NAME; + String className = TermuxConstants.TERMUX_API_APP.TERMUX_API_LAUNCHER_ACTIVITY_NAME; + + TextView changeLauncherActivityStateTextView = findViewById(R.id.textview_change_launcher_activity_state_details); + changeLauncherActivityStateTextView.setText(MarkdownUtils.getSpannedMarkdownText(this, + getString(R.string.msg_change_launcher_activity_state_info, packageName, getClass().getName()))); + + Button changeLauncherActivityStateButton = findViewById(R.id.button_change_launcher_activity_state); + String stateChangeMessage; + boolean newState; + + Boolean currentlyDisabled = PackageUtils.isComponentDisabled(this, + packageName, className, false); + if (currentlyDisabled == null) { + Logger.logError(LOG_TAG, "Failed to check if \"" + packageName + "/" + className + "\" launcher activity is disabled"); + changeLauncherActivityStateButton.setEnabled(false); + changeLauncherActivityStateButton.setAlpha(.5f); + changeLauncherActivityStateButton.setText(com.termux.shared.R.string.action_disable_launcher_icon); + changeLauncherActivityStateButton.setOnClickListener(null); + return; + } + + changeLauncherActivityStateButton.setEnabled(true); + changeLauncherActivityStateButton.setAlpha(1f); + if (currentlyDisabled) { + changeLauncherActivityStateButton.setText(com.termux.shared.R.string.action_enable_launcher_icon); + stateChangeMessage = getString(com.termux.shared.R.string.msg_enabling_launcher_icon, TermuxConstants.TERMUX_API_APP_NAME); + newState = true; + } else { + changeLauncherActivityStateButton.setText(com.termux.shared.R.string.action_disable_launcher_icon); + stateChangeMessage = getString(com.termux.shared.R.string.msg_disabling_launcher_icon, TermuxConstants.TERMUX_API_APP_NAME); + newState = false; + } + + changeLauncherActivityStateButton.setOnClickListener(v -> { + Logger.logInfo(LOG_TAG, stateChangeMessage); + String errmsg = PackageUtils.setComponentState(this, + packageName, className, newState, stateChangeMessage, true); + if (errmsg == null) + setChangeLauncherActivityStateViews(); + else + Logger.logError(LOG_TAG, errmsg); + }); + } + + + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data)); + + switch (requestCode) { + case PermissionUtils.REQUEST_DISABLE_BATTERY_OPTIMIZATIONS: + if(PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) + Logger.logDebug(LOG_TAG, "Battery optimizations disabled by user on request."); + else + Logger.logDebug(LOG_TAG, "Battery optimizations not disabled by user on request."); + break; + case PermissionUtils.REQUEST_GRANT_DISPLAY_OVER_OTHER_APPS_PERMISSION: + if(PermissionUtils.checkDisplayOverOtherAppsPermission(this)) + Logger.logDebug(LOG_TAG, "Display over other apps granted by user on request."); + else + Logger.logDebug(LOG_TAG, "Display over other apps denied by user on request."); + break; + default: + Logger.logError(LOG_TAG, "Unknown request code \"" + requestCode + "\" passed to onRequestPermissionsResult"); + } + } + + + + private void openSettings() { + ActivityUtils.startActivity(this, new Intent().setClass(this, TermuxAPISettingsActivity.class)); + } + +} diff --git a/app/src/main/java/com/termux/api/util/TermuxApiPermissionActivity.java b/app/src/main/java/com/termux/api/activities/TermuxApiPermissionActivity.java similarity index 76% rename from app/src/main/java/com/termux/api/util/TermuxApiPermissionActivity.java rename to app/src/main/java/com/termux/api/activities/TermuxApiPermissionActivity.java index 2fcaa611a..a7a2cb634 100644 --- a/app/src/main/java/com/termux/api/util/TermuxApiPermissionActivity.java +++ b/app/src/main/java/com/termux/api/activities/TermuxApiPermissionActivity.java @@ -1,22 +1,26 @@ -package com.termux.api.util; +package com.termux.api.activities; -import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; -import android.os.Build; import android.text.TextUtils; import android.util.JsonWriter; +import com.termux.api.util.ResultReturner; +import com.termux.shared.android.PermissionUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; + import java.util.ArrayList; public class TermuxApiPermissionActivity extends Activity { + private static final String LOG_TAG = "TermuxApiPermissionActivity"; + /** * Intent extra containing the permissions to request. */ - public static final String PERMISSIONS_EXTRA = "com.termux.api.permission_extra"; + public static final String PERMISSIONS_EXTRA = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".permission_extra"; /** * Check for and request permissions if necessary. @@ -26,7 +30,7 @@ public class TermuxApiPermissionActivity extends Activity { public static boolean checkAndRequestPermissions(Context context, Intent intent, String... permissions) { final ArrayList permissionsToRequest = new ArrayList<>(); for (String permission : permissions) { - if (context.checkSelfPermission(permission) == PackageManager.PERMISSION_DENIED) { + if (!PermissionUtils.checkPermission(context, permission)) { permissionsToRequest.add(permission); } } @@ -56,15 +60,19 @@ public void writeJson(JsonWriter out) throws Exception { @Override protected void onNewIntent(Intent intent) { + Logger.logDebug(LOG_TAG, "onNewIntent"); + super.onNewIntent(intent); setIntent(intent); } @Override protected void onResume() { + Logger.logVerbose(LOG_TAG, "onResume"); + super.onResume(); ArrayList permissionValues = getIntent().getStringArrayListExtra(PERMISSIONS_EXTRA); - requestPermissions(permissionValues.toArray(new String[0]), 123); + PermissionUtils.requestPermissions(this, permissionValues.toArray(new String[0]), 0); finish(); } diff --git a/app/src/main/java/com/termux/api/apis/AudioAPI.java b/app/src/main/java/com/termux/api/apis/AudioAPI.java new file mode 100644 index 000000000..d69800b62 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/AudioAPI.java @@ -0,0 +1,90 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.os.Build; +import android.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +public class AudioAPI { + + private static final String LOG_TAG = "AudioAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + final String SampleRate = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE); + final String framesPerBuffer = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER); + final boolean bluetootha2dp = am.isBluetoothA2dpOn(); + final boolean wiredhs = am.isWiredHeadsetOn(); + + final int sr, bs, sr_ll, bs_ll, sr_ps, bs_ps; + AudioTrack at; + at = new AudioTrack.Builder() + .setBufferSizeInBytes(4) // one 16bit 2ch frame + .build(); + sr = at.getSampleRate(); + bs = at.getBufferSizeInFrames(); + at.release(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + at = new AudioTrack.Builder() + .setBufferSizeInBytes(4) // one 16bit 2ch frame + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) + .build(); + } else { + AudioAttributes aa = new AudioAttributes.Builder() + .setFlags(AudioAttributes.FLAG_LOW_LATENCY) + .build(); + at = new AudioTrack.Builder() + .setAudioAttributes(aa) + .setBufferSizeInBytes(4) // one 16bit 2ch frame + .build(); + } + sr_ll = at.getSampleRate(); + bs_ll = at.getBufferSizeInFrames(); + at.release(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + at = new AudioTrack.Builder() + .setBufferSizeInBytes(4) // one 16bit 2ch frame + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_POWER_SAVING) + .build(); + sr_ps = at.getSampleRate(); + bs_ps = at.getBufferSizeInFrames(); + at.release(); + } else { + sr_ps = sr; + bs_ps = bs; + } + + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + public void writeJson(JsonWriter out) throws Exception { + out.beginObject(); + out.name("PROPERTY_OUTPUT_SAMPLE_RATE").value(SampleRate); + out.name("PROPERTY_OUTPUT_FRAMES_PER_BUFFER").value(framesPerBuffer); + out.name("AUDIOTRACK_SAMPLE_RATE").value(sr); + out.name("AUDIOTRACK_BUFFER_SIZE_IN_FRAMES").value(bs); + if (sr_ll != sr || bs_ll != bs) { // all or nothing + out.name("AUDIOTRACK_SAMPLE_RATE_LOW_LATENCY").value(sr_ll); + out.name("AUDIOTRACK_BUFFER_SIZE_IN_FRAMES_LOW_LATENCY").value(bs_ll); + } + if (sr_ps != sr || bs_ps != bs) { // all or nothing + out.name("AUDIOTRACK_SAMPLE_RATE_POWER_SAVING").value(sr_ps); + out.name("AUDIOTRACK_BUFFER_SIZE_IN_FRAMES_POWER_SAVING").value(bs_ps); + } + out.name("BLUETOOTH_A2DP_IS_ON").value(bluetootha2dp); + out.name("WIREDHEADSET_IS_CONNECTED").value(wiredhs); + out.endObject(); + } + }); + } + +} diff --git a/app/src/main/java/com/termux/api/apis/BatteryStatusAPI.java b/app/src/main/java/com/termux/api/apis/BatteryStatusAPI.java new file mode 100644 index 000000000..1d3c41f45 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/BatteryStatusAPI.java @@ -0,0 +1,186 @@ +package com.termux.api.apis; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; +import android.os.Build; +import android.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.api.util.ResultReturner.ResultJsonWriter; +import com.termux.shared.logger.Logger; + +public class BatteryStatusAPI { + + private static final String LOG_TAG = "BatteryStatusAPI"; + + private static int sTargetSdkVersion; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + sTargetSdkVersion = context.getApplicationContext().getApplicationInfo().targetSdkVersion; + + ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { + @SuppressLint("DefaultLocale") + @Override + public void writeJson(JsonWriter out) throws Exception { + // - https://cs.android.com/android/platform/superproject/+/android-15.0.0_r1:frameworks/base/services/core/java/com/android/server/BatteryService.java;l=745 + Intent batteryStatus = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + + int batteryLevel = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int batteryScale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + + int health = batteryStatus.getIntExtra(BatteryManager.EXTRA_HEALTH, -1); + String batteryHealth; + switch (health) { + case BatteryManager.BATTERY_HEALTH_COLD: + batteryHealth = "COLD"; + break; + case BatteryManager.BATTERY_HEALTH_DEAD: + batteryHealth = "DEAD"; + break; + case BatteryManager.BATTERY_HEALTH_GOOD: + batteryHealth = "GOOD"; + break; + case BatteryManager.BATTERY_HEALTH_OVERHEAT: + batteryHealth = "OVERHEAT"; + break; + case BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE: + batteryHealth = "OVER_VOLTAGE"; + break; + case BatteryManager.BATTERY_HEALTH_UNKNOWN: + batteryHealth = "UNKNOWN"; + break; + case BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE: + batteryHealth = "UNSPECIFIED_FAILURE"; + break; + default: + batteryHealth = Integer.toString(health); + } + + // BatteryManager.EXTRA_PLUGGED: "Extra for ACTION_BATTERY_CHANGED: integer indicating whether the + // device is plugged in to a power source; 0 means it is on battery, other constants are different types + // of power sources." + int pluggedInt = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); + String batteryPlugged; + switch (pluggedInt) { + case 0: + batteryPlugged = "UNPLUGGED"; + break; + case BatteryManager.BATTERY_PLUGGED_AC: + batteryPlugged = "PLUGGED_AC"; + break; + case BatteryManager.BATTERY_PLUGGED_DOCK: + batteryPlugged = "PLUGGED_DOCK"; + break; + case BatteryManager.BATTERY_PLUGGED_USB: + batteryPlugged = "PLUGGED_USB"; + break; + case BatteryManager.BATTERY_PLUGGED_WIRELESS: + batteryPlugged = "PLUGGED_WIRELESS"; + break; + default: + batteryPlugged = "PLUGGED_" + pluggedInt; + } + + double batteryTemperature = batteryStatus.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) / 10.f; + + String batteryStatusString; + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + switch (status) { + case BatteryManager.BATTERY_STATUS_CHARGING: + batteryStatusString = "CHARGING"; + break; + case BatteryManager.BATTERY_STATUS_DISCHARGING: + batteryStatusString = "DISCHARGING"; + break; + case BatteryManager.BATTERY_STATUS_FULL: + batteryStatusString = "FULL"; + break; + case BatteryManager.BATTERY_STATUS_NOT_CHARGING: + batteryStatusString = "NOT_CHARGING"; + break; + case BatteryManager.BATTERY_STATUS_UNKNOWN: + batteryStatusString = "UNKNOWN"; + break; + default: + Logger.logError(LOG_TAG, "Invalid BatteryManager.EXTRA_STATUS value: " + status); + batteryStatusString = "UNKNOWN"; + } + + // - https://stackoverflow.com/questions/24500795/android-battery-voltage-unit-discrepancies + int batteryVoltage = batteryStatus.getIntExtra(BatteryManager.EXTRA_VOLTAGE, -1); + // If in V, convert to mV. + if (batteryVoltage < 100) { + Logger.logVerbose(LOG_TAG, "Fixing voltage from " + batteryVoltage + " to " + (batteryVoltage * 1000)); + batteryVoltage = batteryVoltage * 1000; + } + + BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + + // > Instantaneous battery current in microamperes, as an integer. + // > Positive values indicate net current entering the battery from a charge source, + // > negative values indicate net current discharging from the battery. + // However, some devices may return negative values while charging, and positive + // values while discharging. Inverting sign based on charging state is not a + // possibility as charging current may be lower than current being used by device if + // charger does not output enough current, and will result in false inversions. + // - https://developer.android.com/reference/android/os/BatteryManager#BATTERY_PROPERTY_CURRENT_NOW + // - https://issuetracker.google.com/issues/37131318 + int batteryCurrentNow = getIntProperty(batteryManager, BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); + + // - https://stackoverflow.com/questions/64532112/batterymanagers-battery-property-current-now-returning-0-or-incorrect-current-v + if (Math.abs(batteryCurrentNow / 1000) < 1.0) { + Logger.logVerbose(LOG_TAG, "Fixing current_now from " + batteryCurrentNow + " to " + (batteryCurrentNow * 1000)); + batteryCurrentNow = batteryCurrentNow * 1000; + } + + out.beginObject(); + out.name("present").value(batteryStatus.getBooleanExtra(BatteryManager.EXTRA_PRESENT, false)); + out.name("technology").value(batteryStatus.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)); + out.name("health").value(batteryHealth); + out.name("plugged").value(batteryPlugged); + out.name("status").value(batteryStatusString); + out.name("temperature").value(String.format("%.1f", batteryTemperature)); + out.name("voltage").value(batteryVoltage); + out.name("current").value(batteryCurrentNow); + out.name("current_average").value(getIntProperty(batteryManager, BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)); + out.name("percentage").value(getIntProperty(batteryManager, BatteryManager.BATTERY_PROPERTY_CAPACITY)); + out.name("level").value(batteryLevel); + out.name("scale").value(batteryScale); + out.name("charge_counter").value(getIntProperty(batteryManager, BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)); + out.name("energy").value(getLongProperty(batteryManager, BatteryManager.BATTERY_PROPERTY_ENERGY_COUNTER)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + int batteryCycle = batteryStatus.getIntExtra(BatteryManager.EXTRA_CYCLE_COUNT, -1); + out.name("cycle").value(batteryCycle != -1 ? batteryCycle : null); + } + out.endObject(); + } + }); + } + + /** + * - https://developer.android.com/reference/android/os/BatteryManager.html#getIntProperty(int) + */ + private static Integer getIntProperty(BatteryManager batteryManager, int id) { + if (batteryManager == null) return null; + int value = batteryManager.getIntProperty(id); + if (sTargetSdkVersion < Build.VERSION_CODES.P) + return value != 0 ? value : null; + else + return value != Integer.MIN_VALUE ? value : null; + } + + /** + * - https://developer.android.com/reference/android/os/BatteryManager.html#getLongProperty(int) + */ + private static Long getLongProperty(BatteryManager batteryManager, int id) { + if (batteryManager == null) return null; + long value = batteryManager.getLongProperty(id); + return value != Long.MIN_VALUE ? value : null; + } +} diff --git a/app/src/main/java/com/termux/api/BrightnessAPI.java b/app/src/main/java/com/termux/api/apis/BrightnessAPI.java similarity index 83% rename from app/src/main/java/com/termux/api/BrightnessAPI.java rename to app/src/main/java/com/termux/api/apis/BrightnessAPI.java index 198dc962a..7bbeb5b8c 100644 --- a/app/src/main/java/com/termux/api/BrightnessAPI.java +++ b/app/src/main/java/com/termux/api/apis/BrightnessAPI.java @@ -1,15 +1,21 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.provider.Settings; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; public class BrightnessAPI { + private static final String LOG_TAG = "BrightnessAPI"; + public static void onReceive(final TermuxApiReceiver receiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + final ContentResolver contentResolver = context.getContentResolver(); if (intent.hasExtra("auto")) { boolean auto = intent.getBooleanExtra("auto", false); diff --git a/app/src/main/java/com/termux/api/CallLogAPI.java b/app/src/main/java/com/termux/api/apis/CallLogAPI.java similarity index 80% rename from app/src/main/java/com/termux/api/CallLogAPI.java rename to app/src/main/java/com/termux/api/apis/CallLogAPI.java index cf7232f63..39c60b546 100644 --- a/app/src/main/java/com/termux/api/CallLogAPI.java +++ b/app/src/main/java/com/termux/api/apis/CallLogAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.ContentResolver; import android.content.Context; @@ -8,6 +8,7 @@ import android.util.JsonWriter; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; import java.io.IOException; import java.text.DateFormat; @@ -20,7 +21,11 @@ */ public class CallLogAPI { - static void onReceive(final Context context, final Intent intent) { + private static final String LOG_TAG = "CallLogAPI"; + + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + final int offset = intent.getIntExtra("offset", 0); final int limit = intent.getIntExtra("limit", 50); @@ -33,10 +38,12 @@ public void writeJson(JsonWriter out) throws Exception { } private static void getCallLogs(Context context, JsonWriter out, int offset, int limit) throws IOException { - ContentResolver cr = context.getContentResolver(); - String sortOrder = "date DESC LIMIT + " + limit + " OFFSET " + offset; + ContentResolver contentResolver = context.getContentResolver(); - try (Cursor cur = cr.query(CallLog.Calls.CONTENT_URI, null, null, null, sortOrder)) { + try (Cursor cur = contentResolver.query(CallLog.Calls.CONTENT_URI.buildUpon(). + appendQueryParameter(CallLog.Calls.LIMIT_PARAM_KEY, String.valueOf(limit)). + appendQueryParameter(CallLog.Calls.OFFSET_PARAM_KEY, String.valueOf(offset)) + .build(), null, null, null, "date DESC")) { cur.moveToLast(); int nameIndex = cur.getColumnIndex(CallLog.Calls.CACHED_NAME); @@ -44,6 +51,7 @@ private static void getCallLogs(Context context, JsonWriter out, int offset, int int dateIndex = cur.getColumnIndex(CallLog.Calls.DATE); int durationIndex = cur.getColumnIndex(CallLog.Calls.DURATION); int callTypeIndex = cur.getColumnIndex(CallLog.Calls.TYPE); + int simTypeIndex = cur.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID); SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); out.beginArray(); @@ -56,6 +64,7 @@ private static void getCallLogs(Context context, JsonWriter out, int offset, int out.name("type").value(getCallTypeString(cur.getInt(callTypeIndex))); out.name("date").value(getDateString(cur.getLong(dateIndex), dateFormat)); out.name("duration").value(getTimeString(cur.getInt(durationIndex))); + out.name("sim_id").value(cur.getString(simTypeIndex)); cur.moveToPrevious(); out.endObject(); diff --git a/app/src/main/java/com/termux/api/CameraInfoAPI.java b/app/src/main/java/com/termux/api/apis/CameraInfoAPI.java similarity index 95% rename from app/src/main/java/com/termux/api/CameraInfoAPI.java rename to app/src/main/java/com/termux/api/apis/CameraInfoAPI.java index 24ba835a1..bf27a0f4d 100644 --- a/app/src/main/java/com/termux/api/CameraInfoAPI.java +++ b/app/src/main/java/com/termux/api/apis/CameraInfoAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.Context; import android.content.Intent; @@ -11,12 +11,18 @@ import android.util.Size; import android.util.SizeF; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.api.util.ResultReturner.ResultJsonWriter; +import com.termux.shared.logger.Logger; public class CameraInfoAPI { - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + private static final String LOG_TAG = "CameraInfoAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { diff --git a/app/src/main/java/com/termux/api/PhotoAPI.java b/app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java similarity index 75% rename from app/src/main/java/com/termux/api/PhotoAPI.java rename to app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java index f34488000..3cc6738a0 100644 --- a/app/src/main/java/com/termux/api/PhotoAPI.java +++ b/app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.Context; import android.content.Intent; @@ -20,8 +20,12 @@ import android.view.Surface; import android.view.WindowManager; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.file.TermuxFileUtils; import java.io.File; import java.io.FileOutputStream; @@ -34,20 +38,41 @@ import java.util.List; import java.util.Objects; -public class PhotoAPI { +public class CameraPhotoAPI { + + private static final String LOG_TAG = "CameraPhotoAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { final String filePath = intent.getStringExtra("file"); - final File outputFile = new File(filePath); - final File outputDir = outputFile.getParentFile(); final String cameraId = Objects.toString(intent.getStringExtra("camera"), "0"); ResultReturner.returnData(apiReceiver, intent, stdout -> { - if (!(outputDir.isDirectory() || outputDir.mkdirs())) { - stdout.println("Not a folder (and unable to create it): " + outputDir.getAbsolutePath()); - } else { - takePicture(stdout, context, outputFile, cameraId); + if (filePath == null || filePath.isEmpty()) { + stdout.println("ERROR: " + "File path not passed"); + return; + } + + // Get canonical path of photoFilePath + String photoFilePath = TermuxFileUtils.getCanonicalPath(filePath, null, true); + String photoDirPath = FileUtils.getFileDirname(photoFilePath); + Logger.logVerbose(LOG_TAG, "photoFilePath=\"" + photoFilePath + "\", photoDirPath=\"" + photoDirPath + "\""); + + // If workingDirectory is not a directory, or is not readable or writable, then just return + // Creation of missing directory and setting of read, write and execute permissions are only done if workingDirectory is + // under allowed termux working directory paths. + // We try to set execute permissions, but ignore if they are missing, since only read and write permissions are required + // for working directories. + Error error = TermuxFileUtils.validateDirectoryFileExistenceAndPermissions("photo directory", photoDirPath, + true, true, true, + false, true); + if (error != null) { + stdout.println("ERROR: " + error.getErrorLogString()); + return; } + + takePicture(stdout, context, new File(photoFilePath), cameraId); }); } @@ -65,26 +90,26 @@ public void onOpened(final CameraDevice camera) { try { proceedWithOpenedCamera(context, manager, camera, outputFile, looper, stdout); } catch (Exception e) { - TermuxApiLogger.error("Exception in onOpened()", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Exception in onOpened()", e); closeCamera(camera, looper); } } @Override public void onDisconnected(CameraDevice camera) { - TermuxApiLogger.info("onDisconnected() from camera"); + Logger.logInfo(LOG_TAG, "onDisconnected() from camera"); } @Override public void onError(CameraDevice camera, int error) { - TermuxApiLogger.error("Failed opening camera: " + error); + Logger.logError(LOG_TAG, "Failed opening camera: " + error); closeCamera(camera, looper); } }, null); Looper.loop(); } catch (Exception e) { - TermuxApiLogger.error("Error getting camera", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Error getting camera", e); } } @@ -125,7 +150,7 @@ public void run() { output.write(bytes); } catch (Exception e) { stdout.println("Error writing image: " + e.getMessage()); - TermuxApiLogger.error("Error writing image", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Error writing image", e); } } finally { mImageReader.close(); @@ -154,10 +179,10 @@ public void onConfigured(final CameraCaptureSession session) { // continous preview-capture for 1/2 second session.setRepeatingRequest(previewReq.build(), null, null); - TermuxApiLogger.info("preview started"); + Logger.logInfo(LOG_TAG, "preview started"); Thread.sleep(500); session.stopRepeating(); - TermuxApiLogger.info("preview stoppend"); + Logger.logInfo(LOG_TAG, "preview stoppend"); final CaptureRequest.Builder jpegRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); // Render to our image reader: @@ -169,7 +194,7 @@ public void onConfigured(final CameraCaptureSession session) { saveImage(camera, session, jpegRequest.build()); } catch (Exception e) { - TermuxApiLogger.error("onConfigured() error in preview", e); + Logger.logStackTraceWithMessage(LOG_TAG, "onConfigured() error in preview", e); mImageReader.close(); releaseSurfaces(outputSurfaces); closeCamera(camera, looper); @@ -178,7 +203,7 @@ public void onConfigured(final CameraCaptureSession session) { @Override public void onConfigureFailed(CameraCaptureSession session) { - TermuxApiLogger.error("onConfigureFailed() error in preview"); + Logger.logError(LOG_TAG, "onConfigureFailed() error in preview"); mImageReader.close(); releaseSurfaces(outputSurfaces); closeCamera(camera, looper); @@ -190,7 +215,7 @@ static void saveImage(final CameraDevice camera, CameraCaptureSession session, C session.capture(request, new CameraCaptureSession.CaptureCallback() { @Override public void onCaptureCompleted(CameraCaptureSession completedSession, CaptureRequest request, TotalCaptureResult result) { - TermuxApiLogger.info("onCaptureCompleted()"); + Logger.logInfo(LOG_TAG, "onCaptureCompleted()"); } }, null); } @@ -202,13 +227,13 @@ public void onCaptureCompleted(CameraCaptureSession completedSession, CaptureReq static int correctOrientation(final Context context, final CameraCharacteristics characteristics) { final Integer lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); final boolean isFrontFacing = lensFacing != null && lensFacing == CameraCharacteristics.LENS_FACING_FRONT; - TermuxApiLogger.info((isFrontFacing ? "Using" : "Not using") + " a front facing camera."); + Logger.logInfo(LOG_TAG, (isFrontFacing ? "Using" : "Not using") + " a front facing camera."); Integer sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); if (sensorOrientation != null) { - TermuxApiLogger.info(String.format("Sensor orientation: %s degrees", sensorOrientation)); + Logger.logInfo(LOG_TAG, String.format("Sensor orientation: %s degrees", sensorOrientation)); } else { - TermuxApiLogger.info("CameraCharacteristics didn't contain SENSOR_ORIENTATION. Assuming 0 degrees."); + Logger.logInfo(LOG_TAG, "CameraCharacteristics didn't contain SENSOR_ORIENTATION. Assuming 0 degrees."); sensorOrientation = 0; } @@ -229,11 +254,11 @@ static int correctOrientation(final Context context, final CameraCharacteristics deviceOrientation = 270; break; default: - TermuxApiLogger.info( + Logger.logInfo(LOG_TAG, String.format("Default display has unknown rotation %d. Assuming 0 degrees.", deviceRotation)); deviceOrientation = 0; } - TermuxApiLogger.info(String.format("Device orientation: %d degrees", deviceOrientation)); + Logger.logInfo(LOG_TAG, String.format("Device orientation: %d degrees", deviceOrientation)); int jpegOrientation; if (isFrontFacing) { @@ -243,7 +268,7 @@ static int correctOrientation(final Context context, final CameraCharacteristics } // Add an extra 360 because (-90 % 360) == -90 and Android won't accept a negative rotation. jpegOrientation = (jpegOrientation + 360) % 360; - TermuxApiLogger.info(String.format("Returning JPEG orientation of %d degrees", jpegOrientation)); + Logger.logInfo(LOG_TAG, String.format("Returning JPEG orientation of %d degrees", jpegOrientation)); return jpegOrientation; } @@ -251,14 +276,14 @@ static void releaseSurfaces(List outputSurfaces) { for (Surface outputSurface : outputSurfaces) { outputSurface.release(); } - TermuxApiLogger.info("surfaces released"); + Logger.logInfo(LOG_TAG, "surfaces released"); } static void closeCamera(CameraDevice camera, Looper looper) { try { camera.close(); } catch (RuntimeException e) { - TermuxApiLogger.info("Exception closing camera: " + e.getMessage()); + Logger.logInfo(LOG_TAG, "Exception closing camera: " + e.getMessage()); } if (looper != null) looper.quit(); } diff --git a/app/src/main/java/com/termux/api/ClipboardAPI.java b/app/src/main/java/com/termux/api/apis/ClipboardAPI.java similarity index 89% rename from app/src/main/java/com/termux/api/ClipboardAPI.java rename to app/src/main/java/com/termux/api/apis/ClipboardAPI.java index e88583e1a..83d4ab640 100644 --- a/app/src/main/java/com/termux/api/ClipboardAPI.java +++ b/app/src/main/java/com/termux/api/apis/ClipboardAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.ClipData; import android.content.ClipData.Item; @@ -7,13 +7,19 @@ import android.content.Intent; import android.text.TextUtils; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; import java.io.PrintWriter; public class ClipboardAPI { - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + private static final String LOG_TAG = "ClipboardAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + final ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); final ClipData clipData = clipboard.getPrimaryClip(); diff --git a/app/src/main/java/com/termux/api/ContactListAPI.java b/app/src/main/java/com/termux/api/apis/ContactListAPI.java similarity index 88% rename from app/src/main/java/com/termux/api/ContactListAPI.java rename to app/src/main/java/com/termux/api/apis/ContactListAPI.java index 12df9393c..142e2a488 100644 --- a/app/src/main/java/com/termux/api/ContactListAPI.java +++ b/app/src/main/java/com/termux/api/apis/ContactListAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.ContentResolver; import android.content.Context; @@ -10,12 +10,18 @@ import android.util.JsonWriter; import android.util.SparseArray; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.api.util.ResultReturner.ResultJsonWriter; +import com.termux.shared.logger.Logger; public class ContactListAPI { - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + private static final String LOG_TAG = "ContactListAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { diff --git a/app/src/main/java/com/termux/api/apis/DialogAPI.java b/app/src/main/java/com/termux/api/apis/DialogAPI.java new file mode 100644 index 000000000..c3c9d2c79 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/DialogAPI.java @@ -0,0 +1,1078 @@ +package com.termux.api.apis; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.speech.RecognitionListener; +import android.speech.RecognizerIntent; +import android.speech.SpeechRecognizer; +import android.text.InputType; +import android.util.JsonWriter; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.DatePicker; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.TimePicker; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.widget.NestedScrollView; + +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.termux.api.R; +import com.termux.api.util.ResultReturner; +import com.termux.api.activities.TermuxApiPermissionActivity; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.theme.TermuxThemeUtils; +import com.termux.shared.theme.NightMode; +import com.termux.shared.theme.ThemeUtils; +import com.termux.shared.view.KeyboardUtils; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * API that allows receiving user input interactively in a variety of different ways + */ +public class DialogAPI { + + private static final String LOG_TAG = "DialogAPI"; + + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + context.startActivity(new Intent(context, DialogActivity.class).putExtras(intent.getExtras()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } + + + + public static class DialogActivity extends AppCompatActivity { + + private static final String LOG_TAG = "DialogActivity"; + + private volatile boolean resultReturned = false; + private InputMethod mInputMethod; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(savedInstanceState); + final Intent intent = getIntent(); + final Context context = this; + + + String methodType = intent.hasExtra("input_method") ? intent.getStringExtra("input_method") : ""; + + // Set NightMode.APP_NIGHT_MODE + TermuxThemeUtils.setAppNightMode(context); + boolean shouldEnableDarkTheme = ThemeUtils.shouldEnableDarkTheme(this, NightMode.getAppNightMode().getName()); + if (shouldEnableDarkTheme) + this.setTheme(R.style.DialogTheme_Dark); + + mInputMethod = InputMethodFactory.get(methodType, this); + if (mInputMethod != null) { + mInputMethod.create(this, result -> { + postResult(context, result); + finish(); + }); + } else { + InputResult result = new InputResult(); + result.error = "Unknown Input Method: " + methodType; + postResult(context, result); + } + } + + @Override + protected void onNewIntent(Intent intent) { + Logger.logDebug(LOG_TAG, "onNewIntent"); + + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + protected void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + + super.onDestroy(); + + postResult(this, null); + + if (mInputMethod != null) { + Dialog dialog = mInputMethod.getDialog(); + dismissDialog(dialog); + } + } + + private static void dismissDialog(Dialog dialog) { + try { + if (dialog != null) + dialog.dismiss(); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed tp dismiss dialog", e); + } + } + + /** + * Extract value extras from intent into String array + */ + static String[] getInputValues(Intent intent) { + String[] items = new String[] { }; + + if (intent != null && intent.hasExtra("input_values")) { + String[] temp = intent.getStringExtra("input_values").split("(? -1) { + out.name("index").value(result.index); + } + if (result.values.size() > 0) { + out.name("values"); + out.beginArray(); + for (Value value : result.values) { + out.beginObject(); + out.name("index").value(value.index); + out.name("text").value(value.text); + out.endObject(); + } + out.endArray(); + } + if (!result.error.equals("")) { + out.name("error").value(result.error); + } + + out.endObject(); + out.flush(); + resultReturned = true; + } + }); + } + + + /** + * Factory for returning proper input method type that we received in our incoming intent + */ + static class InputMethodFactory { + + public static InputMethod get(final String type, final AppCompatActivity activity) { + + switch (type == null ? "" : type) { + case "confirm": + return new ConfirmInputMethod(activity); + case "checkbox": + return new CheckBoxInputMethod(activity); + case "counter": + return new CounterInputMethod(activity); + case "date": + return new DateInputMethod(activity); + case "radio": + return new RadioInputMethod(activity); + case "sheet": + return new BottomSheetInputMethod(); + case "speech": + return new SpeechInputMethod(activity); + case "spinner": + return new SpinnerInputMethod(activity); + case "text": + return new TextInputMethod(activity); + case "time": + return new TimeInputMethod(activity); + default: + return null; + } + } + } + + + /** + * Interface for creating an input method type + */ + interface InputMethod { + Dialog getDialog(); + + void create(AppCompatActivity activity, InputResultListener resultListener); + } + + + /** + * Callback interface for receiving an InputResult + */ + interface InputResultListener { + void onResult(InputResult result); + } + + + /** + * Simple POJO to store the result of input methods + */ + static class InputResult { + public String text = ""; + public String error = ""; + public int code = 0; + public static int index = -1; + public List values = new ArrayList<>(); + } + + + public static class Value { + public int index = -1; + public String text = ""; + } + + /* + * -------------------------------------- + * InputMethod Implementations + * -------------------------------------- + */ + + + /** + * CheckBox InputMethod + * Allow users to select multiple options from a range of values + */ + static class CheckBoxInputMethod extends InputDialog { + + CheckBoxInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + LinearLayout createWidgetView(AppCompatActivity activity) { + LinearLayout layout = new LinearLayout(activity); + layout.setOrientation(LinearLayout.VERTICAL); + + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + layoutParams.topMargin = 32; + layoutParams.bottomMargin = 32; + + String[] values = getInputValues(activity.getIntent()); + + for (int j = 0; j < values.length; ++j) { + String value = values[j]; + + CheckBox checkBox = new CheckBox(activity); + checkBox.setText(value); + checkBox.setId(j); + checkBox.setTextSize(18); + checkBox.setPadding(16, 16, 16, 16); + checkBox.setLayoutParams(layoutParams); + + layout.addView(checkBox); + } + return layout; + } + + @Override + String getResult() { + int checkBoxCount = widgetView.getChildCount(); + + List values = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + sb.append("["); + + for (int j = 0; j < checkBoxCount; ++j) { + CheckBox box = widgetView.findViewById(j); + if (box.isChecked()) { + Value value = new Value(); + value.index = j; + value.text = box.getText().toString(); + values.add(value); + sb.append(box.getText().toString()).append(", "); + } + } + inputResult.values = values; + // remove trailing comma and add closing bracket + return sb.toString().replaceAll(", $", "") + "]"; + } + } + + + /** + * Confirm InputMethod + * Allow users to confirm YES or NO. + */ + static class ConfirmInputMethod extends InputDialog { + + ConfirmInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + InputResult onDialogClick(int button) { + inputResult.text = button == Dialog.BUTTON_POSITIVE ? "yes" : "no"; + return inputResult; + } + + @Override + TextView createWidgetView(AppCompatActivity activity) { + TextView textView = new TextView(activity); + final Intent intent = activity.getIntent(); + + String text = intent.hasExtra("input_hint") ? intent.getStringExtra("input_hint") : "Confirm"; + textView.setText(text); + return textView; + } + + @Override + String getNegativeButtonText() { + return "No"; + } + + @Override + String getPositiveButtonText() { + return "Yes"; + } + } + + + /** + * Counter InputMethod + * Allow users to increment or decrement a number in a given range + */ + static class CounterInputMethod extends InputDialog { + static final int DEFAULT_MIN = 0; + static final int DEFAULT_MAX = 100; + static final int RANGE_LENGTH = 3; + + int min; + int max; + int counter; + + TextView counterLabel; + + CounterInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + View createWidgetView(AppCompatActivity activity) { + View layout = View.inflate(activity, R.layout.dialog_counter, null); + counterLabel = layout.findViewById(R.id.counterTextView); + + final Button incrementButton = layout.findViewById(R.id.incrementButton); + incrementButton.setOnClickListener(view -> increment()); + + final Button decrementButton = layout.findViewById(R.id.decrementButton); + decrementButton.setOnClickListener(view -> decrement()); + updateCounterRange(); + + return layout; + } + + void updateCounterRange() { + final Intent intent = activity.getIntent(); + + if (intent.hasExtra("input_range")) { + int[] values = intent.getIntArrayExtra("input_range"); + if (values.length != RANGE_LENGTH) { + inputResult.error = "Invalid range! Must be 3 int values!"; + postCanceledResult(); + dismissDialog(dialog); + } else { + min = Math.min(values[0], values[1]); + max = Math.max(values[0], values[1]); + counter = values[2]; + } + } else { + min = DEFAULT_MIN; + max = DEFAULT_MAX; + + // halfway + counter = (DEFAULT_MAX - DEFAULT_MIN) / 2; + } + updateLabel(); + } + + @Override + String getResult() { + return counterLabel.getText().toString(); + } + + void updateLabel() { + counterLabel.setText(String.valueOf(counter)); + } + + void increment() { + if ((counter + 1) <= max) { + ++counter; + updateLabel(); + } + } + + void decrement() { + if ((counter - 1) >= min) { + --counter; + updateLabel(); + } + } + } + + + /** + * Date InputMethod + * Allow users to pick a specific date + */ + static class DateInputMethod extends InputDialog { + + DateInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + String getResult() { + int month = widgetView.getMonth(); + int day = widgetView.getDayOfMonth(); + int year = widgetView.getYear(); + + Calendar calendar = Calendar.getInstance(); + calendar.set(year, month, day, 0, 0, 0); + + final Intent intent = activity.getIntent(); + if (intent.hasExtra("date_format")) { + String date_format = intent.getStringExtra("date_format"); + try { + SimpleDateFormat dateFormat = new SimpleDateFormat(date_format); + dateFormat.setTimeZone(calendar.getTimeZone()); + return dateFormat.format(calendar.getTime()); + } catch (Exception e) { + inputResult.error = e.toString(); + postCanceledResult(); + } + } + return calendar.getTime().toString(); + } + + @Override + DatePicker createWidgetView(AppCompatActivity activity) { + return new DatePicker(activity); + } + } + + + /** + * Text InputMethod + * Allow users to enter plaintext or a password + */ + static class TextInputMethod extends InputDialog { + + TextInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + String getResult() { + return widgetView.getText().toString(); + } + + @Override + EditText createWidgetView(AppCompatActivity activity) { + final Intent intent = activity.getIntent(); + EditText editText = new EditText(activity); + + if (intent.hasExtra("input_hint")) { + editText.setHint(intent.getStringExtra("input_hint")); + } + + boolean multiLine = intent.getBooleanExtra("multiple_lines", false); + boolean numeric = intent.getBooleanExtra("numeric", false); + boolean password = intent.getBooleanExtra("password", false); + + int flags = InputType.TYPE_CLASS_TEXT; + + if (password) { + flags = numeric ? (flags | InputType.TYPE_NUMBER_VARIATION_PASSWORD) : (flags | InputType.TYPE_TEXT_VARIATION_PASSWORD); + } + + if (multiLine) { + flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE; + editText.setLines(4); + } + + if (numeric) { + flags &= ~InputType.TYPE_CLASS_TEXT; // clear to allow only numbers + flags |= InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL; + } + + editText.setInputType(flags); + + return editText; + } + } + + + /** + * Time InputMethod + * Allow users to pick a specific time + */ + static class TimeInputMethod extends InputDialog { + + TimeInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + String getResult() { + return String.format(Locale.getDefault(), "%02d:%02d", widgetView.getHour(), widgetView.getMinute()); + } + + @Override + TimePicker createWidgetView(AppCompatActivity activity) { + return new TimePicker(activity); + } + } + + + /** + * Radio InputMethod + * Allow users to confirm from radio button options + */ + static class RadioInputMethod extends InputDialog { + RadioGroup radioGroup; + + RadioInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + RadioGroup createWidgetView(AppCompatActivity activity) { + radioGroup = new RadioGroup(activity); + radioGroup.setPadding(16, 16, 16, 16); + + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + layoutParams.topMargin = 32; + layoutParams.bottomMargin = 32; + + String[] values = getInputValues(activity.getIntent()); + + for (int j = 0; j < values.length; ++j) { + String value = values[j]; + + RadioButton button = new RadioButton(activity); + button.setText(value); + button.setId(j); + button.setTextSize(18); + button.setPadding(16, 16, 16, 16); + button.setLayoutParams(layoutParams); + + radioGroup.addView(button); + } + return radioGroup; + } + + @Override + String getResult() { + int radioIndex = radioGroup.indexOfChild(widgetView.findViewById(radioGroup.getCheckedRadioButtonId())); + RadioButton radioButton = (RadioButton) radioGroup.getChildAt(radioIndex); + InputResult.index = radioIndex; + return (radioButton != null) ? radioButton.getText().toString() : ""; + } + } + + + /** + * BottomSheet InputMethod + * Allow users to select from a variety of options in a bottom sheet dialog + */ + public static class BottomSheetInputMethod extends BottomSheetDialogFragment implements InputMethod { + private InputResultListener resultListener; + + + @Override + public void create(AppCompatActivity activity, InputResultListener resultListener) { + this.resultListener = resultListener; + show(activity.getSupportFragmentManager(), "BOTTOM_SHEET"); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + // create custom BottomSheetDialog that has friendlier dismissal behavior + return new BottomSheetDialog(requireActivity(), getTheme()) { + @Override + public void onBackPressed() { + super.onBackPressed(); + // make it so that user only has to hit back key one time to get rid of bottom sheet + requireActivity().onBackPressed(); + postCanceledResult(); + } + + @Override + public void cancel() { + super.cancel(); + + if (isCurrentAppTermux()) { + showKeyboard(); + } + // dismiss on single touch outside of dialog + requireActivity().onBackPressed(); + postCanceledResult(); + } + }; + } + + @SuppressLint("RestrictedApi") + @Override + public void setupDialog(final Dialog dialog, int style) { + LinearLayout layout = new LinearLayout(getContext()); + layout.setMinimumHeight(100); + layout.setPadding(16, 16, 16, 16); + layout.setOrientation(LinearLayout.VERTICAL); + + NestedScrollView scrollView = new NestedScrollView(requireContext()); + final String[] values = getInputValues(requireActivity().getIntent()); + + for (int i = 0; i < values.length; ++i) { + final int j = i; + final TextView textView = new TextView(getContext()); + textView.setText(values[j]); + textView.setTextSize(20); + textView.setPadding(56, 56, 56, 56); + textView.setOnClickListener(view -> { + InputResult result = new InputResult(); + result.text = values[j]; + result.index = j; + dismissDialog(dialog); + resultListener.onResult(result); + }); + + layout.addView(textView); + } + scrollView.addView(layout); + dialog.setContentView(scrollView); + hideKeyboard(); + } + + /** + * These keyboard methods exist to work around inconsistent show / hide behavior + * from canceling BottomSheetDialog and produces the desired result of hiding keyboard + * on creation of dialog and showing it after a selection or cancellation, as long as + * we are still within the Termux application + */ + + protected void hideKeyboard() { + KeyboardUtils.setSoftKeyboardAlwaysHiddenFlags(getActivity()); + } + + protected void showKeyboard() { + KeyboardUtils.showSoftKeyboard(getActivity(), getView()); + } + + /** + * Checks to see if foreground application is Termux + */ + protected boolean isCurrentAppTermux() { + final ActivityManager activityManager = (ActivityManager) requireContext().getSystemService(Context.ACTIVITY_SERVICE); + final List runningProcesses = Objects.requireNonNull(activityManager).getRunningAppProcesses(); + for (final ActivityManager.RunningAppProcessInfo processInfo : runningProcesses) { + if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { + for (final String activeProcess : processInfo.pkgList) { + if (activeProcess.equals(TermuxConstants.TERMUX_PACKAGE_NAME)) { + return true; + } + } + } + } + return false; + } + + protected void postCanceledResult() { + InputResult result = new InputResult(); + result.code = Dialog.BUTTON_NEGATIVE; + resultListener.onResult(result); + } + } + + + /** + * Spinner InputMethod + * Allow users to make a selection based on a list of specified values + */ + static class SpinnerInputMethod extends InputDialog { + + SpinnerInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + String getResult() { + InputResult.index = widgetView.getSelectedItemPosition(); + return widgetView.getSelectedItem().toString(); + } + + @Override + Spinner createWidgetView(AppCompatActivity activity) { + Spinner spinner = new Spinner(activity); + + final Intent intent = activity.getIntent(); + final String[] items = getInputValues(intent); + final ArrayAdapter adapter = new ArrayAdapter<>(activity, R.layout.spinner_item, items); + + spinner.setAdapter(adapter); + return spinner; + } + } + + + /** + * Speech InputMethod + * Allow users to use the built in microphone to get text from speech + */ + static class SpeechInputMethod extends InputDialog { + + SpeechInputMethod(AppCompatActivity activity) { + super(activity); + } + + @Override + TextView createWidgetView(AppCompatActivity activity) { + TextView textView = new TextView(activity); + final Intent intent = activity.getIntent(); + + String text = intent.hasExtra("input_hint") ? intent.getStringExtra("input_hint") : "Listening for speech..."; + + textView.setText(text); + textView.setTextSize(20); + return textView; + } + + @Override + public void create(final AppCompatActivity activity, final InputResultListener resultListener) { + // Since we're using the microphone, we need to make sure we have proper permission + if (!TermuxApiPermissionActivity.checkAndRequestPermissions(activity, activity.getIntent(), Manifest.permission.RECORD_AUDIO)) { + activity.finish(); + } + + if (!hasSpeechRecognizer(activity)) { + Toast.makeText(activity, "No voice recognition found!", Toast.LENGTH_SHORT).show(); + activity.finish(); + } + + + Intent speechIntent = createSpeechIntent(); + final SpeechRecognizer recognizer = createSpeechRecognizer(activity, resultListener); + + // create intermediate InputResultListener so that we can stop our speech listening + // if user hits the cancel button + DialogInterface.OnClickListener clickListener = getClickListener(result -> { + recognizer.stopListening(); + resultListener.onResult(result); + }); + + Dialog dialog = getDialogBuilder(activity, clickListener) + .setPositiveButton(null, null) + .setOnDismissListener(null) + .create(); + + dialog.setCanceledOnTouchOutside(false); + dialog.show(); + + recognizer.startListening(speechIntent); + } + + private boolean hasSpeechRecognizer(Context context) { + List installList = context.getPackageManager().queryIntentActivities(new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), 0); + return !installList.isEmpty(); + } + + private Intent createSpeechIntent() { + Intent speechIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); + speechIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1); + speechIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM); + return speechIntent; + } + + private SpeechRecognizer createSpeechRecognizer(AppCompatActivity activity, final InputResultListener listener) { + SpeechRecognizer recognizer = SpeechRecognizer.createSpeechRecognizer(activity); + recognizer.setRecognitionListener(new RecognitionListener() { + + @Override + public void onResults(Bundle results) { + List voiceResults = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); + + if (voiceResults != null && voiceResults.size() > 0) { + inputResult.text = voiceResults.get(0); + } + listener.onResult(inputResult); + } + + /** + * Get string description for error code + */ + @Override + public void onError(int error) { + String errorDescription; + + switch (error) { + case SpeechRecognizer.ERROR_AUDIO: + errorDescription = "ERROR_AUDIO"; + break; + case SpeechRecognizer.ERROR_CLIENT: + errorDescription = "ERROR_CLIENT"; + break; + case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS: + errorDescription = "ERROR_INSUFFICIENT_PERMISSIONS"; + break; + case SpeechRecognizer.ERROR_NETWORK: + errorDescription = "ERROR_NETWORK"; + break; + case SpeechRecognizer.ERROR_NETWORK_TIMEOUT: + errorDescription = "ERROR_NETWORK_TIMEOUT"; + break; + case SpeechRecognizer.ERROR_SPEECH_TIMEOUT: + errorDescription = "ERROR_SPEECH_TIMEOUT"; + break; + default: + errorDescription = "ERROR_UNKNOWN"; + break; + } + inputResult.error = errorDescription; + listener.onResult(inputResult); + } + + + // unused + @Override + public void onEndOfSpeech() { } + + @Override + public void onReadyForSpeech(Bundle bundle) { } + + @Override + public void onBeginningOfSpeech() { } + + @Override + public void onRmsChanged(float v) { } + + @Override + public void onBufferReceived(byte[] bytes) { } + + @Override + public void onPartialResults(Bundle bundle) { } + + @Override + public void onEvent(int i, Bundle bundle) { } + }); + return recognizer; + } + } + + + /** + * Base Dialog class to extend from for adding specific views / widgets to a Dialog interface + * @param Main view type that will be displayed within dialog + */ + abstract static class InputDialog implements InputMethod { + // result that belongs to us + InputResult inputResult = new InputResult(); + + // listener for our input result + InputResultListener resultListener; + + // view that will be placed in our dialog + T widgetView; + + // dialog that holds everything + Dialog dialog; + + // our activity context + AppCompatActivity activity; + + + // method to be implemented that handles creating view that is placed in our dialog + abstract T createWidgetView(AppCompatActivity activity); + + // method that should be implemented that handles returning a result obtained through user input + String getResult() { + return null; + } + + + InputDialog(AppCompatActivity activity) { + this.activity = activity; + widgetView = createWidgetView(activity); + initActivityDisplay(activity); + } + + @Override + public Dialog getDialog() { + return dialog; + } + + @Override + public void create(AppCompatActivity activity, final InputResultListener resultListener) { + this.resultListener = resultListener; + + // Handle OK and Cancel button clicks + DialogInterface.OnClickListener clickListener = getClickListener(resultListener); + + // Dialog interface that will display to user + dialog = getDialogBuilder(activity, clickListener).create(); + dialog.show(); + } + + void postCanceledResult() { + inputResult.code = Dialog.BUTTON_NEGATIVE; + resultListener.onResult(inputResult); + } + + void initActivityDisplay(Activity activity) { + activity.setFinishOnTouchOutside(false); + activity.requestWindowFeature(Window.FEATURE_NO_TITLE); + } + + /** + * Places our generic widget view type inside a FrameLayout + */ + View getLayoutView(AppCompatActivity activity, T view) { + FrameLayout layout = getFrameLayout(activity); + ViewGroup.LayoutParams params = layout.getLayoutParams(); + + view.setLayoutParams(params); + layout.addView(view); + layout.setScrollbarFadingEnabled(false); + + // wrap everything in scrollview + ScrollView scrollView = new ScrollView(activity); + scrollView.addView(layout); + + return scrollView; + } + + DialogInterface.OnClickListener getClickListener(final InputResultListener listener) { + return (dialogInterface, button) -> { + InputResult result = onDialogClick(button); + listener.onResult(result); + }; + } + + DialogInterface.OnDismissListener getDismissListener() { + return dialogInterface -> { + // force dismiss behavior on single tap outside of dialog + activity.onBackPressed(); + onDismissed(); + }; + } + + /** + * Creates a dialog builder to initialize a dialog w/ a view and button click listeners + */ + AlertDialog.Builder getDialogBuilder(AppCompatActivity activity, DialogInterface.OnClickListener clickListener) { + final Intent intent = activity.getIntent(); + final View layoutView = getLayoutView(activity, widgetView); + + return new AlertDialog.Builder(activity) + .setTitle(intent.hasExtra("input_title") ? intent.getStringExtra("input_title") : "") + .setNegativeButton(getNegativeButtonText(), clickListener) + .setPositiveButton(getPositiveButtonText(), clickListener) + .setOnDismissListener(getDismissListener()) + .setView(layoutView); + + } + + String getNegativeButtonText() { + return "Cancel"; + } + + String getPositiveButtonText() { + return "OK"; + } + + void onDismissed() { + postCanceledResult(); + } + + /** + * Create a basic frame layout that will add a margin around our main widget view + */ + FrameLayout getFrameLayout(AppCompatActivity activity) { + FrameLayout layout = new FrameLayout(activity); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + final int margin = 56; + params.setMargins(margin, margin, margin, margin); + + params.setMargins(56, 56, 56, 56); + layout.setLayoutParams(params); + return layout; + } + + /** + * Returns an InputResult containing code of our button and the text if we hit OK + */ + InputResult onDialogClick(int button) { + // receive indication of whether the OK or CANCEL button is clicked + inputResult.code = button; + + // OK clicked + if (button == Dialog.BUTTON_POSITIVE) { + inputResult.text = getResult(); + } + return inputResult; + } + } + } + +} diff --git a/app/src/main/java/com/termux/api/DownloadAPI.java b/app/src/main/java/com/termux/api/apis/DownloadAPI.java similarity index 80% rename from app/src/main/java/com/termux/api/DownloadAPI.java rename to app/src/main/java/com/termux/api/apis/DownloadAPI.java index edda3448a..201e8094f 100644 --- a/app/src/main/java/com/termux/api/DownloadAPI.java +++ b/app/src/main/java/com/termux/api/apis/DownloadAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.DownloadManager; import android.app.DownloadManager.Request; @@ -6,13 +6,19 @@ import android.content.Intent; import android.net.Uri; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; import java.io.File; public class DownloadAPI { - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + private static final String LOG_TAG = "DownloadAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + ResultReturner.returnData(apiReceiver, intent, out -> { final Uri downloadUri = intent.getData(); if (downloadUri == null) { diff --git a/app/src/main/java/com/termux/api/FingerprintAPI.java b/app/src/main/java/com/termux/api/apis/FingerprintAPI.java similarity index 95% rename from app/src/main/java/com/termux/api/FingerprintAPI.java rename to app/src/main/java/com/termux/api/apis/FingerprintAPI.java index dc712c741..233f2044f 100644 --- a/app/src/main/java/com/termux/api/FingerprintAPI.java +++ b/app/src/main/java/com/termux/api/apis/FingerprintAPI.java @@ -1,9 +1,7 @@ -package com.termux.api; +package com.termux.api.apis; -import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -16,7 +14,7 @@ import androidx.fragment.app.FragmentActivity; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.util.ArrayList; import java.util.List; @@ -29,6 +27,7 @@ * This API allows users to use device fingerprint sensor as an authentication mechanism */ public class FingerprintAPI { + protected static final String TAG = "FingerprintAPI"; protected static final String KEY_NAME = "TermuxFingerprintAPIKey"; protected static final String KEYSTORE_NAME = "AndroidKeyStore"; @@ -63,10 +62,14 @@ public class FingerprintAPI { protected static boolean postedResult = false; + private static final String LOG_TAG = "FingerprintAPI"; + /** * Handles setup of fingerprint sensor and writes Fingerprint result to console */ - static void onReceive(final Context context, final Intent intent) { + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + resetFingerprintResult(); FingerprintManagerCompat fingerprintManagerCompat = FingerprintManagerCompat.from(context); @@ -136,8 +139,12 @@ protected static boolean validateFingerprintSensor(Context context, FingerprintM */ public static class FingerprintActivity extends FragmentActivity{ + private static final String LOG_TAG = "FingerprintActivity"; + @Override public void onCreate(Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + super.onCreate(savedInstanceState); handleFingerprint(); } @@ -167,7 +174,7 @@ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString } setAuthResult(AUTH_RESULT_FAILURE); postFingerprintResult(context, intent, fingerprintResult); - TermuxApiLogger.error(errString.toString()); + Logger.logError(LOG_TAG, errString.toString()); } @Override diff --git a/app/src/main/java/com/termux/api/InfraredAPI.java b/app/src/main/java/com/termux/api/apis/InfraredAPI.java similarity index 83% rename from app/src/main/java/com/termux/api/InfraredAPI.java rename to app/src/main/java/com/termux/api/apis/InfraredAPI.java index 42d8ffcaa..249363088 100644 --- a/app/src/main/java/com/termux/api/InfraredAPI.java +++ b/app/src/main/java/com/termux/api/apis/InfraredAPI.java @@ -1,18 +1,24 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.Context; import android.content.Intent; import android.hardware.ConsumerIrManager; import android.util.JsonWriter; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; /** * Exposing {@link ConsumerIrManager}. */ public class InfraredAPI { - static void onReceiveCarrierFrequency(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + private static final String LOG_TAG = "InfraredAPI"; + + public static void onReceiveCarrierFrequency(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveCarrierFrequency"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { @@ -40,7 +46,9 @@ public void writeJson(JsonWriter out) throws Exception { } - static void onReceiveTransmit(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceiveTransmit(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveTransmit"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { diff --git a/app/src/main/java/com/termux/api/JobSchedulerAPI.java b/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java similarity index 69% rename from app/src/main/java/com/termux/api/JobSchedulerAPI.java rename to app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java index 8d11c3709..eb7977938 100644 --- a/app/src/main/java/com/termux/api/JobSchedulerAPI.java +++ b/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java @@ -1,17 +1,24 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.job.JobInfo; +import android.app.job.JobParameters; import android.app.job.JobScheduler; +import android.app.job.JobService; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.PersistableBundle; import androidx.annotation.RequiresApi; import android.text.TextUtils; -import android.util.Log; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import java.io.File; import java.util.ArrayList; @@ -22,9 +29,8 @@ public class JobSchedulerAPI { private static final String LOG_TAG = "JobSchedulerAPI"; - private static String formatJobInfo(JobInfo jobInfo) { - final String path = jobInfo.getExtras().getString(SchedulerJobService.SCRIPT_FILE_PATH); + final String path = jobInfo.getExtras().getString(JobSchedulerService.SCRIPT_FILE_PATH); List description = new ArrayList(); if (jobInfo.isPeriodic()) { description.add(String.format(Locale.ENGLISH, "(periodic: %dms)", jobInfo.getIntervalMillis())); @@ -54,7 +60,8 @@ private static String formatJobInfo(JobInfo jobInfo) { TextUtils.join(" ", description)); } - static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + public static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); final String scriptPath = intent.getStringExtra("script"); @@ -66,7 +73,7 @@ static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent int final boolean cancelAll = intent.getBooleanExtra("cancel_all", false); final int periodicMillis = intent.getIntExtra("period_ms", 0); - final String networkType = intent.getStringExtra("network"); + String networkType = intent.getStringExtra("network"); final boolean batteryNotLow = intent.getBooleanExtra("battery_not_low", true); final boolean charging = intent.getBooleanExtra("charging", false); final boolean persisted = intent.getBooleanExtra("persisted", false); @@ -83,7 +90,10 @@ static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent int networkTypeCode = JobInfo.NETWORK_TYPE_UNMETERED; break; case "cellular": - networkTypeCode = JobInfo.NETWORK_TYPE_CELLULAR; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + networkTypeCode = JobInfo.NETWORK_TYPE_CELLULAR; + else + networkTypeCode = JobInfo.NETWORK_TYPE_UNMETERED; break; case "not_roaming": networkTypeCode = JobInfo.NETWORK_TYPE_NOT_ROAMING; @@ -110,11 +120,7 @@ static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent int jobScheduler.cancelAll(); return; } else if (cancel) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - cancelJob(apiReceiver, intent, jobScheduler, jobId); - } else { - ResultReturner.returnData(apiReceiver, intent, out -> out.println("Need at least Android N to cancel individual jobs")); - } + cancelJob(apiReceiver, intent, jobScheduler, jobId); return; } @@ -141,9 +147,9 @@ static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent int } PersistableBundle extras = new PersistableBundle(); - extras.putString(SchedulerJobService.SCRIPT_FILE_PATH, file.getAbsolutePath()); + extras.putString(JobSchedulerService.SCRIPT_FILE_PATH, file.getAbsolutePath()); - ComponentName serviceComponent = new ComponentName(context, SchedulerJobService.class); + ComponentName serviceComponent = new ComponentName(context, JobSchedulerService.class); JobInfo.Builder builder = new JobInfo.Builder(jobId, serviceComponent) .setExtras(extras) .setRequiredNetworkType(networkTypeCode) @@ -165,7 +171,7 @@ static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent int final int scheduleResponse = jobScheduler.schedule(job); final String message = String.format(Locale.ENGLISH, "Scheduling %s - response %d", formatJobInfo(job), scheduleResponse); - Log.i(LOG_TAG, message); + Logger.logInfo(LOG_TAG, message); ResultReturner.returnData(apiReceiver, intent, out -> out.println(message)); @@ -200,4 +206,49 @@ private static void cancelJob(TermuxApiReceiver apiReceiver, Intent intent, JobS } + + + public static class JobSchedulerService extends JobService { + + public static final String SCRIPT_FILE_PATH = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".jobscheduler_script_path"; + + private static final String LOG_TAG = "JobSchedulerService"; + + @Override + public boolean onStartJob(JobParameters params) { + Logger.logInfo(LOG_TAG, "onStartJob: " + params.toString()); + + PersistableBundle extras = params.getExtras(); + String filePath = extras.getString(SCRIPT_FILE_PATH); + + ExecutionCommand executionCommand = new ExecutionCommand(); + executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(filePath).build(); + executionCommand.runner = ExecutionCommand.Runner.APP_SHELL.getName(); + + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + Intent executionIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executionCommand.executableUri); + executionIntent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); + executionIntent.putExtra(TERMUX_SERVICE.EXTRA_RUNNER, executionCommand.runner); + executionIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, true); // Also pass in case user using termux-app version < 0.119.0 + + Context context = getApplicationContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // https://developer.android.com/about/versions/oreo/background.html + context.startForegroundService(executionIntent); + } else { + context.startService(executionIntent); + } + + Logger.logInfo(LOG_TAG, "Job started for \"" + filePath + "\""); + + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + Logger.logInfo(LOG_TAG, "onStopJob: " + params.toString()); + return false; + } + } + } diff --git a/app/src/main/java/com/termux/api/KeystoreAPI.java b/app/src/main/java/com/termux/api/apis/KeystoreAPI.java similarity index 97% rename from app/src/main/java/com/termux/api/KeystoreAPI.java rename to app/src/main/java/com/termux/api/apis/KeystoreAPI.java index b9c8ee8b1..ffe008dd4 100644 --- a/app/src/main/java/com/termux/api/KeystoreAPI.java +++ b/app/src/main/java/com/termux/api/apis/KeystoreAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.annotation.SuppressLint; import android.content.Intent; @@ -10,9 +10,11 @@ import android.util.Base64; import android.util.JsonWriter; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.api.util.ResultReturner.ResultJsonWriter; import com.termux.api.util.ResultReturner.WithInput; +import com.termux.shared.logger.Logger; import java.io.ByteArrayOutputStream; import java.io.File; @@ -35,12 +37,17 @@ import java.security.spec.RSAKeyGenParameterSpec; import java.util.Enumeration; -class KeystoreAPI { +public class KeystoreAPI { + + private static final String LOG_TAG = "KeystoreAPI"; + // this is the only provider name that is supported by Android private static final String PROVIDER = "AndroidKeyStore"; @SuppressLint("NewApi") - static void onReceive(TermuxApiReceiver apiReceiver, Intent intent) { + public static void onReceive(TermuxApiReceiver apiReceiver, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + switch (intent.getStringExtra("command")) { case "list": listKeys(apiReceiver, intent); diff --git a/app/src/main/java/com/termux/api/LocationAPI.java b/app/src/main/java/com/termux/api/apis/LocationAPI.java similarity index 90% rename from app/src/main/java/com/termux/api/LocationAPI.java rename to app/src/main/java/com/termux/api/apis/LocationAPI.java index 0b5ffd740..4924d4c90 100644 --- a/app/src/main/java/com/termux/api/LocationAPI.java +++ b/app/src/main/java/com/termux/api/apis/LocationAPI.java @@ -1,5 +1,6 @@ -package com.termux.api; +package com.termux.api.apis; +import android.Manifest; import android.content.Context; import android.content.Intent; import android.location.Location; @@ -10,22 +11,29 @@ import android.os.Looper; import android.os.SystemClock; import android.util.JsonWriter; -import android.util.Log; +import androidx.annotation.RequiresPermission; + +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.api.util.ResultReturner.ResultJsonWriter; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.io.IOException; public class LocationAPI { + private static final String LOG_TAG = "LocationAPI"; + private static final String REQUEST_LAST_KNOWN = "last"; private static final String REQUEST_ONCE = "once"; private static final String REQUEST_UPDATES = "updates"; - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) @Override public void writeJson(final JsonWriter out) throws Exception { LocationManager manager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); @@ -74,7 +82,7 @@ public void onLocationChanged(Location location) { try { locationToJson(location, out); } catch (IOException e) { - TermuxApiLogger.error("Writing json", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Writing json", e); } finally { Looper.myLooper().quit(); } @@ -107,7 +115,7 @@ public void onLocationChanged(Location location) { locationToJson(location, out); out.flush(); } catch (IOException e) { - TermuxApiLogger.error("Writing json", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Writing json", e); } } }, null); @@ -118,7 +126,7 @@ public void run() { try { Thread.sleep(30 * 1000); } catch (InterruptedException e) { - Log.e("termux", "INTER", e); + Logger.logStackTraceWithMessage(LOG_TAG, "INTER", e); } looper.quit(); } diff --git a/app/src/main/java/com/termux/api/MediaPlayerAPI.java b/app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java similarity index 93% rename from app/src/main/java/com/termux/api/MediaPlayerAPI.java rename to app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java index 8514d4a1a..081bc8125 100644 --- a/app/src/main/java/com/termux/api/MediaPlayerAPI.java +++ b/app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Service; import android.content.Context; @@ -8,7 +8,7 @@ import android.os.PowerManager; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.io.File; import java.io.IOException; @@ -19,13 +19,17 @@ */ public class MediaPlayerAPI { + private static final String LOG_TAG = "MediaPlayerAPI"; + /** - * Starts our PlayerService + * Starts our MediaPlayerService */ - static void onReceive(final Context context, final Intent intent) { + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + // Create intent for starting our player service and make sure // we retain all relevant info from this intent - Intent playerService = new Intent(context, PlayerService.class); + Intent playerService = new Intent(context, MediaPlayerService.class); playerService.setAction(intent.getAction()); playerService.putExtras(intent.getExtras()); @@ -55,7 +59,7 @@ public static String getTimeString(int totalSeconds) { /** * All media functionality exists in this background service */ - public static class PlayerService extends Service implements MediaPlayer.OnErrorListener, + public static class MediaPlayerService extends Service implements MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener { protected static MediaPlayer mediaPlayer; @@ -65,6 +69,7 @@ public static class PlayerService extends Service implements MediaPlayer.OnError protected static String trackName; + private static final String LOG_TAG = "MediaPlayerService"; /** * Returns our MediaPlayer instance and ensures it has all the necessary callbacks @@ -84,6 +89,8 @@ protected MediaPlayer getMediaPlayer() { * What we received from TermuxApiReceiver but now within this service */ public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + String command = intent.getAction(); MediaPlayer player = getMediaPlayer(); Context context = getApplicationContext(); @@ -97,9 +104,10 @@ public int onStartCommand(Intent intent, int flags, int startId) { } public void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + super.onDestroy(); cleanUpMediaPlayer(); - TermuxApiLogger.info("MediaPlayerAPI PlayerService onDestroy()"); } /** @@ -120,7 +128,7 @@ public IBinder onBind(Intent intent) { @Override public boolean onError(MediaPlayer mediaPlayer, int what, int extra) { - TermuxApiLogger.error("MediaPlayerAPI error: " + what); + Logger.logVerbose(LOG_TAG, "onError: what: " + what + ", extra: " + extra); return false; } diff --git a/app/src/main/java/com/termux/api/MediaScannerAPI.java b/app/src/main/java/com/termux/api/apis/MediaScannerAPI.java similarity index 82% rename from app/src/main/java/com/termux/api/MediaScannerAPI.java rename to app/src/main/java/com/termux/api/apis/MediaScannerAPI.java index 218b44ed7..d911980dc 100644 --- a/app/src/main/java/com/termux/api/MediaScannerAPI.java +++ b/app/src/main/java/com/termux/api/apis/MediaScannerAPI.java @@ -1,11 +1,12 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.Context; import android.content.Intent; import android.media.MediaScannerConnection; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.io.File; import java.io.PrintWriter; @@ -14,7 +15,11 @@ public class MediaScannerAPI { - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + private static final String LOG_TAG = "MediaScannerAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + final String[] filePaths = intent.getStringArrayExtra("paths"); final boolean recursive = intent.getBooleanExtra("recursive", false); final Integer[] totalScanned = {0}; @@ -35,7 +40,7 @@ private static void scanFiles(PrintWriter out, Context context, String[] filePat context.getApplicationContext(), filePaths, null, - (path, uri) -> TermuxApiLogger.info("'" + path + "'" + (uri != null ? " -> '" + uri + "'" : ""))); + (path, uri) -> Logger.logInfo(LOG_TAG, "'" + path + "'" + (uri != null ? " -> '" + uri + "'" : ""))); if (verbose) for (String path : filePaths) { out.println(path); @@ -54,7 +59,7 @@ private static void scanFilesRecursively(PrintWriter out, Context context, Strin try { fileList = currentPath.listFiles(); } catch (SecurityException e) { - TermuxApiLogger.error(String.format("Failed to open '%s'", currentPath.toString()), e); + Logger.logStackTraceWithMessage(LOG_TAG, String.format("Failed to open '%s'", currentPath.toString()), e); } if (fileList != null && fileList.length > 0) { diff --git a/app/src/main/java/com/termux/api/MicRecorderAPI.java b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java similarity index 89% rename from app/src/main/java/com/termux/api/MicRecorderAPI.java rename to app/src/main/java/com/termux/api/apis/MicRecorderAPI.java index 5ba8a7825..3b954ffd5 100644 --- a/app/src/main/java/com/termux/api/MicRecorderAPI.java +++ b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java @@ -1,9 +1,10 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Service; import android.content.Context; import android.content.Intent; import android.media.MediaRecorder; +import android.os.Build; import android.os.Environment; import android.os.IBinder; import android.util.ArrayMap; @@ -11,7 +12,7 @@ import android.util.SparseIntArray; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import org.json.JSONException; import org.json.JSONObject; @@ -30,10 +31,14 @@ */ public class MicRecorderAPI { + private static final String LOG_TAG = "MicRecorderAPI"; + /** * Starts our MicRecorder service */ - static void onReceive(final Context context, final Intent intent) { + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + Intent recorderService = new Intent(context, MicRecorderService.class); recorderService.setAction(intent.getAction()); recorderService.putExtras(intent.getExtras()); @@ -58,11 +63,15 @@ public static class MicRecorderService extends Service implements MediaRecorder. protected static File file; + private static final String LOG_TAG = "MicRecorderService"; + public void onCreate() { getMediaRecorder(this); } public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + // get command handler and display result String command = intent.getAction(); Context context = getApplicationContext(); @@ -115,8 +124,9 @@ protected static void getMediaRecorder(MicRecorderService service) { } public void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + cleanupMediaRecorder(); - TermuxApiLogger.info("MicRecorderAPI MicRecorderService onDestroy()"); } /** @@ -138,19 +148,21 @@ public IBinder onBind(Intent intent) { @Override public void onError(MediaRecorder mr, int what, int extra) { + Logger.logVerbose(LOG_TAG, "onError: what: " + what + ", extra: " + extra); + isRecording = false; this.stopSelf(); - TermuxApiLogger.error("MicRecorderService onError() " + what); } @Override public void onInfo(MediaRecorder mr, int what, int extra) { + Logger.logVerbose(LOG_TAG, "onInfo: what: " + what + ", extra: " + extra); + switch (what) { case MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED: // intentional fallthrough case MEDIA_RECORDER_INFO_MAX_DURATION_REACHED: this.stopSelf(); } - TermuxApiLogger.info("MicRecorderService onInfo() " + what); } protected static String getDefaultRecordingFilename() { @@ -168,7 +180,7 @@ protected static String getRecordingInfoJSONString() { info.put("outputFile", file.getAbsolutePath()); result = info.toString(2); } catch (JSONException e) { - TermuxApiLogger.error("infoHandler json error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "infoHandler json error", e); } return result; } @@ -202,10 +214,12 @@ public RecorderCommandResult handle(Context context, Intent intent) { duration = MIN_RECORDING_LIMIT; String sencoder = intent.hasExtra("encoder") ? intent.getStringExtra("encoder") : ""; - ArrayMap encoder_map = new ArrayMap<>(3); + ArrayMap encoder_map = new ArrayMap<>(4); encoder_map.put("aac", MediaRecorder.AudioEncoder.AAC); encoder_map.put("amr_nb", MediaRecorder.AudioEncoder.AMR_NB); encoder_map.put("amr_wb", MediaRecorder.AudioEncoder.AMR_WB); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + encoder_map.put("opus", MediaRecorder.AudioEncoder.OPUS); Integer encoder = encoder_map.get(sencoder.toLowerCase()); if (encoder == null) @@ -213,19 +227,23 @@ public RecorderCommandResult handle(Context context, Intent intent) { int format = intent.getIntExtra("format", MediaRecorder.OutputFormat.DEFAULT); if (format == MediaRecorder.OutputFormat.DEFAULT) { - SparseIntArray format_map = new SparseIntArray(3); + SparseIntArray format_map = new SparseIntArray(4); format_map.put(MediaRecorder.AudioEncoder.AAC, MediaRecorder.OutputFormat.MPEG_4); format_map.put(MediaRecorder.AudioEncoder.AMR_NB, MediaRecorder.OutputFormat.THREE_GPP); format_map.put(MediaRecorder.AudioEncoder.AMR_WB, MediaRecorder.OutputFormat.THREE_GPP); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + format_map.put(MediaRecorder.AudioEncoder.OPUS, MediaRecorder.OutputFormat.OGG); format = format_map.get(encoder, MediaRecorder.OutputFormat.DEFAULT); } - SparseArray extension_map = new SparseArray<>(2); + SparseArray extension_map = new SparseArray<>(3); extension_map.put(MediaRecorder.OutputFormat.MPEG_4, ".m4a"); extension_map.put(MediaRecorder.OutputFormat.THREE_GPP, ".3gp"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + extension_map.put(MediaRecorder.OutputFormat.OGG, ".ogg"); String extension = extension_map.get(format); String filename = intent.hasExtra("file") ? intent.getStringExtra("file") : getDefaultRecordingFilename() + (extension != null ? extension : ""); @@ -238,7 +256,7 @@ public RecorderCommandResult handle(Context context, Intent intent) { file = new File(filename); - TermuxApiLogger.info("MediaRecording file is: " + file.getAbsolutePath()); + Logger.logInfo(LOG_TAG, "MediaRecording file is: " + file.getAbsolutePath()); if (file.exists()) { result.error = String.format("File: %s already exists! Please specify a different filename", file.getName()); @@ -269,7 +287,7 @@ public RecorderCommandResult handle(Context context, Intent intent) { 1000)); } catch (IllegalStateException | IOException e) { - TermuxApiLogger.error("MediaRecorder error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "MediaRecorder error", e); result.error = "Recording error: " + e.getMessage(); } } diff --git a/app/src/main/java/com/termux/api/apis/NfcAPI.java b/app/src/main/java/com/termux/api/apis/NfcAPI.java new file mode 100644 index 000000000..a402c1384 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/NfcAPI.java @@ -0,0 +1,325 @@ +package com.termux.api.apis; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.nfc.NdefMessage; +import android.nfc.NdefRecord; +import android.nfc.NfcAdapter; +import android.nfc.Tag; +import android.nfc.tech.Ndef; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.JsonWriter; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +public class NfcAPI { + + private static final String LOG_TAG = "NfcAPI"; + + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + context.startActivity(new Intent(context, NfcActivity.class).putExtras(intent.getExtras()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } + + + + public static class NfcActivity extends AppCompatActivity { + + private NfcAdapter adapter; + static String socket_input; + static String socket_output; + String mode; + String param; + String value; + + private static final String LOG_TAG = "NfcActivity"; + + //Check for NFC + protected void errorNfc(final Context context, Intent intent, String error) { + ResultReturner.returnData(context, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context); + out.beginObject(); + if (error.length() > 0) + out.name("error").value(error); + out.name("nfcPresent").value(null != adapter); + if(null!=adapter) + out.name("nfcActive").value(adapter.isEnabled()); + out.endObject(); + } + }); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(savedInstanceState); + Intent intent = this.getIntent(); + if (intent != null) { + mode = intent.getStringExtra("mode"); + if (null == mode) + mode = "noData"; + param =intent.getStringExtra("param"); + if (null == param) + param = "noData"; + value=intent.getStringExtra("value"); + if (null == socket_input) socket_input = intent.getStringExtra("socket_input"); + if (null == socket_output) socket_output = intent.getStringExtra("socket_output"); + if (mode.equals("noData")) { + errorNfc(this, intent,""); + finish(); + } + } + + NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this); + if((null==adapter)||(!adapter.isEnabled())){ + errorNfc(this,intent,""); + finish(); + } + } + + @Override + protected void onResume() { + Logger.logVerbose(LOG_TAG, "onResume"); + + super.onResume(); + adapter = NfcAdapter.getDefaultAdapter(this); + Intent intentNew = new Intent(this, NfcActivity.class).addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intentNew, 0); + IntentFilter[] intentFilter = new IntentFilter[]{ + new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED), + new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED), + new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)}; + adapter.enableForegroundDispatch(this, pendingIntent, intentFilter, null); + } + + @Override + protected void onNewIntent(Intent intent) { + Logger.logDebug(LOG_TAG, "onNewIntent"); + + intent.putExtra("socket_input", socket_input); + intent.putExtra("socket_output", socket_output); + + if (NfcAdapter.ACTION_TAG_DISCOVERED.equals(intent.getAction())) { + try { + postResult(this, intent); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error posting result" ,e); + } + finish(); + } + super.onNewIntent(intent); + } + + @Override + protected void onPause() { + Logger.logDebug(LOG_TAG, "onPause"); + + adapter.disableForegroundDispatch(this); + super.onPause(); + } + + @Override + protected void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + + socket_input = null; + socket_output = null; + super.onDestroy(); + } + + protected void postResult(final Context context, Intent intent) { + ResultReturner.returnData(context, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + Logger.logDebug(LOG_TAG, "postResult"); + try { + switch (mode) { + case "write": + switch (param) { + case "text": + Logger.logVerbose(LOG_TAG, "Write start"); + onReceiveNfcWrite(context, intent); + Logger.logVerbose(LOG_TAG, "Write end"); + break; + default: + onUnexpectedAction(out, "Wrong Params", "Should be text for TAG"); + break; + } + break; + case "read": + switch (param){ + case "short": + readNDEFTag(intent,out); + break; + case "full": + readFullNDEFTag(intent,out); + break; + case "noData": + readNDEFTag(intent,out); + break; + default: + onUnexpectedAction(out, "Wrong Params", "Should be correct param value"); + break; + } + break; + default: + onUnexpectedAction(out, "Wrong Params", "Should be correct mode value "); + break; + } + } catch (Exception e){ + onUnexpectedAction(out, "exception", e.getMessage()); + } + } + }); + } + + public void onReceiveNfcWrite( final Context context, Intent intent) throws Exception { + Logger.logVerbose(LOG_TAG, "onReceiveNfcWrite"); + + NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context); + Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); + NdefRecord record = NdefRecord.createTextRecord("en", value); + NdefMessage msg = new NdefMessage(new NdefRecord[]{record}); + Ndef ndef = Ndef.get(tag); + ndef.connect(); + ndef.writeNdefMessage(msg); + ndef.close(); + } + + + public void readNDEFTag(Intent intent, JsonWriter out) throws Exception { + Logger.logVerbose(LOG_TAG, "readNDEFTag"); + + NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this); + Parcelable[] msgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES); + Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); + Ndef ndefTag = Ndef.get(tag); + boolean bNdefPresent = false; + String[] strs = tag.getTechList(); + for (String s: strs){ + if (s.equals("android.nfc.tech.Ndef")) { + bNdefPresent = true; + break; + } + } + if (!bNdefPresent){ + onUnexpectedAction(out, "Wrong Technology","termux API support only NFEF Tag"); + return; + } + NdefMessage[] nmsgs = new NdefMessage[msgs.length]; + if (msgs.length == 1) { + nmsgs[0] = (NdefMessage) msgs[0]; + NdefRecord[] records = nmsgs[0].getRecords(); + out.beginObject(); + if (records.length >0 ) { + { + out.name("Record"); + if (records.length > 1) + out.beginArray(); + for (NdefRecord record: records){ + out.beginObject(); + int pos = 1 + record.getPayload()[0]; + pos = (NdefRecord.TNF_WELL_KNOWN==record.getTnf())?(int)record.getPayload()[0]+1:0; + int len = record.getPayload().length - pos; + byte[] msg = new byte[len]; + System.arraycopy(record.getPayload(), pos, msg, 0, len); + out.name("Payload").value(new String(msg)); + out.endObject(); + } + if (records.length > 1) + out.endArray(); + } + } + out.endObject(); + } + } + + public void readFullNDEFTag(Intent intent, JsonWriter out) throws Exception { + Logger.logVerbose(LOG_TAG, "readFullNDEFTag"); + + NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this); + Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); + Ndef ndefTag = Ndef.get(tag); + Parcelable[] msgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES); + + String[] strs = tag.getTechList(); + boolean bNdefPresent = false; + for (String s: strs){ + if (s.equals("android.nfc.tech.Ndef")) { + bNdefPresent = true; + break; + } + } + if (!bNdefPresent){ + onUnexpectedAction(out, "Wrong Technology","termux API support only NFEF Tag"); + return; + } + NdefMessage[] nmsgs = new NdefMessage[msgs.length]; + out.beginObject(); + { + byte[] tagID = tag.getId(); + StringBuilder sp = new StringBuilder(); + for (byte tagIDpart : tagID) { sp.append(String.format("%02x", tagIDpart)); } + out.name("id").value(sp.toString()); + out.name("typeTag").value(ndefTag.getType()); + out.name("maxSize").value(ndefTag.getMaxSize()); + out.name("techList"); + { + out.beginArray(); + String[] tlist = tag.getTechList(); + for (String str : tlist) { + out.value(str); + } + out.endArray(); + } + if (msgs.length == 1) { + Logger.logInfo(LOG_TAG, "-->> readFullNDEFTag - 06"); + nmsgs[0] = (NdefMessage) msgs[0]; + NdefRecord[] records = nmsgs[0].getRecords(); + { + out.name("record"); + if (records.length > 1) + out.beginArray(); + for (NdefRecord record : records) { + out.beginObject(); + out.name("type").value(new String(record.getType())); + out.name("tnf").value(record.getTnf()); + if (records[0].toUri() != null) out.name("URI").value(record.toUri().toString()); + out.name("mime").value(record.toMimeType()); + int pos = 1 + record.getPayload()[0]; + pos = (NdefRecord.TNF_WELL_KNOWN==record.getTnf())?(int)record.getPayload()[0]+1:0; + int len = record.getPayload().length - pos; + byte[] msg = new byte[len]; + System.arraycopy(record.getPayload(), pos, msg, 0, len); + out.name("payload").value(new String(msg)); + out.endObject(); + } + if (records.length > 1) out.endArray(); + } + } + + } + out.endObject(); + } + + protected void onUnexpectedAction(JsonWriter out,String error, String description) throws Exception { + out.beginObject(); + out.name("error").value(error); + out.name("description").value(description); + out.endObject(); + out.flush(); + } + } + +} diff --git a/app/src/main/java/com/termux/api/NotificationAPI.java b/app/src/main/java/com/termux/api/apis/NotificationAPI.java similarity index 68% rename from app/src/main/java/com/termux/api/NotificationAPI.java rename to app/src/main/java/com/termux/api/apis/NotificationAPI.java index d407cc243..caee093a3 100644 --- a/app/src/main/java/com/termux/api/NotificationAPI.java +++ b/app/src/main/java/com/termux/api/apis/NotificationAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Notification; import android.app.NotificationChannel; @@ -19,25 +19,26 @@ import androidx.core.app.RemoteInput; import androidx.core.util.Pair; +import com.termux.api.R; +import com.termux.api.TermuxAPIConstants; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import java.io.File; import java.io.PrintWriter; import java.lang.reflect.Field; -import java.util.Arrays; import java.util.Objects; import java.util.UUID; -import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR_PATH; - public class NotificationAPI { - public static final String TERMUX_SERVICE = "com.termux.app.TermuxService"; - public static final String ACTION_EXECUTE = "com.termux.service_execute"; - public static final String EXTRA_ARGUMENTS = "com.termux.execute.arguments"; - public static final String BIN_SH = TERMUX_PREFIX_DIR_PATH+"/bin/sh"; - private static final String EXTRA_EXECUTE_IN_BACKGROUND = "com.termux.execute.background"; + private static final String LOG_TAG = "NotificationAPI"; + + public static final String BIN_SH = TermuxConstants.TERMUX_PREFIX_DIR_PATH + "/bin/sh"; private static final String CHANNEL_ID = "termux-notification"; private static final String CHANNEL_TITLE = "Termux API notification channel"; private static final String KEY_TEXT_REPLY = "TERMUX_TEXT_REPLY"; @@ -45,7 +46,9 @@ public class NotificationAPI { /** * Show a notification. Driven by the termux-show-notification script. */ - static void onReceiveShowNotification(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceiveShowNotification(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveShowNotification"); + Pair pair = buildNotification(context, intent); NotificationCompat.Builder notification = pair.first; String notificationId = pair.second; @@ -65,27 +68,9 @@ public void writeResult(PrintWriter out) { } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - String priorityExtra = intent.getStringExtra("priority"); - if (priorityExtra == null) priorityExtra = "default"; - int importance; - switch (priorityExtra) { - case "high": - case "max": - importance = NotificationManager.IMPORTANCE_HIGH; - break; - case "low": - importance = NotificationManager.IMPORTANCE_LOW; - break; - case "min": - importance = NotificationManager.IMPORTANCE_MIN; - break; - default: - importance = NotificationManager.IMPORTANCE_DEFAULT; - } NotificationChannel channel = new NotificationChannel(CHANNEL_ID, - CHANNEL_TITLE, importance); + CHANNEL_TITLE, priorityFromIntent(intent)); manager.createNotificationChannel(channel); - notification.setChannelId(CHANNEL_ID); } manager.notify(notificationId, 0, notification.build()); @@ -93,6 +78,62 @@ public void writeResult(PrintWriter out) { }); } + public static void onReceiveChannel(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveChannel"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + NotificationManager m = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + String channelId = intent.getStringExtra("id"); + String channelName = intent.getStringExtra("name"); + + if (channelId == null || channelId.equals("")) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Channel id not specified.")); + return; + } + + if (intent.getBooleanExtra("delete",false)) { + m.deleteNotificationChannel(channelId); + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Deleted channel with id \""+channelId+"\".")); + return; + } + + if (channelName == null || channelName.equals("")) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Cannot create a channel without a name.")); + } + + NotificationChannel c = new NotificationChannel(channelId, channelName, priorityFromIntent(intent)); + m.createNotificationChannel(c); + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Created channel with id \""+channelId+"\" and name \""+channelName+"\".")); + } catch (Exception e) { + e.printStackTrace(); + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Could not create/delete channel.")); + } + } else { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Notification channels are only available on Android 8.0 and higher, use the options for termux-notification instead.")); + } + } + + private static int priorityFromIntent(Intent intent) { + String priorityExtra = intent.getStringExtra("priority"); + if (priorityExtra == null) priorityExtra = "default"; + int importance; + switch (priorityExtra) { + case "high": + case "max": + importance = NotificationManager.IMPORTANCE_HIGH; + break; + case "low": + importance = NotificationManager.IMPORTANCE_LOW; + break; + case "min": + importance = NotificationManager.IMPORTANCE_MIN; + break; + default: + importance = NotificationManager.IMPORTANCE_DEFAULT; + } + return importance; + } static Pair buildNotification(final Context context, final Intent intent) { String priorityExtra = intent.getStringExtra("priority"); @@ -125,7 +166,7 @@ static Pair buildNotification(final Context try { ledColor = Integer.parseInt(lightsArgbExtra, 16) | 0xff000000; } catch (NumberFormatException e) { - TermuxApiLogger.error("Invalid LED color format! Ignoring!"); + Logger.logError(LOG_TAG, "Invalid LED color format! Ignoring!"); } } @@ -142,9 +183,14 @@ static Pair buildNotification(final Context final String notificationId = getNotificationId(intent); String groupKey = intent.getStringExtra("group"); - + + String channel = intent.getStringExtra("channel"); + if (channel == null) { + channel = CHANNEL_ID; + } + final NotificationCompat.Builder notification = new NotificationCompat.Builder(context, - CHANNEL_ID); + channel); notification.setSmallIcon(R.drawable.ic_event_note_black_24dp); notification.setColor(0xFF000000); notification.setContentTitle(title); @@ -260,7 +306,8 @@ static Pair buildNotification(final Context PendingIntent pi = createAction(context, onDeleteActionExtra); notification.setDeleteIntent(pi); } - return new Pair(notification, notificationId); + + return new Pair<>(notification, notificationId); } private static String getNotificationId(Intent intent) { @@ -269,7 +316,9 @@ private static String getNotificationId(Intent intent) { return id; } - static void onReceiveRemoveNotification(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceiveRemoveNotification(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveRemoveNotification"); + ResultReturner.noteDone(apiReceiver, intent); String notificationId = intent.getStringExtra("id"); if (notificationId != null) { @@ -282,9 +331,8 @@ static NotificationCompat.Action createReplyAction(final Context context, Intent int buttonNum, String buttonText, String buttonAction, String notificationId) { - String replyLabel = buttonText; RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel(replyLabel) + .setLabel(buttonText) .build(); // Build a PendingIntent for the reply action to trigger. @@ -295,26 +343,21 @@ static NotificationCompat.Action createReplyAction(final Context context, Intent PendingIntent.FLAG_UPDATE_CURRENT); // Create the reply action and add the remote input. - NotificationCompat.Action action = - new NotificationCompat.Action.Builder(R.drawable.ic_event_note_black_24dp, + return new NotificationCompat.Action.Builder(R.drawable.ic_event_note_black_24dp, buttonText, replyPendingIntent) .addRemoteInput(remoteInput) .build(); - - return action; } private static Intent getMessageReplyIntent(Intent oldIntent, String buttonText, String buttonAction, String notificationId) { - Intent intent = oldIntent. - setClassName("com.termux.api", "com.termux.api.TermuxApiReceiver"). + return oldIntent. + setClassName(TermuxConstants.TERMUX_API_PACKAGE_NAME, TermuxAPIConstants.TERMUX_API_RECEIVER_NAME). putExtra("api_method", "NotificationReply"). putExtra("id", notificationId). - putExtra("action", buttonAction). - putExtra("replyKey", buttonText); - return intent; + putExtra("action", buttonAction); } @@ -330,17 +373,21 @@ static CharSequence shellEscape(CharSequence input) { return "\"" + input.toString().replace("\"", "\\\"") + "\""; } - static void onReceiveReplyToNotification(TermuxApiReceiver termuxApiReceiver, + public static void onReceiveReplyToNotification(TermuxApiReceiver termuxApiReceiver, Context context, Intent intent) { - String replyKey = intent.getStringExtra("replyKey"); + Logger.logDebug(LOG_TAG, "onReceiveReplyToNotification"); + CharSequence reply = getMessageText(intent); - String action = intent.getStringExtra("action") - .replace("$REPLY", shellEscape(reply)); + String action = intent.getStringExtra("action"); + + if (action != null && reply != null) + action = action.replace("$REPLY", shellEscape(reply)); + try { createAction(context, action).send(); } catch (PendingIntent.CanceledException e) { - TermuxApiLogger.error("CanceledException when performing action: " + action); + Logger.logError(LOG_TAG, "CanceledException when performing action: " + action); } String notificationId = intent.getStringExtra("id"); @@ -358,16 +405,18 @@ static void onReceiveReplyToNotification(TermuxApiReceiver termuxApiReceiver, } static Intent createExecuteIntent(String action){ - String[] arguments = new String[]{"-c", action}; - Uri executeUri = new Uri.Builder().scheme("com.termux.file") - .path(BIN_SH) - .appendQueryParameter("arguments", Arrays.toString(arguments)) - .build(); - Intent executeIntent = new Intent(ACTION_EXECUTE, executeUri); - executeIntent.setClassName("com.termux", TERMUX_SERVICE); - executeIntent.putExtra(EXTRA_EXECUTE_IN_BACKGROUND, true); - executeIntent.putExtra(EXTRA_ARGUMENTS, arguments); - return executeIntent; + ExecutionCommand executionCommand = new ExecutionCommand(); + executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(BIN_SH).build(); + executionCommand.arguments = new String[]{"-c", action}; + executionCommand.runner = ExecutionCommand.Runner.APP_SHELL.getName(); + + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + Intent executionIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executionCommand.executableUri); + executionIntent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); + executionIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, executionCommand.arguments); + executionIntent.putExtra(TERMUX_SERVICE.EXTRA_RUNNER, executionCommand.runner); + executionIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, true); // Also pass in case user using termux-app version < 0.119.0 + return executionIntent; } static PendingIntent createAction(final Context context, String action){ diff --git a/app/src/main/java/com/termux/api/NotificationListAPI.java b/app/src/main/java/com/termux/api/apis/NotificationListAPI.java similarity index 83% rename from app/src/main/java/com/termux/api/NotificationListAPI.java rename to app/src/main/java/com/termux/api/apis/NotificationListAPI.java index 778335738..388e3520a 100644 --- a/app/src/main/java/com/termux/api/NotificationListAPI.java +++ b/app/src/main/java/com/termux/api/apis/NotificationListAPI.java @@ -1,21 +1,27 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Notification; import android.content.Context; import android.content.Intent; +import android.service.notification.NotificationListenerService; import android.service.notification.StatusBarNotification; import android.util.JsonWriter; import java.text.SimpleDateFormat; import java.util.Date; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.api.util.ResultReturner.ResultJsonWriter; +import com.termux.shared.logger.Logger; public class NotificationListAPI { + private static final String LOG_TAG = "NotificationListAPI"; + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { @Override @@ -85,5 +91,26 @@ static void listNotifications(Context context, JsonWriter out) throws Exception out.endObject(); } out.endArray(); + } + + + + public static class NotificationService extends NotificationListenerService { + static NotificationService _this; + + public static NotificationService get() { + return _this; + } + + @Override + public void onListenerConnected() { + _this = this; + } + + @Override + public void onListenerDisconnected() { + _this = null; } } + +} diff --git a/app/src/main/java/com/termux/api/apis/SAFAPI.java b/app/src/main/java/com/termux/api/apis/SAFAPI.java new file mode 100644 index 000000000..6dd5c297b --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/SAFAPI.java @@ -0,0 +1,348 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.content.UriPermission; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.FileUtils; +import android.provider.DocumentsContract; +import android.util.JsonWriter; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.documentfile.provider.DocumentFile; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; + +public class SAFAPI { + + private static final String LOG_TAG = "SAFAPI"; + + public static class SAFActivity extends AppCompatActivity { + + private boolean resultReturned = false; + + private static final String LOG_TAG = "SAFActivity"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(savedInstanceState); + Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + startActivityForResult(i, 0); + } + + @Override + protected void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + + super.onDestroy(); + finishAndRemoveTask(); + if (! resultReturned) { + ResultReturner.returnData(this, getIntent(), out -> out.write("")); + resultReturned = true; + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data)); + + super.onActivityResult(requestCode, resultCode, data); + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + resultReturned = true; + ResultReturner.returnData(this, getIntent(), out -> out.println(data.getDataString())); + } + } + finish(); + } + } + + public static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + String method = intent.getStringExtra("safmethod"); + if (method == null) { + Logger.logError(LOG_TAG, "safmethod extra null"); + return; + } + try { + switch (method) { + case "getManagedDocumentTrees": + getManagedDocumentTrees(apiReceiver, context, intent); + break; + case "manageDocumentTree": + manageDocumentTree(context, intent); + break; + case "writeDocument": + writeDocument(apiReceiver, context, intent); + break; + case "createDocument": + createDocument(apiReceiver, context, intent); + break; + case "readDocument": + readDocument(apiReceiver, context, intent); + break; + case "listDirectory": + listDirectory(apiReceiver, context, intent); + break; + case "removeDocument": + removeDocument(apiReceiver, context, intent); + break; + case "statURI": + statURI(apiReceiver, context, intent); + break; + default: + Logger.logError(LOG_TAG, "Unrecognized safmethod: " + "'" + method + "'"); + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error in SAFAPI", e); + } + } + + private static void getManagedDocumentTrees(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() + { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginArray(); + for (UriPermission p : context.getContentResolver().getPersistedUriPermissions()) { + statDocument(out, context, treeUriToDocumentUri(p.getUri())); + } + out.endArray(); + } + }); + } + + private static void manageDocumentTree(Context context, Intent intent) { + Intent i = new Intent(context, SAFActivity.class); + i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ResultReturner.copyIntentExtras(intent, i); + context.startActivity(i); + } + + private static void writeDocument(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String uri = intent.getStringExtra("uri"); + if (uri == null) { + Logger.logError(LOG_TAG, "uri extra null"); + return; + } + DocumentFile f = DocumentFile.fromSingleUri(context, Uri.parse(uri)); + if (f == null) { + return; + } + writeDocumentFile(apiReceiver, context, intent, f); + } + + private static void createDocument(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String treeURIString = intent.getStringExtra("treeuri"); + if (treeURIString == null) { + Logger.logError(LOG_TAG, "treeuri extra null"); + return; + } + String name = intent.getStringExtra("filename"); + if (name == null) { + Logger.logError(LOG_TAG, "filename extra null"); + return; + } + String mime = intent.getStringExtra("mimetype"); + if (mime == null) { + mime = "application/octet-stream"; + } + Uri treeURI = Uri.parse(treeURIString); + String id = DocumentsContract.getTreeDocumentId(treeURI); + try { + id = DocumentsContract.getDocumentId(Uri.parse(treeURIString)); + } catch (IllegalArgumentException ignored) {} + final String finalMime = mime; + final String finalId = id; + ResultReturner.returnData(apiReceiver, intent, out -> + out.println(DocumentsContract.createDocument(context.getContentResolver(), DocumentsContract.buildDocumentUriUsingTree(treeURI, finalId), finalMime, name).toString()) + ); + } + + private static void readDocument(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String uri = intent.getStringExtra("uri"); + if (uri == null) { + Logger.logError(LOG_TAG, "uri extra null"); + return; + } + DocumentFile f = DocumentFile.fromSingleUri(context, Uri.parse(uri)); + if (f == null) { + return; + } + returnDocumentFile(apiReceiver, context, intent, f); + } + + private static void listDirectory(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String treeURIString = intent.getStringExtra("treeuri"); + if (treeURIString == null) { + Logger.logError(LOG_TAG, "treeuri extra null"); + return; + } + Uri treeURI = Uri.parse(treeURIString); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() + { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginArray(); + String id = DocumentsContract.getTreeDocumentId(treeURI); + try { + id = DocumentsContract.getDocumentId(Uri.parse(treeURIString)); + } catch (IllegalArgumentException ignored) {} + try (Cursor c = context.getContentResolver().query(DocumentsContract.buildChildDocumentsUriUsingTree(Uri.parse(treeURIString), id), new String[] { + DocumentsContract.Document.COLUMN_DOCUMENT_ID }, null, null, null)) { + while (c.moveToNext()) { + String documentId = c.getString(0); + Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeURI, documentId); + statDocument(out, context, documentUri); + } + } catch (UnsupportedOperationException ignored) { } + out.endArray(); + } + }); + } + + private static void statURI(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String uriString = intent.getStringExtra("uri"); + if (uriString == null) { + Logger.logError(LOG_TAG, "uri extra null"); + return; + } + Uri docUri = treeUriToDocumentUri(Uri.parse(uriString)); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() + { + @Override + public void writeJson(JsonWriter out) throws Exception { + statDocument(out, context, Uri.parse(docUri.toString())); + } + }); + } + + + private static void removeDocument(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String uri = intent.getStringExtra("uri"); + if (uri == null) { + Logger.logError(LOG_TAG, "uri extra null"); + return; + } + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + if (DocumentsContract.deleteDocument(context.getContentResolver(), Uri.parse(uri))) { + out.println(0); + } else { + out.println(1); + } + } catch (FileNotFoundException | IllegalArgumentException e ) { + out.println(2); + } + }); + } + + + private static Uri treeUriToDocumentUri(Uri tree) { + String id = DocumentsContract.getTreeDocumentId(tree); + try { + id = DocumentsContract.getDocumentId(tree); + } catch (IllegalArgumentException ignored) {} + return DocumentsContract.buildDocumentUriUsingTree(tree, id); + } + + private static void statDocument(JsonWriter out, Context context, Uri uri) throws Exception { + try (Cursor c = context.getContentResolver().query(uri, null, null, null, null)) { + if (c == null || c.getCount() == 0) { + return; + } + int index; + String mime = null; + c.moveToNext(); + out.beginObject(); + + index = c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME); + if (index >= 0) { + out.name("name"); + out.value(c.getString(index)); + } + + index = c.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE); + if (index >= 0) { + out.name("type"); + mime = c.getString(index); + out.value(mime); + } + + out.name("uri"); + out.value(uri.toString()); + + index = c.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED); + if (index >= 0) { + out.name("last_modified"); + out.value(c.getLong(index)); + } + + if (mime != null && !DocumentsContract.Document.MIME_TYPE_DIR.equals(mime)) { + index = c.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE); + if (index >= 0) { + out.name("length"); + out.value(c.getInt(index)); + } + } + + out.endObject(); + } + } + + private static void returnDocumentFile(TermuxApiReceiver apiReceiver, Context context, Intent intent, DocumentFile f) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.BinaryOutput() + { + @Override + public void writeResult(OutputStream out) throws Exception { + try (InputStream in = context.getContentResolver().openInputStream(f.getUri())) { + writeInputStreamToOutputStream(in, out); + } + } + }); + } + + private static void writeDocumentFile(TermuxApiReceiver apiReceiver, Context context, Intent intent, DocumentFile f) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.WithInput() + { + @Override + public void writeResult(PrintWriter unused) throws Exception { + try (OutputStream out = context.getContentResolver().openOutputStream(f.getUri(), "rwt")) { + writeInputStreamToOutputStream(in, out); + } + } + }); + } + + private static void writeInputStreamToOutputStream(InputStream in, OutputStream out) throws IOException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + FileUtils.copy(in, out); + } + else { + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + } + +} diff --git a/app/src/main/java/com/termux/api/SensorAPI.java b/app/src/main/java/com/termux/api/apis/SensorAPI.java similarity index 85% rename from app/src/main/java/com/termux/api/SensorAPI.java rename to app/src/main/java/com/termux/api/apis/SensorAPI.java index d0e9a0cde..efa8fede4 100644 --- a/app/src/main/java/com/termux/api/SensorAPI.java +++ b/app/src/main/java/com/termux/api/apis/SensorAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Service; import android.content.Context; @@ -12,7 +12,7 @@ import android.os.IBinder; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import org.json.JSONArray; import org.json.JSONException; @@ -30,10 +30,14 @@ */ public class SensorAPI { + private static final String LOG_TAG = "SensorAPI"; + /** * Starts our SensorReader service */ public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + Intent serviceIntent = new Intent(context, SensorReaderService.class); serviceIntent.setAction(intent.getAction()); serviceIntent.putExtras(intent.getExtras()); @@ -45,6 +49,7 @@ public static void onReceive(final Context context, final Intent intent) { * All sensor listening functionality exists in this background service */ public static class SensorReaderService extends Service { + // indentation for JSON output protected static final int INDENTATION = 2; @@ -55,8 +60,11 @@ public static class SensorReaderService extends Service { // prevent concurrent modifications w/ sensor readout protected static Semaphore semaphore; + private static final String LOG_TAG = "SensorReaderService"; public void onCreate() { + Logger.logDebug(LOG_TAG, "onCreate"); + super.onCreate(); sensorReadout = new JSONObject(); semaphore = new Semaphore(1); @@ -64,6 +72,8 @@ public void onCreate() { @Override public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + String command = intent.getAction(); Context context = getApplicationContext(); SensorManager sensorManager = getSensorManager(context); @@ -87,9 +97,10 @@ protected static SensorManager getSensorManager(Context context) { @Override public void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + super.onDestroy(); cleanup(); - TermuxApiLogger.info("SensorAPI SensorReaderService onDestroy()"); } protected static void cleanup() { @@ -127,7 +138,7 @@ public void onSensorChanged(SensorEvent sensorEvent) { sensorReadout.put(sensorEvent.sensor.getName(), sensorInfo); semaphore.release(); } catch (JSONException e) { - TermuxApiLogger.error("onSensorChanged error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "onSensorChanged error", e); } catch (InterruptedException e) { e.printStackTrace(); } @@ -194,7 +205,7 @@ private void postSensorCommandResult(final Context context, final Intent intent, output.put("sensors", sensorArray); result.message = output.toString(INDENTATION); } catch (JSONException e) { - TermuxApiLogger.error("listHandler JSON error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "listHandler JSON error", e); } return result; }; @@ -212,7 +223,7 @@ public SensorCommandResult handle(SensorManager sensorManager, Context context, outputWriter = null; sensorManager.unregisterListener(sensorEventListener); result.message = "Sensor cleanup successful!"; - TermuxApiLogger.info("Cleanup()"); + Logger.logInfo(LOG_TAG, "Cleanup()"); } else { result.message = "Sensor cleanup unnecessary"; } @@ -272,21 +283,29 @@ protected static List getSensorsToListenTo(SensorManager sensorManager, sensorManager.registerListener(sensorEventListener, sensor, SensorManager.SENSOR_DELAY_UI); } sensorsToListenTo = availableSensors; - TermuxApiLogger.info("Listening to ALL sensors"); + Logger.logInfo(LOG_TAG, "Listening to ALL sensors"); } else { // try to find matching sensors that were sent in request - for (String sensorName : requestedSensors) { + for (String requestedSensor : requestedSensors) { // ignore case - sensorName = sensorName.toUpperCase(); + requestedSensor = requestedSensor.toUpperCase(); + + Sensor shortestMatchSensor = null; + int shortestMatchSensorLength = Integer.MAX_VALUE; - for (Sensor sensor : availableSensors) { - if (sensor.getName().toUpperCase().contains(sensorName)) { - sensorManager.registerListener(sensorEventListener, sensor, SensorManager.SENSOR_DELAY_UI); - sensorsToListenTo.add(sensor); - break; + for (Sensor availableSensor : availableSensors) { + String sensorName = availableSensor.getName().toUpperCase(); + if (sensorName.contains(requestedSensor) && sensorName.length() < shortestMatchSensorLength) { + shortestMatchSensor = availableSensor; + shortestMatchSensorLength = sensorName.length(); } } + + if (shortestMatchSensor != null) { + sensorManager.registerListener(sensorEventListener, shortestMatchSensor, SensorManager.SENSOR_DELAY_UI); + sensorsToListenTo.add(shortestMatchSensor); + } } } return sensorsToListenTo; @@ -313,15 +332,15 @@ protected static SensorOutputWriter createSensorOutputWriter(Intent intent) { outputWriter = new SensorOutputWriter(socketAddress); outputWriter.setOnErrorListener(e -> { outputWriter = null; - TermuxApiLogger.error("SensorOutputWriter error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "SensorOutputWriter error", e); }); int delay = intent.getIntExtra("delay", SensorOutputWriter.DEFAULT_DELAY); - TermuxApiLogger.info("Delay set to: " + delay); + Logger.logInfo(LOG_TAG, "Delay set to: " + delay); outputWriter.setDelay(delay); int limit = intent.getIntExtra("limit", SensorOutputWriter.DEFAULT_LIMIT); - TermuxApiLogger.info("SensorOutput limit set to: " + limit); + Logger.logInfo(LOG_TAG, "SensorOutput limit set to: " + limit); outputWriter.setLimit(limit); return outputWriter; @@ -377,7 +396,8 @@ public void run() { try { try (LocalSocket outputSocket = new LocalSocket()) { - outputSocket.connect(new LocalSocketAddress(this.outputSocketAddress)); + outputSocket.connect(ResultReturner.getApiLocalSocketAddress( + ResultReturner.context, "output", this.outputSocketAddress)); try (PrintWriter writer = new PrintWriter(outputSocket.getOutputStream())) { @@ -385,7 +405,7 @@ public void run() { try { Thread.sleep(this.delay); } catch (InterruptedException e) { - TermuxApiLogger.info("SensorOutputWriter interrupted: " + e.getMessage()); + Logger.logInfo(LOG_TAG, "SensorOutputWriter interrupted: " + e.getMessage()); } semaphore.acquire(); writer.write(sensorReadout.toString(INDENTATION) + "\n"); @@ -393,15 +413,15 @@ public void run() { semaphore.release(); if (++counter >= limit) { - TermuxApiLogger.info("SensorOutput limit reached! Performing cleanup"); + Logger.logInfo(LOG_TAG, "SensorOutput limit reached! Performing cleanup"); cleanup(); } } - TermuxApiLogger.info("SensorOutputWriter finished"); + Logger.logInfo(LOG_TAG, "SensorOutputWriter finished"); } } } catch (Exception e) { - TermuxApiLogger.error("SensorOutputWriter error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "SensorOutputWriter error", e); if (errorListener != null) { errorListener.onError(e); diff --git a/app/src/main/java/com/termux/api/ShareAPI.java b/app/src/main/java/com/termux/api/apis/ShareAPI.java similarity index 84% rename from app/src/main/java/com/termux/api/ShareAPI.java rename to app/src/main/java/com/termux/api/apis/ShareAPI.java index 407611f1c..a8eeb7655 100644 --- a/app/src/main/java/com/termux/api/ShareAPI.java +++ b/app/src/main/java/com/termux/api/apis/ShareAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.ContentValues; import android.content.Context; @@ -11,16 +11,25 @@ import android.text.TextUtils; import android.webkit.MimeTypeMap; +import com.termux.api.R; +import com.termux.api.TermuxAPIConstants; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; +import com.termux.shared.net.uri.UriUtils; import java.io.File; import java.io.FileNotFoundException; +import java.io.IOException; import java.io.PrintWriter; public class ShareAPI { - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + private static final String LOG_TAG = "ShareAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + final String fileExtra = intent.getStringExtra("file"); final String titleExtra = intent.getStringExtra("title"); final String contentTypeExtra = intent.getStringExtra("content-type"); @@ -42,7 +51,7 @@ static void onReceive(TermuxApiReceiver apiReceiver, final Context context, fina intentAction = Intent.ACTION_VIEW; break; default: - TermuxApiLogger.error("Invalid action '" + actionExtra + "', using 'view'"); + Logger.logError(LOG_TAG, "Invalid action '" + actionExtra + "', using 'view'"); break; } } @@ -81,7 +90,9 @@ public void writeResult(PrintWriter out) { Intent sendIntent = new Intent(); sendIntent.setAction(finalIntentAction); - Uri uriToShare = Uri.parse("content://com.termux.sharedfiles" + fileToShare.getAbsolutePath()); + + // Do not create Uri with Uri.parse() and use Uri.Builder().path(), check UriUtils.getUriFilePath(). + Uri uriToShare = UriUtils.getContentUri(TermuxAPIConstants.TERMUX_API_FILE_SHARE_URI_AUTHORITY, fileToShare.getAbsolutePath()); sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); String contentTypeToUse; @@ -116,6 +127,8 @@ public void writeResult(PrintWriter out) { public static class ContentProvider extends android.content.ContentProvider { + private static final String LOG_TAG = "ContentProvider"; + @Override public boolean onCreate() { return true; @@ -182,7 +195,17 @@ public int update(Uri uri, ContentValues values, String selection, String[] sele @Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { File file = new File(uri.getPath()); + + try { + String path = file.getCanonicalPath(); + String callingPackageName = getCallingPackage(); + Logger.logDebug(LOG_TAG, "Open file request received from " + callingPackageName + " for \"" + path + "\" with mode \"" + mode + "\""); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); } } + } diff --git a/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java b/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java new file mode 100644 index 000000000..17e5693ed --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java @@ -0,0 +1,401 @@ +package com.termux.api.apis; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.PhoneLookup; +import android.provider.Telephony.Sms; +import android.provider.Telephony.Sms.Conversations; +import android.provider.Telephony.TextBasedSmsColumns; +import android.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.api.util.ResultReturner.ResultJsonWriter; +import com.termux.shared.logger.Logger; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static android.provider.Telephony.TextBasedSmsColumns.*; + +import androidx.annotation.Nullable; + +/** + * **See Also:** + * - https://developer.android.com/reference/android/provider/Telephony + * - https://developer.android.com/reference/android/provider/Telephony.Sms.Conversations + * - https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns + * - https://developer.android.com/reference/android/provider/BaseColumns + */ +public class SmsInboxAPI { + + private static final String[] DISPLAY_NAME_PROJECTION = {PhoneLookup.DISPLAY_NAME}; + + private static final String LOG_TAG = "SmsInboxAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + String value; + + final boolean conversationList = intent.getBooleanExtra("conversation-list", false); + + final boolean conversationReturnMultipleMessages = intent.getBooleanExtra("conversation-return-multiple-messages", false); + final boolean conversationReturnNestedView = intent.getBooleanExtra("conversation-return-nested-view", false); + final boolean conversationReturnNoOrderReverse = intent.getBooleanExtra("conversation-return-no-order-reverse", false); + + final int conversationOffset = intent.getIntExtra("conversation-offset", -1); + final int conversationLimit = intent.getIntExtra("conversation-limit", -1); + final String conversationSelection = intent.getStringExtra("conversation-selection"); + + /* + NOTE: When conversation or messages are queried from the Android database, first the + sort order is applied, and then any offset and limit values are used to filter the + entries. Since the default sort order is 'date DESC', Android returns the latest dated + conversations or messages first, but the API reverses the order by default (with + `Cursor.moveToLast()`/`Cursor.moveToPrevious()`) so that the latest entries are printed + at the end. If the order should not be reversed, then pass the respective + `*-return-no-order-reverse` extras. + */ + value = intent.getStringExtra("conversation-sort-order"); + if (value == null || value.isEmpty()) { + value = "date DESC"; + } + final String conversationSortOrder = value; + + + final int messageOffset = intent.getIntExtra("offset", 0); + final int messageLimit = intent.getIntExtra("limit", 10); + final int messageTypeColumn = intent.getIntExtra("type", TextBasedSmsColumns.MESSAGE_TYPE_INBOX); + final String messageSelection = intent.getStringExtra("message-selection"); + + value = intent.getStringExtra("from"); + if (value == null || value.isEmpty()) { + value = null; + } + final String messageAddress = value; + + value = intent.getStringExtra("message-sort-order"); + if (value == null || value.isEmpty()) { + value = "date DESC"; + } + final String messageSortOrder = value; + + final boolean messageReturnNoOrderReverse = intent.getBooleanExtra("message-return-no-order-reverse", false); + + Uri contentURI; + if (conversationList) { + contentURI = typeToContentURI(TextBasedSmsColumns.MESSAGE_TYPE_ALL); + } else { + contentURI = typeToContentURI(messageAddress == null ? + messageTypeColumn : TextBasedSmsColumns.MESSAGE_TYPE_ALL); + } + + ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + if (conversationList) { + getConversations(context, out, + conversationOffset, conversationLimit, + conversationSelection, + conversationSortOrder, + conversationReturnMultipleMessages,conversationReturnNestedView, + conversationReturnNoOrderReverse, + messageOffset, messageLimit, + messageSelection, + messageSortOrder, + messageReturnNoOrderReverse); + } else { + getAllSms(context, out, contentURI, + messageOffset, messageLimit, + messageSelection, messageAddress, + messageSortOrder, + messageReturnNoOrderReverse); + } + } + }); + } + + @SuppressLint("SimpleDateFormat") + public static void getConversations(Context context, JsonWriter out, + int conversationOffset, int conversationLimit, + String conversationSelection, + String conversationSortOrder, + boolean conversationReturnMultipleMessages, boolean conversationReturnNestedView, + boolean conversationReturnNoOrderReverse, + int messageOffset, int messageLimit, + String messageSelection, + String messageSortOrder, + boolean messageReturnNoOrderReverse) throws IOException { + ContentResolver cr = context.getContentResolver(); + + // `THREAD_ID` is used to select messages for a conversation, so do not allow caller to pass it. + if (messageSelection != null && messageSelection.matches("^(.*[ \t\n])?" + THREAD_ID + "[ \t\n].*$")) { + throw new IllegalArgumentException( + "The 'conversation-selection' cannot contain '" + THREAD_ID + "': `" + messageSelection + "`"); + } + + conversationSortOrder = getSortOrder(conversationSortOrder, conversationOffset, conversationLimit); + messageSortOrder = getSortOrder(messageSortOrder, messageOffset, messageLimit); + + int index; + try (Cursor conversationCursor = cr.query(Conversations.CONTENT_URI, + null, conversationSelection, null , conversationSortOrder)) { + int conversationCount = conversationCursor.getCount(); + if (conversationReturnNoOrderReverse) { + conversationCursor.moveToFirst(); + } else { + conversationCursor.moveToLast(); + } + + Map nameCache = new HashMap<>(); + + if (conversationReturnNestedView) { + out.beginObject(); + } else { + out.beginArray(); + } + for (int i = 0; i < conversationCount; i++) { + index = conversationCursor.getColumnIndex(THREAD_ID); + if (index < 0) { + conversationCursor.moveToPrevious(); + continue; + } + + int id = conversationCursor.getInt(index); + + if (conversationReturnNestedView) { + out.name(String.valueOf(id)); + out.beginArray(); + } + + String[] messageSelectionArgs = null; + if (messageSelection == null || messageSelection.isEmpty()) { + messageSelection = ""; + } else { + messageSelection += " "; + } + + Cursor messageCursor = cr.query(Sms.CONTENT_URI, null, + messageSelection + THREAD_ID + " == '" + id +"'", messageSelectionArgs, + messageSortOrder); + + int messageCount = messageCursor.getCount(); + if (messageCount > 0) { + if (conversationReturnMultipleMessages) { + if (messageReturnNoOrderReverse) { + messageCursor.moveToFirst(); + } else { + messageCursor.moveToLast(); + } + + for (int j = 0; j < messageCount; j++) { + writeElement(messageCursor, out, nameCache, context); + + if (messageReturnNoOrderReverse) { + messageCursor.moveToNext(); + } else { + messageCursor.moveToPrevious(); + } + } + } else { + messageCursor.moveToFirst(); + writeElement(messageCursor, out, nameCache, context); + } + } + + messageCursor.close(); + + if (conversationReturnNestedView) { + out.endArray(); + } + + if (conversationReturnNoOrderReverse) { + conversationCursor.moveToNext(); + } else { + conversationCursor.moveToPrevious(); + } + } + if (conversationReturnNestedView) { + out.endObject(); + } else { + out.endArray(); + } + } + } + + @SuppressLint("SimpleDateFormat") + private static void writeElement(Cursor c, JsonWriter out, Map nameCache, Context context) throws IOException { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + int index; + int threadID = c.getInt(c.getColumnIndexOrThrow(THREAD_ID)); + String smsAddress = c.getString(c.getColumnIndexOrThrow(ADDRESS)); + String smsBody = c.getString(c.getColumnIndexOrThrow(BODY)); + long smsReceivedDate = c.getLong(c.getColumnIndexOrThrow(DATE)); + // long smsSentDate = c.getLong(c.getColumnIndexOrThrow(TextBasedSmsColumns.DATE_SENT)); + int smsID = c.getInt(c.getColumnIndexOrThrow("_id")); + + String smsSenderName = getContactNameFromNumber(nameCache, context, smsAddress); + String messageType = getMessageType(c.getInt(c.getColumnIndexOrThrow(TYPE))); + + out.beginObject(); + out.name("threadid").value(threadID); + out.name("type").value(messageType); + + index = c.getColumnIndex(READ); + if (index >= 0) { + out.name("read").value(c.getInt(index) != 0); + } + + if (smsSenderName != null) { + if (messageType.equals("inbox")) { + out.name("sender").value(smsSenderName); + } else { + out.name("sender").value("You"); + } + } + + out.name("address").value(smsAddress); + // Deprecated: Address can be a name like service provider instead of a number. + out.name("number").value(smsAddress); + + out.name("received").value(dateFormat.format(new Date(smsReceivedDate))); + // if (Math.abs(smsReceivedDate - smsSentDate) >= 60000) { + // out.write(" (sent "); + // out.write(dateFormat.format(new Date(smsSentDate))); + // out.write(")"); + // } + out.name("body").value(smsBody); + out.name("_id").value(smsID); + + out.endObject(); + } + + + @SuppressLint("SimpleDateFormat") + public static void getAllSms(Context context, JsonWriter out, + Uri contentURI, + int messageOffset, int messageLimit, + String messageSelection, String messageAddress, + String messageSortOrder, + boolean messageReturnNoOrderReverse) throws IOException { + ContentResolver cr = context.getContentResolver(); + + String[] messageSelectionArgs = null; + if (messageSelection == null || messageSelection.isEmpty()) { + if (messageAddress != null && !messageAddress.isEmpty()) { + messageSelection = ADDRESS + " LIKE ?"; + messageSelectionArgs = new String[]{messageAddress}; + } + } + + messageSortOrder = getSortOrder(messageSortOrder, messageOffset, messageLimit); + + try (Cursor messageCursor = cr.query(contentURI, null, + messageSelection, messageSelectionArgs, + messageSortOrder)) { + int messageCount = messageCursor.getCount(); + if (messageReturnNoOrderReverse) { + messageCursor.moveToFirst(); + } else { + messageCursor.moveToLast(); + } + + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Map nameCache = new HashMap<>(); + + out.beginArray(); + for (int i = 0; i < messageCount; i++) { + writeElement(messageCursor, out, nameCache, context); + + if (messageReturnNoOrderReverse) { + messageCursor.moveToNext(); + } else { + messageCursor.moveToPrevious(); + } + } + out.endArray(); + } + } + + private static String getContactNameFromNumber(Map cache, Context context, String number) { + if (cache.containsKey(number)) { + return cache.get(number); + } + + int index; + Uri contactUri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); + try (Cursor c = context.getContentResolver().query(contactUri, DISPLAY_NAME_PROJECTION, null, null, null)) { + String name = null; + if (c.moveToFirst()) { + index = c.getColumnIndex(PhoneLookup.DISPLAY_NAME); + if (index >= 0) { + name = c.getString(index); + } + } + + cache.put(number, name); + return name; + } + } + + private static String getMessageType(int type) { + switch (type) + { + case TextBasedSmsColumns.MESSAGE_TYPE_INBOX: + return "inbox"; + case TextBasedSmsColumns.MESSAGE_TYPE_SENT: + return "sent"; + case TextBasedSmsColumns.MESSAGE_TYPE_DRAFT: + return "draft"; + case TextBasedSmsColumns.MESSAGE_TYPE_FAILED: + return "failed"; + case TextBasedSmsColumns.MESSAGE_TYPE_OUTBOX: + return "outbox"; + default: + return ""; + } + } + + private static Uri typeToContentURI(int type) { + switch (type) { + case TextBasedSmsColumns.MESSAGE_TYPE_SENT: + return Sms.Sent.CONTENT_URI; + case TextBasedSmsColumns.MESSAGE_TYPE_DRAFT: + return Sms.Draft.CONTENT_URI; + case TextBasedSmsColumns.MESSAGE_TYPE_OUTBOX: + return Sms.Outbox.CONTENT_URI; + case TextBasedSmsColumns.MESSAGE_TYPE_INBOX: + return Sms.Inbox.CONTENT_URI; + case TextBasedSmsColumns.MESSAGE_TYPE_ALL: + default: + return Sms.CONTENT_URI; + } + } + + @Nullable + private static String getSortOrder(String sortOrder, int offset, int limit) { + if (sortOrder == null) { + sortOrder = ""; + } + if (limit >= 0) { + sortOrder += " LIMIT " + limit; + } + if (offset >= 0) { + sortOrder += " OFFSET " + offset; + } + if (sortOrder.isEmpty()) { + sortOrder = null; + } + return sortOrder; + } + +} diff --git a/app/src/main/java/com/termux/api/SmsSendAPI.java b/app/src/main/java/com/termux/api/apis/SmsSendAPI.java similarity index 72% rename from app/src/main/java/com/termux/api/SmsSendAPI.java rename to app/src/main/java/com/termux/api/apis/SmsSendAPI.java index 78fdce455..c7d34862d 100644 --- a/app/src/main/java/com/termux/api/SmsSendAPI.java +++ b/app/src/main/java/com/termux/api/apis/SmsSendAPI.java @@ -1,21 +1,30 @@ -package com.termux.api; +package com.termux.api.apis; +import android.Manifest; import android.content.Context; import android.content.Intent; import android.telephony.SmsManager; import android.telephony.SubscriptionManager; import android.telephony.SubscriptionInfo; +import androidx.annotation.RequiresPermission; + +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.io.PrintWriter; import java.util.ArrayList; public class SmsSendAPI { - static void onReceive(TermuxApiReceiver apiReceiver, Context context, final Intent intent) { + private static final String LOG_TAG = "SmsSendAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.WithStringInput() { + @RequiresPermission(allOf = { Manifest.permission.READ_PHONE_STATE, Manifest.permission.SEND_SMS }) @Override public void writeResult(PrintWriter out) { final SmsManager smsManager = getSmsManager(context,intent); @@ -30,7 +39,7 @@ public void writeResult(PrintWriter out) { } if (recipients == null || recipients.length == 0) { - TermuxApiLogger.error("No recipient given"); + Logger.logError(LOG_TAG, "No recipient given"); } else { final ArrayList messages = smsManager.divideMessage(inputString); for (String recipient : recipients) { @@ -41,6 +50,7 @@ public void writeResult(PrintWriter out) { }); } + @RequiresPermission(Manifest.permission.READ_PHONE_STATE) static SmsManager getSmsManager(Context context, final Intent intent) { int slot = intent.getIntExtra("slot", -1); if(slot == -1) { @@ -48,7 +58,7 @@ static SmsManager getSmsManager(Context context, final Intent intent) { } else { SubscriptionManager sm = context.getSystemService(SubscriptionManager.class); if(sm == null) { - TermuxApiLogger.error("SubscriptionManager not supported"); + Logger.logError(LOG_TAG, "SubscriptionManager not supported"); return null; } for(SubscriptionInfo si: sm.getActiveSubscriptionInfoList()) { @@ -56,7 +66,7 @@ static SmsManager getSmsManager(Context context, final Intent intent) { return SmsManager.getSmsManagerForSubscriptionId(si.getSubscriptionId()); } } - TermuxApiLogger.error("Sim slot "+slot+" not found"); + Logger.logError(LOG_TAG, "Sim slot "+slot+" not found"); return null; } } diff --git a/app/src/main/java/com/termux/api/SpeechToTextAPI.java b/app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java similarity index 90% rename from app/src/main/java/com/termux/api/SpeechToTextAPI.java rename to app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java index 483e5c125..c33d7a31a 100644 --- a/app/src/main/java/com/termux/api/SpeechToTextAPI.java +++ b/app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Activity; import android.app.AlertDialog; @@ -14,7 +14,8 @@ import android.speech.SpeechRecognizer; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; import java.io.PrintWriter; import java.util.List; @@ -22,6 +23,8 @@ public class SpeechToTextAPI { + private static final String LOG_TAG = "SpeechToTextAPI"; + public static class SpeechToTextService extends IntentService { private static final String STOP_ELEMENT = ""; @@ -37,8 +40,12 @@ public SpeechToTextService(String name) { protected SpeechRecognizer mSpeechRecognizer; final LinkedBlockingQueue queueu = new LinkedBlockingQueue<>(); + private static final String LOG_TAG = "SpeechToTextService"; + @Override public void onCreate() { + Logger.logDebug(LOG_TAG, "onCreate"); + super.onCreate(); final Context context = this; @@ -53,7 +60,7 @@ public void onRmsChanged(float rmsdB) { @Override public void onResults(Bundle results) { List recognitions = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); - TermuxApiLogger.error("RecognitionListener#onResults(" + recognitions + ")"); + Logger.logError(LOG_TAG, "RecognitionListener#onResults(" + recognitions + ")"); queueu.addAll(recognitions); } @@ -66,7 +73,7 @@ public void onReadyForSpeech(Bundle params) { public void onPartialResults(Bundle partialResults) { // Do nothing. List strings = partialResults.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); - TermuxApiLogger.error("RecognitionListener#onPartialResults(" + strings + ")"); + Logger.logError(LOG_TAG, "RecognitionListener#onPartialResults(" + strings + ")"); queueu.addAll(strings); } @@ -94,13 +101,13 @@ public void onError(int error) { default: description = Integer.toString(error); } - TermuxApiLogger.error("RecognitionListener#onError(" + description + ")"); + Logger.logError(LOG_TAG, "RecognitionListener#onError(" + description + ")"); queueu.add(STOP_ELEMENT); } @Override public void onEndOfSpeech() { - TermuxApiLogger.error("RecognitionListener#onEndOfSpeech()"); + Logger.logError(LOG_TAG, "RecognitionListener#onEndOfSpeech()"); queueu.add(STOP_ELEMENT); } @@ -145,14 +152,16 @@ public void onBeginningOfSpeech() { @Override public void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + super.onDestroy(); - TermuxApiLogger.error("onDestroy"); mSpeechRecognizer.destroy(); } @Override protected void onHandleIntent(final Intent intent) { - TermuxApiLogger.error("onHandleIntent"); + Logger.logDebug(LOG_TAG, "onHandleIntent:\n" + IntentUtils.getIntentString(intent)); + ResultReturner.returnData(this, intent, new ResultReturner.WithInput() { @Override public void writeResult(PrintWriter out) throws Exception { @@ -171,6 +180,8 @@ public void writeResult(PrintWriter out) throws Exception { } public static void onReceive(final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + context.startService(new Intent(context, SpeechToTextService.class).putExtras(intent.getExtras())); } diff --git a/app/src/main/java/com/termux/api/StorageGetAPI.java b/app/src/main/java/com/termux/api/apis/StorageGetAPI.java similarity index 53% rename from app/src/main/java/com/termux/api/StorageGetAPI.java rename to app/src/main/java/com/termux/api/apis/StorageGetAPI.java index 2cd769980..2101c8cf1 100644 --- a/app/src/main/java/com/termux/api/StorageGetAPI.java +++ b/app/src/main/java/com/termux/api/apis/StorageGetAPI.java @@ -1,14 +1,22 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.Nullable; + +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.errors.Error; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.file.TermuxFileUtils; -import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -16,18 +24,34 @@ public class StorageGetAPI { - private static final String FILE_EXTRA = "com.termux.api.storage.file"; + private static final String FILE_EXTRA = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".storage.file"; + + private static final String LOG_TAG = "StorageGetAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); - static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { ResultReturner.returnData(apiReceiver, intent, out -> { final String fileExtra = intent.getStringExtra("file"); - if (fileExtra == null || !new File(fileExtra).getParentFile().canWrite()) { - out.println("ERROR: Not a writable folder: " + fileExtra); + if (fileExtra == null || fileExtra.isEmpty()) { + out.println("ERROR: " + "File path not passed"); + + return; + } + + // Get canonical path of fileExtra + String filePath = TermuxFileUtils.getCanonicalPath(fileExtra, null, true); + String fileParentDirPath = FileUtils.getFileDirname(filePath); + Logger.logVerbose(LOG_TAG, "filePath=\"" + filePath + "\", fileParentDirPath=\"" + fileParentDirPath + "\""); + + Error error = FileUtils.checkMissingFilePermissions("file parent directory", fileParentDirPath, "rw-", true); + if (error != null) { + out.println("ERROR: " + error.getErrorLogString()); return; } Intent intent1 = new Intent(context, StorageActivity.class); - intent1.putExtra(FILE_EXTRA, fileExtra); + intent1.putExtra(FILE_EXTRA, filePath); intent1.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent1); }); @@ -37,8 +61,19 @@ public static class StorageActivity extends Activity { private String outputFile; + private static final String LOG_TAG = "StorageActivity"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(savedInstanceState); + } + @Override public void onResume() { + Logger.logVerbose(LOG_TAG, "onResume"); + super.onResume(); outputFile = getIntent().getStringExtra(FILE_EXTRA); @@ -56,6 +91,8 @@ public void onResume() { @Override protected void onActivityResult(int requestCode, int resultCode, Intent resultData) { + Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(resultData)); + super.onActivityResult(requestCode, resultCode, resultData); if (resultCode == RESULT_OK) { Uri data = resultData.getData(); @@ -74,7 +111,7 @@ protected void onActivityResult(int requestCode, int resultCode, Intent resultDa } } } catch (IOException e) { - TermuxApiLogger.error("Error copying " + data + " to " + outputFile); + Logger.logStackTraceWithMessage(LOG_TAG, "Error copying " + data + " to " + outputFile, e); } } finish(); diff --git a/app/src/main/java/com/termux/api/TelephonyAPI.java b/app/src/main/java/com/termux/api/apis/TelephonyAPI.java similarity index 76% rename from app/src/main/java/com/termux/api/TelephonyAPI.java rename to app/src/main/java/com/termux/api/apis/TelephonyAPI.java index f44dcea43..6a751e6c8 100644 --- a/app/src/main/java/com/termux/api/TelephonyAPI.java +++ b/app/src/main/java/com/termux/api/apis/TelephonyAPI.java @@ -1,5 +1,6 @@ -package com.termux.api; +package com.termux.api.apis; +import android.Manifest; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; @@ -10,11 +11,18 @@ import android.telephony.CellInfoGsm; import android.telephony.CellInfoLte; import android.telephony.CellInfoWcdma; +import android.telephony.CellInfoNr; +import android.telephony.CellIdentityNr; +import android.telephony.CellSignalStrength; +import android.telephony.CellSignalStrengthNr; import android.telephony.TelephonyManager; import android.util.JsonWriter; -import android.util.Log; +import androidx.annotation.RequiresPermission; + +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; import java.io.IOException; @@ -25,11 +33,27 @@ */ public class TelephonyAPI { + private static final String LOG_TAG = "TelephonyAPI"; + private static void writeIfKnown(JsonWriter out, String name, int value) throws IOException { if (value != Integer.MAX_VALUE) out.name(name).value(value); } + private static void writeIfKnown(JsonWriter out, String name, long value) throws IOException { + if (value != Long.MAX_VALUE) out.name(name).value(value); + } + private static void writeIfKnown(JsonWriter out, String name, int[] value) throws IOException { + if (value != null) { + out.name(name); + out.beginArray(); + for (int i = 0; i < value.length; i++) out.value(value[i]); + out.endArray(); + + } + } + + public static void onReceiveTelephonyCellInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveTelephonyCellInfo"); - static void onReceiveTelephonyCellInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { @@ -76,6 +100,46 @@ public void writeJson(JsonWriter out) throws Exception { writeIfKnown(out, "tac", lteInfo.getCellIdentity().getTac()); writeIfKnown(out, "mcc", lteInfo.getCellIdentity().getMcc()); writeIfKnown(out, "mnc", lteInfo.getCellIdentity().getMnc()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + writeIfKnown(out, "rsrp", lteInfo.getCellSignalStrength().getRsrp()); + writeIfKnown(out, "rsrq", lteInfo.getCellSignalStrength().getRsrq()); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + writeIfKnown(out, "rssi", lteInfo.getCellSignalStrength().getRssi()); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + writeIfKnown(out, "bands", lteInfo.getCellIdentity().getBands()); + } + } else if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) && (cellInfo instanceof CellInfoNr)) { + CellInfoNr nrInfo = (CellInfoNr) cellInfo; + CellIdentityNr nrcellIdent = (CellIdentityNr) nrInfo.getCellIdentity(); + CellSignalStrength ssInfo = nrInfo.getCellSignalStrength(); + out.name("type").value("nr"); + out.name("registered").value(cellInfo.isRegistered()); + + out.name("asu").value(ssInfo.getAsuLevel()); + out.name("dbm").value(ssInfo.getDbm()); + writeIfKnown(out, "level", ssInfo.getLevel()); + writeIfKnown(out, "nci", nrcellIdent.getNci()); + writeIfKnown(out, "pci", nrcellIdent.getPci()); + writeIfKnown(out, "tac", nrcellIdent.getTac()); + out.name("mcc").value(nrcellIdent.getMccString()); + out.name("mnc").value(nrcellIdent.getMncString()); + if (ssInfo instanceof CellSignalStrengthNr) { + CellSignalStrengthNr nrssInfo = (CellSignalStrengthNr) ssInfo; + writeIfKnown(out, "csi_rsrp", nrssInfo.getCsiRsrp()); + writeIfKnown(out, "csi_rsrq", nrssInfo.getCsiRsrq()); + writeIfKnown(out, "csi_sinr", nrssInfo.getCsiSinr()); + writeIfKnown(out, "ss_rsrp", nrssInfo.getSsRsrp()); + writeIfKnown(out, "ss_rsrq", nrssInfo.getSsRsrq()); + writeIfKnown(out, "ss_sinr", nrssInfo.getSsSinr()); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + writeIfKnown(out, "bands", nrcellIdent.getBands()); + } } else if (cellInfo instanceof CellInfoCdma) { CellInfoCdma cdmaInfo = (CellInfoCdma) cellInfo; out.name("type").value("cdma"); @@ -121,9 +185,11 @@ public void writeJson(JsonWriter out) throws Exception { }); } + public static void onReceiveTelephonyDeviceInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveTelephonyDeviceInfo"); - static void onReceiveTelephonyDeviceInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @RequiresPermission(Manifest.permission.READ_PHONE_STATE) @SuppressLint("HardwareIds") @Override public void writeJson(JsonWriter out) throws Exception { @@ -185,10 +251,13 @@ public void writeJson(JsonWriter out) throws Exception { String device_id = null; try { - device_id = phoneType == TelephonyManager.PHONE_TYPE_GSM ? manager.getImei() : manager.getMeid(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + device_id = phoneType == TelephonyManager.PHONE_TYPE_GSM ? manager.getImei() : manager.getMeid(); + } } catch (SecurityException e) { // Failed to obtain device id. - // Android 10+. + // Android 10+ requires READ_PRIVILEGED_PHONE_STATE + // https://source.android.com/devices/tech/config/device-identifiers } out.name("device_id").value(device_id); @@ -269,6 +338,10 @@ public void writeJson(JsonWriter out) throws Exception { networkTypeName = "unknown"; break; default: + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) && (networkType == TelephonyManager.NETWORK_TYPE_NR)) { + networkTypeName = "nr"; + break; + } networkTypeName = Integer.toString(networkType); break; } @@ -323,11 +396,14 @@ public void writeJson(JsonWriter out) throws Exception { }); } - static void onReceiveTelephonyCall(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceiveTelephonyCall(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveTelephonyCall"); + String numberExtra = intent.getStringExtra("number"); if (numberExtra == null) { - Log.e("termux-api", "No 'number extra"); + Logger.logError(LOG_TAG, "No 'number' extra"); ResultReturner.noteDone(apiReceiver, intent); + return; } if(numberExtra.contains("#")) @@ -342,7 +418,7 @@ static void onReceiveTelephonyCall(TermuxApiReceiver apiReceiver, final Context try { context.startActivity(callIntent); } catch (SecurityException e) { - Log.e("termux-api", "Exception in phone call", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Exception in phone call", e); } ResultReturner.noteDone(apiReceiver, intent); diff --git a/app/src/main/java/com/termux/api/TextToSpeechAPI.java b/app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java similarity index 87% rename from app/src/main/java/com/termux/api/TextToSpeechAPI.java rename to app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java index 4fea86ba7..2dae2dd1c 100644 --- a/app/src/main/java/com/termux/api/TextToSpeechAPI.java +++ b/app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.IntentService; import android.content.Context; @@ -12,7 +12,8 @@ import android.util.JsonWriter; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; import java.io.BufferedReader; import java.io.InputStreamReader; @@ -24,7 +25,11 @@ public class TextToSpeechAPI { + private static final String LOG_TAG = "TextToSpeechAPI"; + public static void onReceive(final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + context.startService(new Intent(context, TextToSpeechService.class).putExtras(intent.getExtras())); } @@ -32,12 +37,23 @@ public static class TextToSpeechService extends IntentService { TextToSpeech mTts; final CountDownLatch mTtsLatch = new CountDownLatch(1); + private static final String LOG_TAG = "TextToSpeechService"; + public TextToSpeechService() { super(TextToSpeechService.class.getName()); } + @Override + public void onCreate() { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(); + } + @Override public void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + if (mTts != null) mTts.shutdown(); super.onDestroy(); @@ -45,6 +61,8 @@ public void onDestroy() { @Override protected void onHandleIntent(final Intent intent) { + Logger.logDebug(LOG_TAG, "onHandleIntent:\n" + IntentUtils.getIntentString(intent)); + final String speechLanguage = intent.getStringExtra("language"); final String speechRegion = intent.getStringExtra("region"); final String speechVariant = intent.getStringExtra("variant"); @@ -83,7 +101,7 @@ protected void onHandleIntent(final Intent intent) { if (status == TextToSpeech.SUCCESS) { mTtsLatch.countDown(); } else { - TermuxApiLogger.error("Failed tts initialization: status=" + status); + Logger.logError(LOG_TAG, "Failed tts initialization: status=" + status); stopSelf(); } }, speechEngine); @@ -95,11 +113,11 @@ public void writeResult(PrintWriter out) { try { try { if (!mTtsLatch.await(10, TimeUnit.SECONDS)) { - TermuxApiLogger.error("Timeout waiting for TTS initialization"); + Logger.logError(LOG_TAG, "Timeout waiting for TTS initialization"); return; } } catch (InterruptedException e) { - TermuxApiLogger.error("Interrupted awaiting TTS initialization"); + Logger.logError(LOG_TAG, "Interrupted awaiting TTS initialization"); return; } @@ -131,7 +149,7 @@ public void onStart(String utteranceId) { @Override public void onError(String utteranceId) { - TermuxApiLogger.error("UtteranceProgressListener.onError() called"); + Logger.logError(LOG_TAG, "UtteranceProgressListener.onError() called"); synchronized (ttsDoneUtterancesCount) { ttsDoneUtterancesCount.incrementAndGet(); ttsDoneUtterancesCount.notify(); @@ -150,7 +168,7 @@ public void onDone(String utteranceId) { if (speechLanguage != null) { int setLanguageResult = mTts.setLanguage(getLocale(speechLanguage, speechRegion, speechVariant)); if (setLanguageResult != TextToSpeech.LANG_AVAILABLE) { - TermuxApiLogger.error("tts.setLanguage('" + speechLanguage + "') returned " + setLanguageResult); + Logger.logError(LOG_TAG, "tts.setLanguage('" + speechLanguage + "') returned " + setLanguageResult); } } @@ -180,7 +198,7 @@ public void onDone(String utteranceId) { } } } catch (Exception e) { - TermuxApiLogger.error("TTS error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "TTS error", e); } } }); diff --git a/app/src/main/java/com/termux/api/ToastAPI.java b/app/src/main/java/com/termux/api/apis/ToastAPI.java similarity index 89% rename from app/src/main/java/com/termux/api/ToastAPI.java rename to app/src/main/java/com/termux/api/apis/ToastAPI.java index 5221b3561..96ea2ea03 100644 --- a/app/src/main/java/com/termux/api/ToastAPI.java +++ b/app/src/main/java/com/termux/api/apis/ToastAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.Context; import android.content.Intent; @@ -11,13 +11,17 @@ import android.widget.Toast; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.io.PrintWriter; public class ToastAPI { + private static final String LOG_TAG = "ToastAPI"; + public static void onReceive(final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + final int durationExtra = intent.getBooleanExtra("short", false) ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG; final int backgroundColor = getColorExtra(intent, "background", Color.GRAY); final int textColor = getColorExtra(intent, "text_color", Color.WHITE); @@ -54,7 +58,7 @@ protected static int getColorExtra(Intent intent, String extra, int defaultColor try { color = Color.parseColor(colorExtra); } catch (IllegalArgumentException e) { - TermuxApiLogger.error(String.format("Failed to parse color '%s' for '%s'", colorExtra, extra)); + Logger.logError(LOG_TAG, String.format("Failed to parse color '%s' for '%s'", colorExtra, extra)); } } return color; diff --git a/app/src/main/java/com/termux/api/TorchAPI.java b/app/src/main/java/com/termux/api/apis/TorchAPI.java similarity index 85% rename from app/src/main/java/com/termux/api/TorchAPI.java rename to app/src/main/java/com/termux/api/apis/TorchAPI.java index 1ddb35bc5..1e0867c50 100644 --- a/app/src/main/java/com/termux/api/TorchAPI.java +++ b/app/src/main/java/com/termux/api/apis/TorchAPI.java @@ -1,22 +1,25 @@ -package com.termux.api; +package com.termux.api.apis; -import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; import android.hardware.Camera; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraManager; -import android.os.Build; import android.widget.Toast; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; public class TorchAPI { private static Camera legacyCamera; + private static final String LOG_TAG = "TorchAPI"; + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + boolean enabled = intent.getBooleanExtra("enabled", false); toggleTorch(context, enabled); @@ -34,12 +37,12 @@ private static void toggleTorch(Context context, boolean enabled) { Toast.makeText(context, "Torch unavailable on your device", Toast.LENGTH_LONG).show(); } } catch (CameraAccessException e) { - TermuxApiLogger.error("Error toggling torch", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Error toggling torch", e); } } private static void legacyToggleTorch(boolean enabled) { - TermuxApiLogger.info("Using legacy camera api to toggle torch"); + Logger.logInfo(LOG_TAG, "Using legacy camera api to toggle torch"); if (legacyCamera == null) { legacyCamera = Camera.open(); diff --git a/app/src/main/java/com/termux/api/apis/UsbAPI.java b/app/src/main/java/com/termux/api/apis/UsbAPI.java new file mode 100644 index 000000000..0d9257731 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/UsbAPI.java @@ -0,0 +1,338 @@ +package com.termux.api.apis; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbManager; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.util.JsonWriter; +import android.util.SparseArray; + +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.HashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import androidx.annotation.NonNull; + +public class UsbAPI { + + protected static final String LOG_TAG = "UsbAPI"; + + protected static SparseArray openDevices = new SparseArray<>(); + + protected static final String ACTION_USB_PERMISSION = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".USB_PERMISSION"; + + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + Intent serviceIntent = new Intent(context, UsbService.class); + serviceIntent.setAction(intent.getAction()); + Bundle extras = intent.getExtras(); + if (extras != null) + serviceIntent.putExtras(extras); + context.startService(serviceIntent); + } + + public static class UsbService extends Service { + + protected static final String LOG_TAG = "UsbService"; + + private final ThreadPoolExecutor mThreadPoolExecutor; + + public UsbService() { + super(); + mThreadPoolExecutor = new ThreadPoolExecutor(1, 1, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>()); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + public void onCreate() { + Logger.logDebug(LOG_TAG, "onCreate"); + + super.onCreate(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + + String action = intent.getAction(); + if (action == null) { + Logger.logError(LOG_TAG, "No action passed"); + ResultReturner.returnData(this, intent, out -> out.append("Missing action\n")); + } + + if (action != null) { + switch (action) { + case "list": + runListAction(intent); + break; + case "permission": + runPermissionAction(intent); + break; + case "open": + runOpenAction(intent); + break; + default: + Logger.logError(LOG_TAG, "Invalid action: \"" + action + "\""); + ResultReturner.returnData(this, intent, out -> out.append("Invalid action: \"" + action + "\"\n")); + } + } + + return Service.START_NOT_STICKY; + } + + @Override + public void onDestroy() { + Logger.logDebug(LOG_TAG, "onDestroy"); + + super.onDestroy(); + } + + + + protected void runListAction(Intent intent) { + Logger.logVerbose(LOG_TAG,"Running 'list' usb devices action"); + + ResultReturner.returnData(this, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + listDevices(out); + } + }); + } + + protected void listDevices(JsonWriter out) throws IOException { + UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); + HashMap deviceList = usbManager.getDeviceList(); + out.beginArray(); + for (String deviceName : deviceList.keySet()) { + out.value(deviceName); + } + out.endArray(); + } + + + + protected void runPermissionAction(Intent intent) { + mThreadPoolExecutor.submit(() -> { + String deviceName = intent.getStringExtra("device"); + + Logger.logVerbose(LOG_TAG,"Running 'permission' action for device \"" + deviceName + "\""); + + UsbDevice device = getDevice(intent, deviceName); + if (device == null) return; + + int status = checkAndRequestUsbDevicePermission(intent, device); + ResultReturner.returnData(this, intent, out -> { + if (status == 0) { + Logger.logVerbose(LOG_TAG, "Permission granted for device \"" + device.getDeviceName() + "\""); + out.append("Permission granted.\n" ); + } else if (status == 1) { + Logger.logVerbose(LOG_TAG, "Permission denied for device \"" + device.getDeviceName() + "\""); + out.append("Permission denied.\n" ); + } else if (status == -1) { + out.append("Permission request timeout.\n" ); + } + }); + }); + } + + + + protected void runOpenAction(Intent intent) { + mThreadPoolExecutor.submit(() -> { + String deviceName = intent.getStringExtra("device"); + + Logger.logVerbose(LOG_TAG,"Running 'open' action for device \"" + deviceName + "\""); + + UsbDevice device = getDevice(intent, deviceName); + if (device == null) return; + + int status = checkAndRequestUsbDevicePermission(intent, device); + ResultReturner.returnData(this, intent, new ResultReturner.WithAncillaryFd() { + @Override + public void writeResult(PrintWriter out) { + if (status == 0) { + int fd = open(device); + if (fd < 0) { + Logger.logVerbose(LOG_TAG, "Failed to open device \"" + device.getDeviceName() + "\": " + fd); + out.append("Open device failed.\n"); + } else { + Logger.logVerbose(LOG_TAG, "Open device \"" + device.getDeviceName() + "\" successful"); + this.sendFd(out, fd); + } + } else if (status == 1) { + Logger.logVerbose(LOG_TAG, "Permission denied to open device \"" + device.getDeviceName() + "\""); + out.append("Permission denied.\n" ); + } else if (status == -1) { + out.append("Permission request timeout.\n" ); + } + } + }); + }); + } + + protected int open(@NonNull UsbDevice device) { + UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); + + UsbDeviceConnection connection = usbManager.openDevice(device); + if (connection == null) return -2; + + int fd = connection.getFileDescriptor(); + if (fd == -1) { + connection.close(); + return -1; + } + + openDevices.put(fd, connection); + return fd; + } + + + + protected UsbDevice getDevice(Intent intent, String deviceName) { + UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); + + HashMap deviceList = usbManager.getDeviceList(); + UsbDevice device = deviceList.get(deviceName); + if (device == null) { + Logger.logVerbose(LOG_TAG, "Failed to find device \"" + deviceName + "\""); + ResultReturner.returnData(this, intent, out -> out.append("No such device.\n")); + } + + return device; + } + + + + protected boolean checkUsbDevicePermission(@NonNull UsbDevice device) { + UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); + return usbManager.hasPermission(device); + } + + protected int checkAndRequestUsbDevicePermission(Intent intent, @NonNull UsbDevice device) { + boolean checkResult = checkUsbDevicePermission(device); + Logger.logVerbose(LOG_TAG, "Permission check result for device \"" + device.getDeviceName() + "\": " + checkResult); + if (checkResult) { + return 0; + } + + if(!intent.getBooleanExtra("request", false)) { + return 1; + } + + Logger.logVerbose(LOG_TAG, "Requesting permission for device \"" + device.getDeviceName() + "\""); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + + BroadcastReceiver usbReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent usbIntent) { + if (ACTION_USB_PERMISSION.equals(usbIntent.getAction())) { + boolean requestResult = usbIntent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); + Logger.logVerbose(LOG_TAG, "Permission request result for device \"" + device.getDeviceName() + "\": " + requestResult); + result.set(requestResult); + } + context.unregisterReceiver(this); + latch.countDown(); + } + }; + + UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); + + Intent usbIntent = new Intent(ACTION_USB_PERMISSION); + // Use explicit intent, otherwise permission request intent will be blocked if intent is + // mutable and app uses `targetSdkVersion` `>= 34`, or following exception will be logged + // to logcat if app uses `targetSdkVersion` `< 34`. + // > `android.app.StackTrace: New mutable implicit PendingIntent: pkg=com.termux.api, + // > action=com.termux.api.USB_PERMISSION, featureId=null. This will be blocked once the + // > app targets U+ for security reasons.` + // - https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents + usbIntent.setPackage(getPackageName()); + + // Use mutable intent, otherwise permission request intent will be blocked if app + // uses `targetSdkVersion` `>= 31` and following exception may be logged to logcat. + // > java.lang.IllegalArgumentException: com.termux.api: Targeting S+ (version 31 and above) + // > requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent. + // > Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality + // > depends on the PendingIntent being mutable, e.g. if it needs to be used with inline + // > replies or bubbles. + // The intent must not be immutable as the `EXTRA_PERMISSION_GRANTED` extra needs to be + // returned by the Android framework. Otherwise, if requesting permission after + // reattaching device, and user presses `OK` to grant permission, the + // `EXTRA_PERMISSION_GRANTED` extra would not exist in the intent, and default `false` + // value would get used, and `No permission` condition of the open request would get + // triggered, even though permission was granted and it won't need to be requested for + // next open request. + // - https://developer.android.com/about/versions/12/behavior-changes-12#pending-intent-mutability + //noinspection ObsoleteSdkInt + int pendingIntentFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_MUTABLE : 0; + PendingIntent permissionIntent = PendingIntent.getBroadcast(this, 0, usbIntent, pendingIntentFlags); + + try { + // Specify flag to not export receiver, otherwise permission request intent will be + // blocked if app uses `targetSdkVersion` `>= 34`. + // - https://developer.android.com/about/versions/14/behavior-changes-14#runtime-receivers-exported + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(usbReceiver, new IntentFilter(ACTION_USB_PERMISSION), + Context.RECEIVER_NOT_EXPORTED); + } else { + //noinspection UnspecifiedRegisterReceiverFlag + registerReceiver(usbReceiver, new IntentFilter(ACTION_USB_PERMISSION)); + } + + // Request permission and wait. + usbManager.requestPermission(device, permissionIntent); + + try { + if (!latch.await(30L, TimeUnit.SECONDS)) { + Logger.logVerbose(LOG_TAG, "Permission request time out for device \"" + device.getDeviceName() + "\" after 30s"); + return -1; + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + Boolean requestResult = result.get(); + if (requestResult != null) { + usbReceiver = null; + return requestResult ? 0 : 1; + } else { + return 1; + } + } finally { + try { + if (usbReceiver != null) { + unregisterReceiver(usbReceiver); + } + } catch (Exception e) { + // Ignore + } + } + } + } + +} diff --git a/app/src/main/java/com/termux/api/apis/VibrateAPI.java b/app/src/main/java/com/termux/api/apis/VibrateAPI.java new file mode 100644 index 000000000..c2a69a51e --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/VibrateAPI.java @@ -0,0 +1,59 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +public class VibrateAPI { + + private static final String LOG_TAG = "VibrateAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + new Thread() { + @Override + public void run() { + Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + int milliseconds = intent.getIntExtra("duration_ms", 1000); + boolean force = intent.getBooleanExtra("force", false); + + AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + Logger.logError(LOG_TAG, "Audio service null"); + return; + } + + // Do not vibrate if "Silent" ringer mode or "Do Not Disturb" is enabled and -f/--force option is not used. + if (am.getRingerMode() != AudioManager.RINGER_MODE_SILENT || force) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(milliseconds, VibrationEffect.DEFAULT_AMPLITUDE), + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build()); + } else { + vibrator.vibrate(milliseconds); + } + } catch (Exception e) { + // Issue on samsung devices on android 8 + // java.lang.NullPointerException: Attempt to read from field 'android.os.VibrationEffect com.android.server.VibratorService$Vibration.mEffect' on a null object reference + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to run vibrator", e); + } + } + } + }.start(); + + ResultReturner.noteDone(apiReceiver, intent); + } + +} diff --git a/app/src/main/java/com/termux/api/VolumeAPI.java b/app/src/main/java/com/termux/api/apis/VolumeAPI.java similarity index 93% rename from app/src/main/java/com/termux/api/VolumeAPI.java rename to app/src/main/java/com/termux/api/apis/VolumeAPI.java index 6a7e4cbf4..6febb9d8f 100644 --- a/app/src/main/java/com/termux/api/VolumeAPI.java +++ b/app/src/main/java/com/termux/api/apis/VolumeAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.content.Context; import android.content.Intent; @@ -6,7 +6,9 @@ import android.util.JsonWriter; import android.util.SparseArray; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; import java.io.IOException; @@ -24,8 +26,11 @@ public class VolumeAPI { streamMap.append(AudioManager.STREAM_VOICE_CALL, "call"); } + private static final String LOG_TAG = "VolumeAPI"; + + public static void onReceive(final TermuxApiReceiver receiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); - static void onReceive(final TermuxApiReceiver receiver, final Context context, final Intent intent) { final AudioManager audioManager = (AudioManager)context.getSystemService(Context.AUDIO_SERVICE); String action = intent.getAction(); diff --git a/app/src/main/java/com/termux/api/WallpaperAPI.java b/app/src/main/java/com/termux/api/apis/WallpaperAPI.java similarity index 88% rename from app/src/main/java/com/termux/api/WallpaperAPI.java rename to app/src/main/java/com/termux/api/apis/WallpaperAPI.java index 0d6f960ba..3a8efe62c 100644 --- a/app/src/main/java/com/termux/api/WallpaperAPI.java +++ b/app/src/main/java/com/termux/api/apis/WallpaperAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.app.Service; import android.app.WallpaperManager; @@ -6,11 +6,10 @@ import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; -import android.os.Build; import android.os.IBinder; import com.termux.api.util.ResultReturner; -import com.termux.api.util.TermuxApiLogger; +import com.termux.shared.logger.Logger; import java.io.IOException; import java.io.InputStream; @@ -24,7 +23,11 @@ public class WallpaperAPI { - static void onReceive(final Context context, final Intent intent) { + private static final String LOG_TAG = "WallpaperAPI"; + + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + Intent wallpaperService = new Intent(context, WallpaperService.class); wallpaperService.putExtras(intent.getExtras()); context.startService(wallpaperService); @@ -38,8 +41,11 @@ static void onReceive(final Context context, final Intent intent) { public static class WallpaperService extends Service { protected static final int DOWNLOAD_TIMEOUT = 30; + private static final String LOG_TAG = "WallpaperService"; public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + if (intent.hasExtra("file")) { getWallpaperFromFile(intent); } else if (intent.hasExtra("url")) { @@ -72,7 +78,7 @@ protected void getWallpaperFromUrl(final Intent intent) { try { result = wallpaperDownload.get(DOWNLOAD_TIMEOUT, TimeUnit.SECONDS); } catch (InterruptedException e) { - TermuxApiLogger.info("Wallpaper download interrupted"); + Logger.logInfo(LOG_TAG, "Wallpaper download interrupted"); } catch (ExecutionException e) { result.error = "Unknown host!"; } catch (TimeoutException e) { @@ -116,13 +122,8 @@ protected void onWallpaperResult(final Intent intent, WallpaperResult result) { if (result.wallpaper != null) { try { - // allow setting of lock screen wallpaper for Nougat and later - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - int flag = intent.hasExtra("lockscreen") ? WallpaperManager.FLAG_LOCK : WallpaperManager.FLAG_SYSTEM; - wallpaperManager.setBitmap(result.wallpaper, null, true, flag); - } else { - wallpaperManager.setBitmap(result.wallpaper); - } + int flag = intent.hasExtra("lockscreen") ? WallpaperManager.FLAG_LOCK : WallpaperManager.FLAG_SYSTEM; + wallpaperManager.setBitmap(result.wallpaper, null, true, flag); result.message = "Wallpaper set successfully!"; } catch (IOException e) { result.error = "Error setting wallpaper: " + e.getMessage(); diff --git a/app/src/main/java/com/termux/api/WifiAPI.java b/app/src/main/java/com/termux/api/apis/WifiAPI.java similarity index 86% rename from app/src/main/java/com/termux/api/WifiAPI.java rename to app/src/main/java/com/termux/api/apis/WifiAPI.java index 797577c58..0708d9e69 100644 --- a/app/src/main/java/com/termux/api/WifiAPI.java +++ b/app/src/main/java/com/termux/api/apis/WifiAPI.java @@ -1,4 +1,4 @@ -package com.termux.api; +package com.termux.api.apis; import android.annotation.SuppressLint; import android.content.Context; @@ -7,18 +7,23 @@ import android.net.wifi.ScanResult; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; -import android.os.Build; import android.text.TextUtils; import android.text.format.Formatter; import android.util.JsonWriter; +import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; import java.util.List; public class WifiAPI { - static void onReceiveWifiConnectionInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + private static final String LOG_TAG = "WifiAPI"; + + public static void onReceiveWifiConnectionInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveWifiConnectionInfo"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @SuppressLint("HardwareIds") @Override @@ -51,7 +56,9 @@ static boolean isLocationEnabled(Context context) { return lm.isProviderEnabled(LocationManager.GPS_PROVIDER); } - static void onReceiveWifiScanInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceiveWifiScanInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveWifiScanInfo"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { @@ -98,6 +105,9 @@ public void writeJson(JsonWriter out) throws Exception { // centerFreq0 says "Not used if the AP bandwidth is 20 MHz". out.name("center_frequency_mhz").value(scan.centerFreq0); } + if (!TextUtils.isEmpty(scan.capabilities)) { + out.name("capabilities").value(scan.capabilities); + } if (!TextUtils.isEmpty(scan.operatorFriendlyName)) { out.name("operator_name").value(scan.operatorFriendlyName.toString()); } @@ -112,7 +122,9 @@ public void writeJson(JsonWriter out) throws Exception { }); } - static void onReceiveWifiEnable(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + public static void onReceiveWifiEnable(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveWifiEnable"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @Override public void writeJson(JsonWriter out) { diff --git a/app/src/main/java/com/termux/api/settings/activities/TermuxAPISettingsActivity.java b/app/src/main/java/com/termux/api/settings/activities/TermuxAPISettingsActivity.java new file mode 100644 index 000000000..80d39d194 --- /dev/null +++ b/app/src/main/java/com/termux/api/settings/activities/TermuxAPISettingsActivity.java @@ -0,0 +1,134 @@ +package com.termux.api.settings.activities; + +import android.content.Context; +import android.os.Bundle; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import com.termux.api.R; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.file.FileUtils; +import com.termux.shared.models.ReportInfo; +import com.termux.shared.interact.ShareUtils; +import com.termux.shared.android.PackageUtils; +import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; +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 TermuxAPISettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true); + + setContentView(R.layout.activity_termux_api_settings); + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings, new RootPreferencesFragment()) + .commit(); + } + + AppCompatActivityUtils.setToolbar(this, com.termux.shared.R.id.toolbar); + AppCompatActivityUtils.setShowBackButtonInActionBar(this, true); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + public static class RootPreferencesFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + setPreferencesFromResource(R.xml.sets__termux, rootKey); + + new Thread() { + @Override + public void run() { + configureTermuxAPIPreference(context); + configureAboutPreference(context); + configureDonatePreference(context); + } + }.start(); + } + + private void configureTermuxAPIPreference(@NonNull Context context) { + Preference termuxAPIPreference = findPreference("sets__termux_api_app"); + 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 configureAboutPreference(@NonNull Context context) { + Preference aboutPreference = findPreference("link__termux_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_PACKAGE)); + aboutString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context, true)); + aboutString.append("\n\n").append(TermuxUtils.getImportantLinksMarkdownString(context)); + + ReportInfo reportInfo = new ReportInfo(title, + TermuxConstants.TERMUX_API_APP.TERMUX_API_MAIN_ACTIVITY_NAME, title); + reportInfo.setReportString(aboutString.toString()); + reportInfo.setReportSaveFileLabelAndPath(title, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_API_APP_NAME.replaceAll(":", "") + + "-" + title + ".log", true, true)); + + ReportActivity.startReportActivity(context, reportInfo); + } + }.start(); + + return true; + }); + } + } + + private void configureDonatePreference(@NonNull Context context) { + Preference donatePreference = findPreference("link__termux_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/api/settings/fragments/termux_api_app/TermuxAPIPreferencesFragment.java b/app/src/main/java/com/termux/api/settings/fragments/termux_api_app/TermuxAPIPreferencesFragment.java new file mode 100644 index 000000000..8e7aebb6d --- /dev/null +++ b/app/src/main/java/com/termux/api/settings/fragments/termux_api_app/TermuxAPIPreferencesFragment.java @@ -0,0 +1,49 @@ +package com.termux.api.settings.fragments.termux_api_app; + +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.api.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.prefs__termux_api_app___prefs__app, 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/api/settings/fragments/termux_api_app/debugging/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/api/settings/fragments/termux_api_app/debugging/DebuggingPreferencesFragment.java new file mode 100644 index 000000000..174be5768 --- /dev/null +++ b/app/src/main/java/com/termux/api/settings/fragments/termux_api_app/debugging/DebuggingPreferencesFragment.java @@ -0,0 +1,117 @@ +package com.termux.api.settings.fragments.termux_api_app.debugging; + +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.api.R; +import com.termux.shared.logger.Logger; +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.prefs__termux_api_app___prefs__app___prefs__debugging, 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; + + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); + loggingCategory.addPreference(logLevelListPreference); + } + } + + public static ListPreference setLogLevelListPreferenceData(ListPreference logLevelListPreference, Context context, int logLevel) { + if (logLevelListPreference == null) + logLevelListPreference = new ListPreference(context); + + CharSequence[] logLevels = Logger.getLogLevelsArray(); + CharSequence[] logLevelLabels = Logger.getLogLevelLabelsArray(context, logLevels, true); + + logLevelListPreference.setEntryValues(logLevels); + logLevelListPreference.setEntries(logLevelLabels); + + logLevelListPreference.setValue(String.valueOf(logLevel)); + logLevelListPreference.setDefaultValue(Logger.DEFAULT_LOG_LEVEL); + + return logLevelListPreference; + } +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final 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/api/util/ResultReturner.java b/app/src/main/java/com/termux/api/util/ResultReturner.java index b50ca389d..8634ccbda 100644 --- a/app/src/main/java/com/termux/api/util/ResultReturner.java +++ b/app/src/main/java/com/termux/api/util/ResultReturner.java @@ -1,31 +1,53 @@ package com.termux.api.util; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.IntentService; import android.content.BroadcastReceiver; import android.content.BroadcastReceiver.PendingResult; +import android.content.Context; import android.content.Intent; +import android.content.pm.ApplicationInfo; import android.net.LocalSocket; import android.net.LocalSocketAddress; +import android.net.LocalSocketAddress.Namespace; import android.os.ParcelFileDescriptor; import android.util.JsonWriter; +import androidx.annotation.NonNull; + +import com.termux.shared.android.PackageUtils; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.plugins.TermuxPluginUtils; + import java.io.ByteArrayOutputStream; import java.io.FileDescriptor; +import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; public abstract class ResultReturner { + @SuppressLint("StaticFieldLeak") + public static Context context; + + private static final String LOG_TAG = "ResultReturner"; + /** - * An extra intent parameter which specifies a linux abstract namespace socket address where output from the API + * An extra intent parameter which specifies a unix socket address where output from the API * call should be written. */ private static final String SOCKET_OUTPUT_EXTRA = "socket_output"; /** - * An extra intent parameter which specifies a linux abstract namespace socket address where input to the API call + * An extra intent parameter which specifies a unix socket address where input to the API call * can be read from. */ private static final String SOCKET_INPUT_EXTRA = "socket_input"; @@ -35,7 +57,7 @@ public interface ResultWriter { } /** - * Possible subclass of {@link ResultWriter} when input is to be read from stdin. + * Possible subclass of {@link ResultWriter} when input is to be read from {@link #SOCKET_INPUT_EXTRA}. */ public static abstract class WithInput implements ResultWriter { protected InputStream in; @@ -44,9 +66,30 @@ public void setInput(InputStream inputStream) throws Exception { this.in = inputStream; } } + + /** + * Possible subclass of {@link ResultWriter} when the output is binary data instead of text. + */ + public static abstract class BinaryOutput implements ResultWriter { + private OutputStream out; + + public void setOutput(OutputStream outputStream) { + this.out = outputStream; + } + + public abstract void writeResult(OutputStream out) throws Exception; + + /** + * writeResult with a PrintWriter is marked as final and overwritten, so you don't accidentally use it + */ + public final void writeResult(PrintWriter unused) throws Exception { + writeResult(out); + out.flush(); + } + } /** - * Possible marker interface for a {@link ResultWriter} when input is to be read from stdin. + * Possible marker interface for a {@link ResultWriter} when input is to be read from {@link #SOCKET_INPUT_EXTRA}. */ public static abstract class WithStringInput extends WithInput { protected String inputString; @@ -69,14 +112,53 @@ public final void setInput(InputStream inputStream) throws Exception { } public static abstract class WithAncillaryFd implements ResultWriter { - private int fd = -1; + private LocalSocket outputSocket = null; + private final ParcelFileDescriptor[] pfds = { null }; + + public final void setOutputSocketForFds(LocalSocket outputSocket) { + this.outputSocket = outputSocket; + } - public final void setFd(int newFd) { - fd = newFd; + public final void sendFd(PrintWriter out, int fd) { + // If fd already sent, then error out as we only support sending one currently. + if (this.pfds[0] != null) { + Logger.logStackTraceWithMessage(LOG_TAG, "File descriptor already sent", new Exception()); + return; + } + + this.pfds[0] = ParcelFileDescriptor.adoptFd(fd); + FileDescriptor[] fds = { pfds[0].getFileDescriptor() }; + + // Set fd to be sent + outputSocket.setFileDescriptorsForSend(fds); + + // As per the docs: + // > The file descriptors will be sent with the next write of normal data, and will be + // delivered in a single ancillary message. + // - https://developer.android.com/reference/android/net/LocalSocket#setFileDescriptorsForSend(java.io.FileDescriptor[]) + // So we write the `@` character. It is not special, it is just the chosen character + // expected as the message by the native `termux-api` command when a fd is sent. + // - https://github.com/termux/termux-api-package/blob/e62bdadea3f26b60430bb85248f300fee68ecdcc/termux-api.c#L358 + out.print("@"); + + // Actually send the by fd by flushing the data previously written (`@`) as PrintWriter is buffered. + out.flush(); + + // Clear existing fd after it has been sent, otherwise it will get sent for every data write, + // even though we are currently not writing anything else. Android will not clear it automatically. + // - https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/net/LocalSocketImpl.java;l=523?q=setFileDescriptorsForSend + // - https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-14.0.0_r1:core/jni/android_net_LocalSocketImpl.cpp;l=194 + outputSocket.setFileDescriptorsForSend(null); } - public final int getFd() { - return fd; + public final void cleanupFds() { + if (this.pfds[0] != null) { + try { + this.pfds[0].close(); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to close file descriptor", e); + } + } } } @@ -106,64 +188,122 @@ public static void copyIntentExtras(Intent origIntent, Intent newIntent) { } + /** + * Get {@link LocalSocketAddress} for a socket address. + * + * If socket address starts with a path separator `/`, then a {@link Namespace#FILESYSTEM} + * {@link LocalSocketAddress} is returned, otherwise an {@link Namespace#ABSTRACT}. + * + * The `termux-api-package` versions `<= 0.58.0` create a abstract namespace socket and higher + * version create filesystem path socket. + * + * - https://man7.org/linux/man-pages/man7/unix.7.html + */ + @SuppressLint("SdCardPath") + public static LocalSocketAddress getApiLocalSocketAddress(@NonNull Context context, + @NonNull String socketLabel, @NonNull String socketAddress) { + if (socketAddress.startsWith("/")) { + ApplicationInfo termuxApplicationInfo = PackageUtils.getApplicationInfoForPackage(context, + TermuxConstants.TERMUX_PACKAGE_NAME); + if (termuxApplicationInfo == null) { + throw new RuntimeException("Failed to get ApplicationInfo for the Termux app package: " + + TermuxConstants.TERMUX_PACKAGE_NAME); + } + + List termuxAppDataDirectories = Arrays.asList(termuxApplicationInfo.dataDir, + "/data/data/" + TermuxConstants.TERMUX_PACKAGE_NAME); + if (!FileUtils.isPathInDirPaths(socketAddress, termuxAppDataDirectories, true)) { + throw new RuntimeException("The " + socketLabel + " socket address \"" + socketAddress + "\"" + + " is not under Termux app data directories: " + termuxAppDataDirectories); + } + + return new LocalSocketAddress(socketAddress, Namespace.FILESYSTEM); + } else { + return new LocalSocketAddress(socketAddress, Namespace.ABSTRACT); + } + } + /** * Run in a separate thread, unless the context is an IntentService. */ public static void returnData(Object context, final Intent intent, final ResultWriter resultWriter) { - final PendingResult asyncResult = (context instanceof BroadcastReceiver) ? ((BroadcastReceiver) context) - .goAsync() : null; + final BroadcastReceiver receiver = (BroadcastReceiver) ((context instanceof BroadcastReceiver) ? context : null); final Activity activity = (Activity) ((context instanceof Activity) ? context : null); + final PendingResult asyncResult = receiver != null ? receiver.goAsync() : null; final Runnable runnable = () -> { + PrintWriter writer = null; + LocalSocket outputSocket = null; try { - final ParcelFileDescriptor[] pfds = { null }; - try (LocalSocket outputSocket = new LocalSocket()) { - String outputSocketAdress = intent.getStringExtra(SOCKET_OUTPUT_EXTRA); - outputSocket.connect(new LocalSocketAddress(outputSocketAdress)); - try (PrintWriter writer = new PrintWriter(outputSocket.getOutputStream())) { - if (resultWriter != null) { - if (resultWriter instanceof WithInput) { - try (LocalSocket inputSocket = new LocalSocket()) { - String inputSocketAdress = intent.getStringExtra(SOCKET_INPUT_EXTRA); - inputSocket.connect(new LocalSocketAddress(inputSocketAdress)); - ((WithInput) resultWriter).setInput(inputSocket.getInputStream()); - resultWriter.writeResult(writer); - } - } else { - resultWriter.writeResult(writer); - } - if(resultWriter instanceof WithAncillaryFd) { - int fd = ((WithAncillaryFd) resultWriter).getFd(); - if (fd >= 0) { - pfds[0] = ParcelFileDescriptor.adoptFd(fd); - FileDescriptor[] fds = { pfds[0].getFileDescriptor() }; - outputSocket.setFileDescriptorsForSend(fds); - } - } + outputSocket = new LocalSocket(); + String outputSocketAddress = intent.getStringExtra(SOCKET_OUTPUT_EXTRA); + if (outputSocketAddress == null || outputSocketAddress.isEmpty()) + throw new IOException("Missing '" + SOCKET_OUTPUT_EXTRA + "' extra"); + Logger.logDebug(LOG_TAG, "Connecting to output socket \"" + outputSocketAddress + "\""); + outputSocket.connect(getApiLocalSocketAddress(ResultReturner.context, "output", outputSocketAddress)); + writer = new PrintWriter(outputSocket.getOutputStream()); + + if (resultWriter != null) { + if(resultWriter instanceof WithAncillaryFd) { + ((WithAncillaryFd) resultWriter).setOutputSocketForFds(outputSocket); + } + if (resultWriter instanceof BinaryOutput) { + BinaryOutput bout = (BinaryOutput) resultWriter; + bout.setOutput(outputSocket.getOutputStream()); + } + if (resultWriter instanceof WithInput) { + try (LocalSocket inputSocket = new LocalSocket()) { + String inputSocketAddress = intent.getStringExtra(SOCKET_INPUT_EXTRA); + if (inputSocketAddress == null || inputSocketAddress.isEmpty()) + throw new IOException("Missing '" + SOCKET_INPUT_EXTRA + "' extra"); + inputSocket.connect(getApiLocalSocketAddress(ResultReturner.context, "input", inputSocketAddress)); + ((WithInput) resultWriter).setInput(inputSocket.getInputStream()); + resultWriter.writeResult(writer); } + } else { + resultWriter.writeResult(writer); + } + if (resultWriter instanceof WithAncillaryFd) { + ((WithAncillaryFd) resultWriter).cleanupFds(); } } - if(pfds[0] != null) { - pfds[0].close(); - } - if (asyncResult != null) { + + if (asyncResult != null && receiver.isOrderedBroadcast()) { asyncResult.setResultCode(0); } else if (activity != null) { activity.setResult(0); } - } catch (Exception e) { - TermuxApiLogger.error("Error in ResultReturner", e); - if (asyncResult != null) { + } catch (Throwable t) { + String message = "Error in " + LOG_TAG; + Logger.logStackTraceWithMessage(LOG_TAG, message, t); + + TermuxPluginUtils.sendPluginCommandErrorNotification(ResultReturner.context, LOG_TAG, + TermuxConstants.TERMUX_API_APP_NAME + " Error", message, t); + + if (asyncResult != null && receiver != null && receiver.isOrderedBroadcast()) { asyncResult.setResultCode(1); } else if (activity != null) { activity.setResult(1); } } finally { - if (asyncResult != null) { - asyncResult.finish(); - } else if (activity != null) { - activity.finish(); + try { + if (writer != null) + writer.close(); + if (outputSocket != null) + outputSocket.close(); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to close", e); + } + + try { + if (asyncResult != null) { + asyncResult.finish(); + } else if (activity != null) { + activity.finish(); + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to finish", e); } } }; @@ -175,4 +315,8 @@ public static void returnData(Object context, final Intent intent, final ResultW } } + public static void setContext(Context context) { + ResultReturner.context = context.getApplicationContext(); + } + } diff --git a/app/src/main/java/com/termux/api/util/TermuxApiLogger.java b/app/src/main/java/com/termux/api/util/TermuxApiLogger.java deleted file mode 100644 index bfd038bc6..000000000 --- a/app/src/main/java/com/termux/api/util/TermuxApiLogger.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.termux.api.util; - -import android.util.Log; - -public class TermuxApiLogger { - - private static final String TAG = "termux-api"; - - public static void info(String message) { - Log.i(TAG, message); - } - - public static void error(String message) { - Log.e(TAG, message); - } - - public static void error(String message, Exception exception) { - Log.e(TAG, message, exception); - } - -} diff --git a/app/src/main/java/com/termux/api/util/ViewUtils.java b/app/src/main/java/com/termux/api/util/ViewUtils.java new file mode 100644 index 000000000..6b8d22356 --- /dev/null +++ b/app/src/main/java/com/termux/api/util/ViewUtils.java @@ -0,0 +1,33 @@ +package com.termux.api.util; + +import android.content.Context; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import com.termux.api.R; +import com.termux.shared.theme.ThemeUtils; + +public class ViewUtils { + + public static void setWarningTextViewAndButtonState(@NonNull Context context, + @NonNull TextView textView, @NonNull Button button, + boolean warningState, String text) { + if (warningState) { + textView.setTextColor(ContextCompat.getColor(context, com.termux.shared.R.color.red_error)); + textView.setLinkTextColor(ContextCompat.getColor(context, com.termux.shared.R.color.red_error_link)); + button.setEnabled(true); + button.setAlpha(1); + } else { + textView.setTextColor(ThemeUtils.getTextColorPrimary(context)); + textView.setLinkTextColor(ThemeUtils.getTextColorLink(context)); + button.setEnabled(false); + button.setAlpha(0.5f); + } + + button.setText(text); + } + +} diff --git a/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml b/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml index 6192469fc..61bc6d97e 100644 --- a/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + diff --git a/app/src/main/res/layout/activity_termux_api_main.xml b/app/src/main/res/layout/activity_termux_api_main.xml new file mode 100644 index 000000000..64a915934 --- /dev/null +++ b/app/src/main/res/layout/activity_termux_api_main.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_termux_api_settings.xml b/app/src/main/res/layout/activity_termux_api_settings.xml new file mode 100644 index 000000000..d3914191e --- /dev/null +++ b/app/src/main/res/layout/activity_termux_api_settings.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/layout/dialog_textarea_input.xml b/app/src/main/res/layout/dialog_textarea_input.xml index 2d1331c0c..88446c311 100644 --- a/app/src/main/res/layout/dialog_textarea_input.xml +++ b/app/src/main/res/layout/dialog_textarea_input.xml @@ -1,5 +1,6 @@ + android:scrollbars="vertical" + android:importantForAutofill="no" + tools:ignore="LabelFor" /> + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 682868c7e..dd2ac5b66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,4 +13,87 @@ Grant permission This service keeps Termux:API running in the background for faster startup of termux-* commands. This app needs the following permission(s):\n + + &TERMUX_API_APP_NAME; is a plugin app for the &TERMUX_APP_NAME; app + that executes termux-api package commands. + Check &TERMUX_APP_NAME; app github %1$s, &TERMUX_API_APP_NAME; app github %2$s and + %3$s package github %4$s for more info. + + \n\nThe &TERMUX_API_APP_NAME; app requires `%3$s` apt package to function. + Run `pkg install %3$s` to install it. + + \n\nNote that if &TERMUX_API_APP_NAME; app crashes too many times, then android will mark the + app as a bad process and you will need to manually start this activity again once for the + api commands to start working again, otherwise the commands will hang. + + \n\nReports for some crashes may be shown when you restart &TERMUX_APP_NAME; app. + + + + + Android battery optimizations + should be disabled for the &TERMUX_API_APP_NAME; app so that termux-api script can start + it from the background if its failing to do so. Do not worry, this will not drain battery, + the app currently only runs commands when called from termux-api script. + Check https://developer.android.com/about/versions/oreo/background and + https://developer.android.com/guide/components/foreground-services#background-start-restrictions + for more info. + + \n\nAlso check https://dontkillmyapp.com for info on vendor specific app killers. + Depending on vendor you may need to do things like enable AutoStart, disable DuraSpeed, + enable `Display pop-up windows while running in the background` for the app. + Disable Battery Optimizations + + The display over other + apps permission should be granted to &TERMUX_API_APP_NAME; app for starting foreground + activities from background. Check https://developer.android.com/guide/components/activities/background-starts + for more info. + + Grant Draw Over Apps Permission + + The &TERMUX_API_APP_NAME; app does not + require a require launcher activity/icon to function. You can optionally disable the launcher + activity if you want and enable it again from the main activity if required. + \n\nThe launcher activity is an alias for the current main activity of the app which can + still be opened after disabling the launcher activity. The main activity can be opened + from `&TERMUX_APP_NAME; app settings` -> `&TERMUX_API_APP_NAME;` -> `Open App` if the + option has been implemented in your installed &TERMUX_APP_NAME; app version. Otherwise, + running the `am start "%1$s/%2$s"` command in the &TERMUX_APP_NAME; app should open it. + \n\nNote that on some devices the APIs may not function properly if the launcher activity is + disabled. + + Already Granted + Already Disabled + Info + Settings + + + + + Settings + + + &TERMUX_API_APP_NAME; + Preferences for &TERMUX_API_APP_NAME; app + + + Debugging + Preferences for debugging + + + Debugging + Preferences for debugging + + + Logger + + + Log Level + + + + About + + + Donate diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 908699a04..c2b7fe27d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -15,10 +15,12 @@