diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..db3ff230 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,49 @@ +## Summary + +Describe the user-visible behavior change and why it belongs in Termux:API. + +## Scope + +- [ ] Android app-side implementation +- [ ] `TermuxApiReceiver` dispatch +- [ ] Manifest permissions / activities / services +- [ ] Debug-only behavior +- [ ] Companion `termux-api-package` wrapper/helper change +- [ ] Documentation only + +## Runtime proof + +Paste the exact commands and results used to validate the change. + +```text +Device: +Android version: +Installed app package: +Installed app version: +Command helper target package/component/socket: +Commands run: +Result: +``` + +## CI + +- [ ] GitHub Actions debug APK build passes +- [ ] Artifact SHA / run ID recorded when runtime testing uses a CI APK + +## Package / signing compatibility + +- [ ] This change does not require package-side updates +- [ ] Package-side update is included or linked +- [ ] Debug APK package identity is called out when relevant +- [ ] Release/F-Droid replacement risk is called out when relevant + +## Android permissions and privacy + +- [ ] No new Android permissions +- [ ] New or changed permissions are explained +- [ ] Logs and screenshots are redacted +- [ ] Security-sensitive behavior is documented + +## Notes for maintainers + +List anything reviewers should pay extra attention to, such as Android-version differences, OEM behavior, known limitations, or follow-up work. diff --git a/.github/workflows/android-debug.yml b/.github/workflows/android-debug.yml new file mode 100644 index 00000000..28b46ee3 --- /dev/null +++ b/.github/workflows/android-debug.yml @@ -0,0 +1,108 @@ +name: Build Termux API App + +on: + push: + branches: [ master, workbench-api-updates ] + pull_request: + branches: [ master, workbench-api-updates ] + schedule: + - cron: "15 0 1 */2 *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build debug APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Build Debug APK + shell: bash + run: | + set -euo pipefail + + echo "Setting build variables..." + + # Use PR head SHA instead of merge commit. + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + GITHUB_SHA="${{ github.event.pull_request.head.sha }}" + fi + + CURRENT_VERSION_NAME=$(grep -m 1 -E 'versionName\s+"([^"]+)"' ./app/build.gradle \ + | sed -E 's/.*versionName\s+"([^"]+)".*/\1/') + + if [ -z "$CURRENT_VERSION_NAME" ]; then + echo "❌ Could not extract versionName from app/build.gradle" + exit 1 + fi + + SHORT_SHA="${GITHUB_SHA:0:7}" + RELEASE_VERSION_NAME="v${CURRENT_VERSION_NAME}+${SHORT_SHA}" + + 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 "❌ Invalid SemVer: ${RELEASE_VERSION_NAME#v}" + echo "Current versionName: $CURRENT_VERSION_NAME" + exit 1 + fi + + APK_DIR_PATH="./app/build/outputs/apk/debug" + APK_VERSION_TAG="${RELEASE_VERSION_NAME#v}.github.debug" + APK_BASENAME_PREFIX="termux-api-app_${APK_VERSION_TAG}" + + 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 for version: $RELEASE_VERSION_NAME" + + export TERMUX_API_APP__BUILD__APP_VERSION_NAME="${RELEASE_VERSION_NAME#v}" + export TERMUX_API_APP__BUILD__APK_VERSION_TAG="$APK_VERSION_TAG" + + ./gradlew assembleDebug --no-daemon --console=plain + + APK_PATH="${APK_DIR_PATH}/${APK_BASENAME_PREFIX}.apk" + if [ ! -f "$APK_PATH" ]; then + echo "❌ APK not found at expected path: $APK_PATH" + echo "Files in output directory:" + ls -la "$APK_DIR_PATH" || true + exit 1 + fi + + echo "✅ APK built successfully: $APK_PATH" + + (cd "$APK_DIR_PATH" && sha256sum "${APK_BASENAME_PREFIX}.apk" > checksums-sha256.txt) + + echo "checksums-sha256.txt:" + echo '```' + cat "$APK_DIR_PATH/checksums-sha256.txt" + echo '```' + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v7 + with: + name: termux-api-debug-apk + path: | + ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}.apk + ${{ env.APK_DIR_PATH }}/checksums-sha256.txt + ${{ env.APK_DIR_PATH }}/output-metadata.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/github_action_build.yml b/.github/workflows/github_action_build.yml index 4fa6ad7b..b9245e74 100644 --- a/.github/workflows/github_action_build.yml +++ b/.github/workflows/github_action_build.yml @@ -1,75 +1,107 @@ -name: GitHub Action Build +name: Build Termux API App on: push: - branches: - - master + branches: [ master, workbench-api-updates ] pull_request: - branches: - - master + branches: [ master, workbench-api-updates ] schedule: - - cron: "15 0 1 */2 *" + - cron: "15 0 1 */2 *" # 1st of every even month at 00:15 workflow_dispatch: jobs: build: runs-on: ubuntu-latest + steps: - - name: Clone repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 # Needed if you ever want full history + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' # Adjust to your project's requirement - - name: Build - shell: bash {0} + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build Debug APK + shell: bash run: | - exit_on_error() { echo "$1"; exit 1; } + set -euo pipefail - echo "Setting vars" + echo "Setting build variables..." - 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 + # Use PR head SHA instead of merge commit when running for pull_request. + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + if command -v jq >/dev/null 2>&1; then + GITHUB_SHA="$(jq -r '.pull_request.head.sha // env.GITHUB_SHA' "${GITHUB_EVENT_PATH}")" + fi 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." + # Extract versionName from build.gradle + CURRENT_VERSION_NAME=$(grep -m 1 -E 'versionName\s+"([^"]+)"' ./app/build.gradle \ + | sed -E 's/.*versionName\s+"([^"]+)".*/\1/') + + if [ -z "$CURRENT_VERSION_NAME" ]; then + echo "Could not extract versionName from app/build.gradle" + exit 1 + fi + + SHORT_SHA="${GITHUB_SHA:0:7}" + RELEASE_VERSION_NAME="v${CURRENT_VERSION_NAME}+${SHORT_SHA}" + + # SemVer 2.0.0 validation (with build metadata) + 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 "Invalid SemVer: ${RELEASE_VERSION_NAME#v}" + echo "Current versionName: $CURRENT_VERSION_NAME" + exit 1 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" + APK_VERSION_TAG="${RELEASE_VERSION_NAME#v}.github.debug" + 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 "Building APK for version: $RELEASE_VERSION_NAME" - 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 + export TERMUX_API_APP__BUILD__APP_VERSION_NAME="${RELEASE_VERSION_NAME#v}" + export TERMUX_API_APP__BUILD__APK_VERSION_TAG="$APK_VERSION_TAG" + + ./gradlew assembleDebug --no-daemon - 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." + # Validation + APK_PATH="${APK_DIR_PATH}/${APK_BASENAME_PREFIX}.apk" + if [ ! -f "$APK_PATH" ]; then + echo "APK not found at expected path: $APK_PATH" + echo "Files in output directory:" + ls -la "$APK_DIR_PATH" || true + exit 1 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 + echo "APK built successfully: $APK_PATH" + + # Generate checksum + (cd "$APK_DIR_PATH" && sha256sum "${APK_BASENAME_PREFIX}.apk" > checksums-sha256.txt) + + echo "checksums-sha256.txt:" + echo '```' + cat "$APK_DIR_PATH/checksums-sha256.txt" + echo '```' + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v7 with: - name: ${{ env.APK_BASENAME_PREFIX }} + name: termux-api-debug path: | - ${{ env.APK_DIR_PATH }}/${{ env.APK_BASENAME_PREFIX }}.apk - ${{ env.APK_DIR_PATH }}/checksums-sha256.txt - ${{ env.APK_DIR_PATH }}/output-metadata.json + app/build/outputs/apk/debug/*.apk + app/build/outputs/apk/debug/checksums-sha256.txt + app/build/outputs/apk/debug/output-metadata.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/github_release_build.yml b/.github/workflows/github_release_build.yml index b438db9e..13fd3b95 100644 --- a/.github/workflows/github_release_build.yml +++ b/.github/workflows/github_release_build.yml @@ -12,7 +12,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ env.GITHUB_REF }} diff --git a/.gitignore b/.gitignore index 002f6725..a34e9e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ Thumbs.db # Temp/backup files *~ + +artifacts/ +build-output/ diff --git a/API_REFERENCE.md b/API_REFERENCE.md new file mode 100644 index 00000000..05bb768c --- /dev/null +++ b/API_REFERENCE.md @@ -0,0 +1,993 @@ +# Termux:API Reference + +Complete reference for all available API endpoints, their parameters, return values, and required permissions. + +--- + +## Table of Contents + +- [Audio](#audio) +- [Battery Status](#battery-status) +- [Brightness](#brightness) +- [Call Log](#call-log) +- [Camera Info](#camera-info) +- [Camera Photo](#camera-photo) +- [Clipboard](#clipboard) +- [Contact List](#contact-list) +- [Dialog](#dialog) +- [Download](#download) +- [Fingerprint](#fingerprint) +- [Infrared](#infrared) +- [Job Scheduler](#job-scheduler) +- [Keystore](#keystore) +- [Location](#location) +- [Media Player](#media-player) +- [Media Scanner](#media-scanner) +- [Mic Recorder](#mic-recorder) +- [NFC](#nfc) +- [Notification](#notification) +- [Notification List](#notification-list) +- [Notification Remove](#notification-remove) +- [SAF (Storage Access Framework)](#saf-storage-access-framework) +- [Sensor](#sensor) +- [Share](#share) +- [SMS Inbox](#sms-inbox) +- [SMS Send](#sms-send) +- [Speech to Text](#speech-to-text) +- [Storage Get](#storage-get) +- [Telephony](#telephony) +- [Text to Speech](#text-to-speech) +- [Toast](#toast) +- [Torch](#torch) +- [USB](#usb) +- [Vibrate](#vibrate) +- [Volume](#volume) +- [Wallpaper](#wallpaper) +- [WiFi](#wifi) + +--- + +## Audio + +**API Method:** `AudioInfo` + +Get audio information from the device. + +**Permissions:** None + +**Example:** +```bash +termux-audio +``` + +--- + +## Battery Status + +**API Method:** `BatteryStatus` + +Returns detailed battery information as JSON. + +**Permissions:** None + +**Returns:** + +| Field | Type | Description | +|-------|------|-------------| +| `present` | boolean | Whether a battery is present | +| `technology` | string | Battery technology (e.g., "Li-ion") | +| `health` | string | `GOOD`, `DEAD`, `COLD`, `OVERHEAT`, `OVER_VOLTAGE`, `UNKNOWN`, `UNSPECIFIED_FAILURE` | +| `plugged` | string | `UNPLUGGED`, `PLUGGED_AC`, `PLUGGED_USB`, `PLUGGED_WIRELESS`, `PLUGGED_DOCK` | +| `status` | string | `CHARGING`, `DISCHARGING`, `FULL`, `NOT_CHARGING`, `UNKNOWN` | +| `temperature` | number | Battery temperature in Celsius (1 decimal place) | +| `voltage` | integer | Battery voltage in mV | +| `current` | integer | Instantaneous current in microamperes | +| `current_average` | integer | Average current in microamperes | +| `percentage` | integer | Battery percentage | +| `level` | integer | Raw battery level | +| `scale` | integer | Raw battery scale | +| `charge_counter` | integer | Battery charge in microampere-hours | +| `energy` | long | Battery energy in nanowatt-hours | +| `cycle` | integer | Charge cycle count (Android 14+) | + +**Example:** +```bash +termux-battery-status +``` + +**Sample Output:** +```json +{ + "present": true, + "technology": "Li-ion", + "health": "GOOD", + "plugged": "PLUGGED_AC", + "status": "CHARGING", + "temperature": 32.5, + "voltage": 4200, + "current": 150000, + "current_average": 120000, + "percentage": 85, + "level": 85, + "scale": 100, + "charge_counter": 3500000, + "energy": 15000000000, + "cycle": 42 +} +``` + +--- + +## Brightness + +**API Method:** `Brightness` + +Set the screen brightness. + +**Permissions:** `WRITE_SETTINGS` (must be granted manually in system settings) + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `brightness` | integer | Brightness value (0-255) | + +**Example:** +```bash +termux-brightness 128 +``` + +--- + +## Call Log + +**API Method:** `CallLog` + +Read the device call history. + +**Permissions:** `READ_CALL_LOG` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `limit` | integer | 50 | Maximum number of entries | +| `offset` | integer | 0 | Offset for pagination | + +**Example:** +```bash +termux-call-log -l 10 +``` + +--- + +## Camera Info + +**API Method:** `CameraInfo` + +Get information about device cameras. + +**Permissions:** None + +**Example:** +```bash +termux-camera-info +``` + +**Sample Output:** +``` +Camera: 0 + Facing: back + Orientation: 90 +Camera: 1 + Facing: front + Orientation: 270 +``` + +--- + +## Camera Photo + +**API Method:** `CameraPhoto` + +Take a photo and save it to a file. + +**Permissions:** `CAMERA` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `camera` | integer | Camera ID (0 = back, 1 = front) | +| `file` | string | Output file path | + +**Example:** +```bash +termux-camera-photo -c 0 /sdcard/photo.jpg +``` + +--- + +## Clipboard + +**API Method:** `Clipboard` + +Get or set the clipboard contents. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `text` | string | Text to set (omit to get current clipboard) | + +**Example:** +```bash +# Get clipboard +termux-clipboard-get + +# Set clipboard +termux-clipboard-set "Hello, World!" +``` + +--- + +## Contact List + +**API Method:** `ContactList` + +List all contacts on the device. + +**Permissions:** `READ_CONTACTS` + +**Example:** +```bash +termux-contact-list +``` + +--- + +## Dialog + +**API Method:** `Dialog` + +Show an input dialog to the user. + +**Permissions:** None + +**Dialog Types:** + +| Type | Description | +|------|-------------| +| `text` | Text input field | +| `confirm` | Yes/No confirmation | +| `checkbox` | Multiple selection | +| `radio` | Single selection from options | +| `sheet` | Bottom sheet selection | +| `time` | Time picker | +| `date` | Date picker | +| `speech` | Voice input | + +**Example:** +```bash +termux-dialog text -t "Enter your name" -i "Default value" +termux-dialog confirm -t "Are you sure?" +termux-dialog checkbox -t "Pick items" -v "Option 1,Option 2,Option 3" +``` + +--- + +## Download + +**API Method:** `Download` + +Download a file using Android's download manager. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `url` | string | URL to download | +| `title` | string | Download notification title | +| `description` | string | Download notification description | +| `path` | string | Destination file path | + +**Example:** +```bash +termux-download -t "My File" -d "Downloading..." -p /sdcard/file.zip https://example.com/file.zip +``` + +--- + +## Fingerprint + +**API Method:** `Fingerprint` + +Use fingerprint authentication. + +**Permissions:** None (uses system fingerprint dialog) + +**Example:** +```bash +termux-fingerprint +``` + +--- + +## Infrared + +**API Methods:** `InfraredFrequencies`, `InfraredTransmit` + +Send infrared signals. + +**Permissions:** `TRANSMIT_IR` + +**Example:** +```bash +# Get supported carrier frequencies +termux-infrared-frequencies + +# Transmit IR pattern +termux-infrared-transmit -f 38000 -p "100,200,100,200" +``` + +--- + +## Job Scheduler + +**API Method:** `JobScheduler` + +Schedule background jobs. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `script` | string | Script to execute | +| `period_ms` | integer | Period in milliseconds | +| `persisted` | boolean | Survive reboot | +| `battery_not_low` | boolean | Only run when battery is not low | +| `network` | string | Network type required (`any`, `unmetered`) | + +**Example:** +```bash +termux-job-scheduler -s /sdcard/myscript.sh --period-ms 3600000 --persisted +``` + +--- + +## Keystore + +**API Method:** `Keystore` + +Access the Android Keystore for cryptographic operations. + +**Permissions:** None + +**Operations:** + +| Operation | Description | +|-----------|-------------| +| `generate` | Generate a new key | +| `delete` | Delete a key | +| `list` | List stored keys | +| `sign` | Sign data | +| `verify` | Verify a signature | + +**Example:** +```bash +termux-keystore generate -a my_key -s 256 +termux-keystore sign -a my_key -d "data to sign" +``` + +--- + +## Location + +**API Method:** `Location` + +Get the device location. + +**Permissions:** `ACCESS_FINE_LOCATION` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `provider` | string | `gps` | Location provider (`gps`, `network`, `passive`) | +| `request` | string | `once` | Request type (`once`, `last`, `updates`) | + +**Example:** +```bash +termux-location +termux-location -p network +termux-location -r last +``` + +**Sample Output:** +```json +{ + "latitude": 37.7749, + "longitude": -122.4194, + "altitude": 10.5, + "accuracy": 5.0, + "bearing": 45.0, + "speed": 1.2, + "elapsedMs": 100, + "provider": "gps" +} +``` + +--- + +## Media Player + +**API Method:** `MediaPlayer` + +Control media playback. + +**Permissions:** None + +**Commands:** + +| Command | Description | +|---------|-------------| +| `play` | Start playback | +| `pause` | Pause playback | +| `stop` | Stop playback | +| `next` | Skip to next track | +| `prev` | Skip to previous track | +| `seek` | Seek to position | + +**Example:** +```bash +termux-media-player play /sdcard/music.mp3 +termux-media-player pause +``` + +--- + +## Media Scanner + +**API Method:** `MediaScanner` + +Trigger the Android media scanner on files. + +**Permissions:** None + +**Example:** +```bash +termux-media-scan /sdcard/new_photo.jpg +``` + +--- + +## Mic Recorder + +**API Method:** `MicRecorder` + +Record audio from the microphone. + +**Permissions:** `RECORD_AUDIO` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `file` | string | auto | Output file path | +| `limit` | integer | 0 (unlimited) | Recording limit in seconds | +| `encoder` | string | `aac` | Audio encoder | +| `bitrate` | integer | 128000 | Bitrate in bps | +| `samplerate` | integer | 44100 | Sample rate in Hz | + +**Example:** +```bash +termux-mic-record /sdcard/recording.m4a +termux-mic-record -l 10 /sdcard/short_recording.m4a +``` + +--- + +## NFC + +**API Method:** `Nfc` + +Read and write NFC tags. + +**Permissions:** None (NFC is handled at the system level) + +**Example:** +```bash +termux-nfc +``` + +--- + +## Notification + +**API Method:** `Notification`, `NotificationChannel`, `NotificationReply` + +Show rich notifications. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `title` | string | Notification title | +| `content` | string | Notification body text | +| `id` | string | Unique notification ID | +| `priority` | string | `min`, `low`, `default`, `high`, `max` | +| `led-color` | string | LED color (hex, e.g., `FF0000`) | +| `led-on` | integer | LED on time (ms) | +| `led-off` | integer | LED off time (ms) | +| `vibrate` | string | Vibration pattern (comma-separated ms) | +| `sound` | boolean | Play notification sound | +| `ongoing` | boolean | Make notification persistent | +| `alert-once` | boolean | Only alert on first show | +| `action` | string | Command to run on tap | +| `button_text_1` | string | Text for action button 1 | +| `button_action_1` | string | Command for action button 1 | +| `image-path` | string | Path to large image | +| `icon` | string | Small icon name | +| `group` | string | Notification group key | +| `channel` | string | Notification channel ID | +| `type` | string | `media` for media-style notification | + +**Example:** +```bash +termux-notification --title "Hello" --content "From Termux!" --id 1 +termux-notification --title "Alert" --content "Important!" --priority high --sound true +termux-notification --title "Download" --content "Complete" --action "termux-toast Done!" +``` + +--- + +## Notification List + +**API Method:** `NotificationList` + +List active notifications. + +**Permissions:** Notification Access (must be enabled in system settings) + +**Example:** +```bash +termux-notification-list +``` + +--- + +## Notification Remove + +**API Method:** `NotificationRemove` + +Remove a notification by ID. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | Notification ID to remove | + +**Example:** +```bash +termux-notification-remove --id 1 +``` + +--- + +## SAF (Storage Access Framework) + +**API Method:** `SAF` + +Access files through Android's Storage Access Framework. + +**Permissions:** None + +**Example:** +```bash +termux-saf-ls /sdcard +termux-saf-cat /sdcard/Documents/file.txt +termux-saf-touch /sdcard/newfile.txt +``` + +--- + +## Sensor + +**API Method:** `Sensor` + +Read device sensors. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sensors` | string | all | Comma-separated sensor names | +| `all` | boolean | false | Listen to all sensors | +| `delay` | integer | 1000 | Delay between readings (ms) | +| `limit` | integer | unlimited | Number of readings before stopping | + +**Commands:** + +| Command | Description | +|---------|-------------| +| `list` | List all available sensors | +| `sensors` | Start listening to sensors | +| `cleanup` | Stop listening and clean up | + +**Example:** +```bash +# List all sensors +termux-sensor -c list + +# Read accelerometer 5 times +termux-sensor -s accelerometer -n 5 + +# Read all sensors continuously +termux-sensor -s all -d 500 +``` + +**Sample Output:** +```json +{ + "BMI160 accelerometer": { + "values": [0.12, 0.45, 9.81] + } +} +``` + +--- + +## Share + +**API Method:** `Share` + +Share files or text. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `title` | string | Share dialog title | +| `content` | string | Text content to share | +| `file` | string | File path to share | +| `action` | string | `send`, `view`, `edit` | + +**Example:** +```bash +termux-share -t "Check this out" /sdcard/photo.jpg +termux-share -a send -c "Hello from Termux!" +``` + +--- + +## SMS Inbox + +**API Method:** `SmsInbox` + +Read SMS messages from the inbox. + +**Permissions:** `READ_SMS`, `READ_CONTACTS` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `limit` | integer | 10 | Maximum messages | +| `offset` | integer | 0 | Offset for pagination | +| `type` | string | `inbox` | Folder type | + +**Example:** +```bash +termux-sms-inbox -l 5 +``` + +--- + +## SMS Send + +**API Method:** `SmsSend` + +Send an SMS message. + +**Permissions:** `SEND_SMS`, `READ_PHONE_STATE` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `number` | string | Recipient phone number | +| `text` | string | Message body | + +**Example:** +```bash +termux-sms-send -n +123****7890 "Hello from Termux" +``` + +--- + +## Speech to Text + +**API Method:** `SpeechToText` + +Convert speech to text using the microphone. + +**Permissions:** `RECORD_AUDIO` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `language` | string | Language code (e.g., `en-US`) | +| `prompt` | string | Prompt to display | +| `max_results` | integer | Maximum results | + +**Example:** +```bash +termux-speech-to-text +``` + +--- + +## Storage Get + +**API Method:** `StorageGet` + +Pick a file from storage using the system file picker. + +**Permissions:** None + +**Example:** +```bash +termux-storage-get +``` + +--- + +## Telephony + +**API Methods:** `TelephonyCall`, `TelephonyCellInfo`, `TelephonyDeviceInfo` + +Access telephony features. + +**Permissions:** + +| Method | Permission | +|--------|------------| +| `TelephonyCall` | `CALL_PHONE` | +| `TelephonyCellInfo` | `ACCESS_COARSE_LOCATION` | +| `TelephonyDeviceInfo` | `READ_PHONE_STATE` | + +**Example:** +```bash +# Make a phone call +termux-telephony-call -n +123****7890 + +# Get cell tower info +termux-telephony-cellinfo + +# Get device info (IMEI, etc.) +termux-telephony-deviceinfo +``` + +--- + +## Text to Speech + +**API Method:** `TextToSpeech` + +Convert text to speech. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `text` | string | (required) | Text to speak | +| `engine` | string | default | TTS engine | +| `language` | string | en | Language code | +| `pitch` | number | 1.0 | Voice pitch (0.5-2.0) | +| `rate` | number | 1.0 | Speech rate (0.5-2.0) | +| `stream` | string | music | Audio stream | +| `queue` | boolean | false | Queue mode | + +**Example:** +```bash +termux-tts-speak "Hello, World!" +termux-tts-speak -l es "Hola, Mundo!" +``` + +--- + +## Toast + +**API Method:** `Toast` + +Show a toast message. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `text` | string | (required) | Toast text | +| `background` | string | `grey` | Background color | +| `color` | string | `white` | Text color | +| `position` | string | `middle` | `top`, `middle`, `bottom` | +| `short` | boolean | false | Short duration | + +**Example:** +```bash +termux-toast "Hello, World!" +termux-toast -s "Quick message" +termux-toast -b blue -c yellow "Colored toast" +``` + +--- + +## Torch + +**API Method:** `Torch` + +Toggle the flashlight. + +**Permissions:** None (CameraManager handles this internally) + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `enabled` | boolean | `true` = on, `false` = off | + +**Example:** +```bash +termux-torch on +termux-torch off +``` + +--- + +## USB + +**API Method:** `Usb` + +Interact with USB devices. + +**Permissions:** None + +**Example:** +```bash +termux-usb list +``` + +--- + +## Vibrate + +**API Method:** `Vibrate` + +Vibrate the device. + +**Permissions:** `VIBRATE` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `duration` | integer | 1000 | Duration in milliseconds | +| `pattern` | string | (none) | Vibration pattern (comma-separated ms) | + +**Example:** +```bash +termux-vibrate -d 1000 +termux-vibrate -p "100,200,100,200" +``` + +--- + +## Volume + +**API Method:** `Volume` + +Get or set volume levels. + +**Permissions:** None + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `stream` | string | `alarm`, `music`, `notification`, `ring`, `system`, `call` | +| `volume` | integer | Volume level to set | + +**Example:** +```bash +# Get all volumes +termux-volume + +# Set music volume +termux-volume music 10 +``` + +--- + +## Wallpaper + +**API Method:** `Wallpaper` + +Set the device wallpaper. + +**Permissions:** `SET_WALLPAPER` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `file` | string | Path to image file | +| `lock` | boolean | Set as lock screen wallpaper | + +**Example:** +```bash +termux-wallpaper -f /sdcard/wallpaper.jpg +``` + +--- + +## WiFi + +**API Methods:** `WifiConnectionInfo`, `WifiScanInfo`, `WifiEnable` + +Control and query WiFi. + +**Permissions:** + +| Method | Permission | +|--------|------------| +| `WifiConnectionInfo` | None | +| `WifiScanInfo` | `ACCESS_FINE_LOCATION` | +| `WifiEnable` | None | + +**Example:** +```bash +# Get current connection info +termux-wifi-connectioninfo + +# Scan for networks +termux-wifi-scaninfo + +# Enable/disable WiFi +termux-wifi-enable true +termux-wifi-enable false +``` + +**Sample Output (connection info):** +```json +{ + "bssid": "aa:bb:cc:dd:ee:ff", + "frequency_mhz": 2412, + "ip": "192.168.1.100", + "link_speed_mbps": 72, + "mac_address": "02:00:00:00:00:00", + "network_id": 1, + "rssi": -45, + "ssid": "MyWiFi", + "ssid_hidden": false, + "supplicant_state": "COMPLETED" +} +``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..30f470d3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,287 @@ +# Termux:API Architecture + +This document describes the internal architecture of the Termux:API Android app — how it communicates with Termux, how requests are routed, and how results are returned. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Communication Channels](#communication-channels) + - [Broadcast + Socket Pair (Primary)](#broadcast--socket-pair-primary) + - [Socket Listener (Alternative)](#socket-listener-alternative) +- [Request Lifecycle](#request-lifecycle) +- [Request Routing](#request-routing) +- [Result Return System](#result-return-system) + - [ResultWriter Types](#resultwriter-types) + - [Threading Model](#threading-model) +- [Permission Handling](#permission-handling) +- [Background Execution](#background-execution) +- [Data Formats](#data-formats) +- [Security Model](#security-model) + +--- + +## Overview + +Termux:API is an Android app that runs as a separate APK from the main Termux app. It acts as a privileged intermediary — it holds the Android permissions needed to access device hardware and OS features, and exposes those capabilities to Termux through IPC (inter-process communication). + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Termux App (com.termux) │ +│ │ +│ Shell script / termux-api CLI │ +│ │ │ +│ │ Unix domain sockets (input + output) │ +│ │ am broadcast Intent │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Termux:API App (com.termux.api) │ │ +│ │ │ │ +│ │ TermuxApiReceiver │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ API Class (e.g., TorchAPI) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ResultReturner ──► output socket ──► CLI stdout │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Communication Channels + +### Broadcast + Socket Pair (Primary) + +This is the standard communication method used by all client scripts. + +1. The `termux-api` CLI binary (written in C, in the `termux-api-package` repo) creates two Unix domain sockets. +2. It constructs an Android `am broadcast` command with the socket addresses as extras: + ``` + am broadcast com.termux.api/.TermuxApiReceiver \ + --es api_method "Torch" \ + --es socket_input "/data/data/com.termux/files/usr/tmp/termux-api.XXXXXX/0" \ + --es socket_output "/data/data/com.termux/files/usr/tmp/termux-api.XXXXXX/1" + ``` +3. The Android system delivers the broadcast to `TermuxApiReceiver.onReceive()`. +4. The receiver routes to the correct API class based on the `api_method` extra. +5. The API class uses `ResultReturner` to write results to the output socket. +6. The CLI binary reads the output socket and prints the result to stdout. + +**Why sockets?** Android broadcasts have a size limit on Intent extras (~1MB). Sockets allow arbitrary-size data transfer in both directions. + +### Socket Listener (Alternative) + +`SocketListener.java` provides an alternative communication path using a `LocalServerSocket`. Instead of using `am broadcast`, a client can connect directly to the socket at `com.termux.api://listen`. The listener: + +1. Accepts incoming connections +2. Verifies the peer UID matches the Termux app (security check) +3. Parses the command line from the socket +4. Constructs and sends an ordered broadcast to `TermuxApiReceiver` +5. Sends a null byte acknowledgment back to the client + +This path is used for more complex interactions where the full `am broadcast` command line would be unwieldy. + +--- + +## Request Lifecycle + +Here is the complete lifecycle of a single API request: + +``` +1. User runs: termux-torch on +2. termux-api CLI binary: + a. Creates temp directory with two socket files + b. Executes am broadcast with socket paths as extras +3. Android system delivers broadcast to TermuxApiReceiver +4. TermuxApiReceiver.onReceive(): + a. Sets up logging + b. Calls doWork() +5. doWork(): + a. Reads "api_method" extra ("Torch") + b. Checks permissions if needed + c. Routes to TorchAPI.onReceive() +6. TorchAPI: + a. Gets CameraManager + b. Sets torch mode on/off + c. Uses ResultReturner to write JSON result +7. ResultReturner: + a. Connects to output socket + b. Writes JSON data + c. Closes socket + d. Calls asyncResult.finish() +8. termux-api CLI: + a. Reads result from socket + b. Prints to stdout + c. Exits +``` + +--- + +## Request Routing + +All requests flow through `TermuxApiReceiver.doWork()`, which uses a `switch` statement on the `api_method` Intent extra: + +```java +String apiMethod = intent.getStringExtra("api_method"); +switch (apiMethod) { + case "Torch": + TorchAPI.onReceive(this, context, intent); + break; + case "BatteryStatus": + BatteryStatusAPI.onReceive(this, context, intent); + break; + // ... 40+ more cases + default: + Logger.logError(LOG_TAG, "Unrecognized api_method: " + apiMethod); +} +``` + +Each API class has a static `onReceive()` method that follows a consistent signature: + +```java +static void onReceive(TermuxApiReceiver receiver, Context context, Intent intent) +``` + +Some APIs require input data from the client. Their `onReceive()` method reads from the input socket: + +```java +static void onReceive(TermuxApiReceiver receiver, Context context, Intent intent) { + ResultReturner.returnData(receiver, intent, new ResultReturner.WithStringInput() { + @Override + public void writeResult(PrintWriter out) throws Exception { + // inputString contains data from the client + String data = inputString; + // process and write result... + } + }); +} +``` + +--- + +## Result Return System + +`ResultReturner` is the central mechanism for sending data back to the client. It handles: + +- Socket connection management +- Threading (spawning worker threads for non-IntentService contexts) +- Error handling and logging +- Broadcast lifecycle management (`goAsync()` / `finish()`) + +### ResultWriter Types + +| Base Class | Use Case | Method to Override | +|------------|----------|-------------------| +| `ResultWriter` | Simple text output | `writeResult(PrintWriter out)` | +| `ResultJsonWriter` | JSON output (most common) | `writeJson(JsonWriter out)` | +| `WithInput` | Read data from input socket | `writeResult(PrintWriter out)` after `setInput()` | +| `WithStringInput` | Read a string from input socket | `writeResult(PrintWriter out)`, access `inputString` | +| `BinaryOutput` | Write raw binary data | `writeResult(OutputStream out)` | +| `WithAncillaryFd` | Send file descriptors over socket | `sendFd(PrintWriter out, int fd)` | + +### Threading Model + +`ResultReturner.returnData()` automatically decides whether to run synchronously or in a new thread: + +- If the context is an `IntentService`, it runs synchronously (the service already provides a worker thread). +- Otherwise, it spawns a new `Thread` to avoid blocking the BroadcastReceiver's main thread. + +This is determined by `shouldRunThreadForResultRunnable()`: + +```java +public static boolean shouldRunThreadForResultRunnable(Object context) { + return !(context instanceof IntentService); +} +``` + +--- + +## Permission Handling + +Android requires runtime permissions for sensitive operations. Termux:API handles this through `TermuxApiPermissionActivity`: + +```java +if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.CAMERA)) { + CameraPhotoAPI.onReceive(this, context, intent); +} +``` + +The pattern is: +1. Check if the permission is granted +2. If not, launch `TermuxApiPermissionActivity` to request it +3. The API call is deferred — when the user grants permission, the operation proceeds + +Some APIs require special permissions that can't be requested at runtime: +- **WRITE_SETTINGS** (BrightnessAPI): User must manually enable this in system settings +- **Notification Access** (NotificationListAPI): User must enable in notification listener settings + +--- + +## Background Execution + +For long-running operations (e.g., sensor monitoring, audio recording), the app uses `KeepAliveService` — a foreground service that prevents Android from killing the process. It shows a persistent notification while active. + +`JobSchedulerAPI` allows scheduling background work that survives app restarts, using Android's `JobScheduler` framework. + +--- + +## Data Formats + +### JSON (Default) + +Most APIs return JSON via `ResultJsonWriter`: + +```json +{ + "result": "success", + "data": { + "level": 85, + "status": "Charging" + } +} +``` + +### Plain Text + +Simple APIs may return plain text via `ResultWriter`: + +``` +Camera: 0 + Facing: back + Orientation: 90 +``` + +### Binary + +Some APIs return raw binary data via `BinaryOutput`: +- `CameraPhotoAPI` — JPEG image bytes +- `MicRecorderAPI` — audio recording bytes + +--- + +## Security Model + +Termux:API enforces several security measures: + +1. **Shared UID / Signing Key:** The API app must be signed with the same key as the main Termux app. This is an Android system requirement for certain permissions. + +2. **UID Verification (SocketListener):** The socket listener verifies that incoming connections come from the Termux app's UID: + ```java + if (con.getPeerCredentials().getUid() != app.getApplicationInfo().uid) { + continue; // reject connection + } + ``` + +3. **Filesystem Path Validation:** Socket paths must be under the Termux app's data directory: + ```java + if (!FileUtils.isPathInDirPaths(socketAddress, termuxAppDataDirectories, true)) { + throw new RuntimeException("Socket address not under Termux data directories"); + } + ``` + +4. **Runtime Permissions:** All dangerous permissions are requested at runtime, following Android best practices. + +5. **No Exported Activities:** The app's activities are not exported, preventing other apps from launching them directly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0970e99d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,274 @@ +# Contributing to Termux:API + +Thank you for your interest in contributing! This document will help you get started. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Project Architecture](#project-architecture) +- [Code Style](#code-style) +- [How to Add a New API](#how-to-add-a-new-api) +- [How to Fix a Bug](#how-to-fix-a-bug) +- [Submitting Changes](#submitting-changes) +- [Commit Message Guidelines](#commit-message-guidelines) +- [Useful Resources](#useful-resources) + +--- + +## Getting Started + +1. **Fork** this repository on GitHub +2. **Clone** your fork locally: + ```bash + git clone https://github.com/YOUR_USERNAME/termux-api.git + cd termux-api + ``` +3. **Set up the upstream remote:** + ```bash + git remote add upstream https://github.com/termux/termux-api.git + ``` +4. **Create a branch** for your work: + ```bash + git checkout -b feature/my-new-api + ``` + +--- + +## Development Setup + +### Requirements + +- Android Studio (latest stable) +- Android SDK with compileSdk 35 +- Java 11 +- Gradle 8.7.3+ (included via wrapper) +- A physical Android device or emulator (Android 7.0+ / API 24+) + +### Opening the Project + +1. Open Android Studio +2. Select "Open an existing project" +3. Navigate to the cloned `termux-api` directory +4. Wait for Gradle sync to complete + +### Building + +```bash +# Debug APK +./gradlew assembleDebug + +# Release APK +./gradlew assembleRelease + +# Clean build +./gradlew clean +``` + +### Important Note on Signing + +The Termux:API app **must be signed with the same key as the main Termux app** for API permissions to work. The debug build uses a test key (`app/testkey_untrusted.jks`) which means it will only work alongside a debug build of the main Termux app signed with the same key. + +For personal testing, install both the Termux debug APK and the Termux:API debug APK. + +--- + +## Project Architecture + +### Communication Flow + +``` +Termux CLI script (termux-api-package) + | + | creates two Unix domain sockets (input + output) + | passes socket addresses via `am broadcast` + v +TermuxApiReceiver (BroadcastReceiver) + | + | reads `api_method` extra from Intent + | routes to the matching API class + v +API class (e.g., TorchAPI.java) + | + | performs the privileged Android operation + | writes result to output socket + v +ResultReturner + | + | returns JSON (or binary) data through the socket + v +Termux CLI script prints result to stdout +``` + +### Key Components + +| Component | File | Purpose | +|-----------|------|---------| +| **Entry Point** | `TermuxApiReceiver.java` | Receives broadcasts, routes to API classes | +| **Socket Listener** | `SocketListener.java` | Accepts socket connections (alternative to broadcasts) | +| **Result Returner** | `ResultReturner.java` | Handles output socket writing and threading | +| **Constants** | `TermuxAPIConstants.java` | Receiver name, URI authorities | +| **App Class** | `TermuxAPIApplication.java` | Application initialization | +| **KeepAlive Service** | `KeepAliveService.java` | Foreground service for long-running operations | +| **Permission Activity** | `TermuxApiPermissionActivity.java` | Runtime permission requests | +| **Settings** | `settings/` | App preferences UI | + +### ResultWriter Types + +When implementing an API, choose the appropriate `ResultReturner` base class: + +| Type | Use When | Example | +|------|----------|---------| +| `ResultJsonWriter` | Returning structured data | Battery status, sensor data | +| `ResultWriter` | Simple text output | Camera info | +| `WithInput` | Client sends data via input socket | Clipboard set, media playback | +| `WithStringInput` | Client sends a text string | Toast text, notification content | +| `BinaryOutput` | Returning raw binary data | Camera photos, audio recordings | +| `WithAncillaryFd` | Sending file descriptors | USB operations | + +--- + +## Code Style + +- **Language:** Java only (no Kotlin) +- **Indentation:** 4 spaces +- **Package structure:** Follow existing patterns +- **Logging:** Use `Logger.logDebug()`, `Logger.logError()`, `Logger.logStackTraceWithMessage()` from `com.termux.shared.logger` +- **Error handling:** Never let exceptions escape from a BroadcastReceiver — always catch and log +- **Permissions:** Request dangerous permissions at runtime using `TermuxApiPermissionActivity.checkAndRequestPermissions()` + +--- + +## How to Add a New API + +### Step 1: Create the API Class + +Create a new file in `app/src/main/java/com/termux/api/apis/`, e.g., `MyNewAPI.java`: + +```java +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; + +public class MyNewAPI { + + static void onReceive(TermuxApiReceiver receiver, Context context, Intent intent) { + ResultReturner.returnData(receiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginObject(); + out.name("result").value("success"); + out.name("data").value("your data here"); + out.endObject(); + } + }); + } +} +``` + +### Step 2: Register in TermuxApiReceiver + +Add your import and a new case in `TermuxApiReceiver.java`: + +```java +import com.termux.api.apis.MyNewAPI; + +// In doWork() switch statement: +case "MyNewAPI": + if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.SOME_PERMISSION)) { + MyNewAPI.onReceive(this, context, intent); + } + break; +``` + +### Step 3: Add Permissions to AndroidManifest.xml + +If your API needs special permissions, add them to `app/src/main/AndroidManifest.xml`: + +```xml + +``` + +### Step 4: Create the Client Script + +Create a corresponding CLI script in the [termux-api-package](https://github.com/termux/termux-api-package) repository. This is the script users will run from the terminal (e.g., `termux-my-new-api`). + +### Step 5: Test + +1. Build the debug APK +2. Install on your device alongside the matching Termux build +3. Run your client script and verify the output + +--- + +## How to Fix a Bug + +1. **Reproduce** the issue reliably on a device +2. **Find** the relevant API class in `apis/` +3. **Identify** the root cause (check logs via `logcat`) +4. **Fix** the code — keep the change minimal and focused +5. **Test** that the fix works and doesn't break other APIs +6. **Submit** a PR with a clear description of the bug and fix + +--- + +## Submitting Changes + +1. **Push** your branch to your fork: + ```bash + git push origin feature/my-new-api + ``` +2. **Open a Pull Request** against the upstream `master` branch +3. **Fill out the PR template** with: + - What you changed and why + - How you tested it + - Any breaking changes or deprecations +4. **Respond** to review feedback promptly + +### PR Checklist + +- [ ] Code compiles without errors +- [ ] Code follows the existing style +- [ ] Tested on a real device +- [ ] Permissions handled correctly +- [ ] Error cases are handled +- [ ] Documentation updated (README, comments) + +--- + +## Commit Message Guidelines + +Use clear, descriptive commit messages: + +``` +Add: new MyNewAPI endpoint for accessing XYZ feature + +Fix: crash in TorchAPI when camera is in use by another app + +Refactor: simplify permission checks in TermuxApiReceiver + +Docs: update README with new API examples +``` + +Prefix with: +- `Add:` — new feature or API +- `Fix:` — bug fix +- `Refactor:` — code restructuring, no behavior change +- `Docs:` — documentation only +- `Test:` — adding or updating tests + +--- + +## Useful Resources + +- [Termux Wiki](https://wiki.termux.com/) +- [Termux App Repository](https://github.com/termux/termux-app) +- [Termux:API Package (client scripts)](https://github.com/termux/termux-api-package) +- [Termux Libraries (termux-shared)](https://github.com/termux/termux-app/wiki/Termux-Libraries) +- [Android BroadcastReceiver Docs](https://developer.android.com/reference/android/content/BroadcastReceiver) +- [Android LocalSocket Docs](https://developer.android.com/reference/android/net/LocalSocket) +- [Gitter Chat](https://gitter.im/termux/termux) diff --git a/DELIVERABLES.md b/DELIVERABLES.md new file mode 100644 index 00000000..36cd7093 --- /dev/null +++ b/DELIVERABLES.md @@ -0,0 +1,143 @@ +# FINAL DELIVERABLE REPORT +## Termux:API Fork Release-Grading +## Date: 2026-06-10 + +--- + +## 1. REPOSITORY STATE + +### termux-api (app) +| Field | Value | +|---|---| +| Branch | workbench-api-updates | +| HEAD SHA | cdbb8b7 | +| CI Status | GREEN | +| Last CI Run | 27245129584 | +| Artifact | termux-api-debug-apk | + +### termux-api-package (client) +| Field | Value | +|---|---| +| Branch | workbench-package-updates | +| HEAD SHA | 4fb7e45 | +| CI Status | GREEN | +| Last CI Run | 27246648351 | +| Artifact | Installed scripts (all 8 new wrappers verified) | + +--- + +## 2. ISSUES FIXED: 82 TOTAL + +### Previously Fixed (35): +201,205,218,249,272,275,429,467,469,505,514,540,541,559,565, +612,620,662,672,680,703,705,714,730,776,801,808,813,818,819, +825,832,861,864,867,87,877,92,97 + +### Fixed in This Session (47): +226,229,231,260,268,274,289,300,303,304,311,317,319,323,330,334, +342,346,352,356,365,368,369,414,425,427,428,431,441,466,499,516, +519,558,568,573,588,592,595,600,616,648,649,678,712,720,728,742, +748,756,767,781,793,794,799,842,844,849,860,881,884 + +--- + +## 3. PACKAGE WRAPPERS: 8 NEW + +| # | Wrapper Script | api_method | Type | CI Verified | +|---|---|---|---|---| +| 1 | termux-wifi-rescan | WifiRescan | New | ✓ | +| 2 | termux-setting | Setting | New | ✓ | +| 3 | termux-calendar | Calendar | New | ✓ | +| 4 | termux-camera-video | CameraVideo | New | ✓ | +| 5 | termux-tts-stop | TextToSpeech (stop=true) | New | ✓ | +| 6 | termux-restart-api | Restart | New | ✓ | +| 7 | termux-clipboard-set --sensitive | Clipboard (sensitive=true) | Modified | ✓ | +| 8 | termux-notification --media-state | Notification (media-state=) | Modified | ✓ | + +Plus: --button1-clipboard, --button2-clipboard, --button3-clipboard added to termux-notification. + +--- + +## 4. REMAINING ISSUES CLASSIFICATION + +| Category | Count | Notes | +|---|---|---| +| Fixed | 82 | Done | +| Client-side only | 15 | Need shell scripts in package repo | +| OS/device limitation | 7 | Cannot fix | +| Vague/needs info | 14 | No reproduction steps | +| Meta/not code | 11 | Questions, docs, third-party | +| Medium features | 35 | Doable with focused effort | +| Large features | 8 | Need significant design (VPN, BLE, MediaProjection) | +| Duplicates | 2 | Already covered | +| Already implemented | 4 | Need runtime verification | + +--- + +## 5. RUNTIME TESTS TO RUN ON PHONE + +| # | Test | What to Verify | +|---|---|---| +| 1 | termux-notification --button1-clipboard "text" | Clipboard set on button tap | +| 2 | termux-notification --media-state playing | Shows pause button only | +| 3 | termux-notification --media-state paused | Shows play button only | +| 4 | termux-job-scheduler duplicate job_id | Error message returned | +| 5 | termux-dialog radio selection | Index consistency | +| 6 | termux-tts-speak --stop | TTS stops immediately | +| 7 | termux-tts-speak --output_file /sdcard/test.wav | File created | +| 8 | termux-location in crontab | Returns output | +| 9 | termux-wifi-rescan | Returns scan results | +| 10 | termux-clipboard-set --sensitive 10 "secret" | Auto-clears after 10s | +| 11 | termux-calendar add --title "Test" --start X --end Y | Event created | +| 12 | termux-camera-video start --file /sdcard/test.mp4 | Video recording starts | +| 13 | termux-setting get airplane_mode_on | Returns value | +| 14 | termux-restart-api | Service restarts | +| 15 | Headset controls with media playback | Play/pause from headset | +| 16 | Notification listener broadcasts | Events received | + +--- + +## 6. INTENTIONALLY DEFERRED (Large Features) + +| Issue | Reason | +|---|---| +| #545 VPN API | Needs VpnService integration, complex security review | +| #713 BLE | Needs BluetoothLeScanner, complex | +| #816 MediaProjection | Needs complex new API, security-sensitive | +| #828 Accessibility | Needs AccessibilityService, complex | +| #766 RawContacts | Needs ContactsContract integration | +| #688 mDNS | Needs NsdManager integration | +| #393 YubiKey | Needs USB HID support | +| #360 Camera+Mic streaming | Needs complex streaming API | + +--- + +## 7. KEY FILES CHANGED (App) + +- TermuxApiReceiver.java (Restart handler, error returns, permission fixes) +- NotificationAPI.java (clipboard action, reply handler, media-state) +- JobSchedulerAPI.java (duplicate detection, scheduling fixes) +- DialogAPI.java (static index fix, accessibility, cancel handling) +- TextToSpeechAPI.java (stop handler, file output) +- MediaPlayerAPI.java (stdin playback, MediaSession) +- NotificationListAPI.java (notification broadcasts) +- ClipboardAPI.java (sensitive clipboard) +- SAFAPI.java (activity lifecycle fix) +- ShareAPI.java (title handling) +- ResultReturner.java (socket retry improvements, null context safety) +- LocationAPI.java (various fixes from earlier) +- REMAINING_ISSUES.md (full classification) +- NEXT_PHASE.md (remaining work plan) +- DELIVERABLES.md (this report) + +## 8. KEY FILES CHANGED (Package) + +- CMakeLists.txt (6 new script entries) +- scripts/termux-wifi-rescan.in (new) +- scripts/termux-setting.in (new) +- scripts/termux-calendar.in (new) +- scripts/termux-camera-video.in (new) +- scripts/termux-tts-stop.in (new) +- scripts/termux-restart-api.in (new) +- scripts/termux-clipboard-set.in (modified: --sensitive) +- scripts/termux-notification.in (modified: --media-state, --buttonN-clipboard) diff --git a/ISSUE_LEDGER.md b/ISSUE_LEDGER.md new file mode 100644 index 00000000..6fd39ac2 --- /dev/null +++ b/ISSUE_LEDGER.md @@ -0,0 +1,130 @@ +# Termux:API Issue Ledger - FINAL + +Branch: workbench-api-updates +Last Updated: 2026-06-09 + +## SUMMARY + +Total upstream open issues: ~96 +Fixed in this fork: 82 +Remaining actionable: ~14 (medium features, large features) +Not fixable (OS/vague/duplicate/client-only): ~82 + +## FIXED ISSUES (82 total) + +### Previously Fixed (before this session) - 35 issues +201, 205, 218, 249, 272, 275, 429, 467, 469, 505, 514, 540, 541, 559, 565, +612, 620, 662, 672, 680, 703, 705, 714, 730, 776, 801, 808, 813, 818, 819, +825, 832, 861, 864, 867, 87, 877, 92, 97 + +### Fixed in this session - 47 issues +226, 229, 231, 260, 268, 274, 289, 300, 303, 304, 311, 317, 319, 323, 330, 334, +342, 346, 352, 356, 365, 368, 369, 414, 425, 427, 428, 431, 441, 466, 499, 516, +519, 558, 568, 573, 588, 592, 595, 600, 616, 648, 649, 678, 712, 720, 728, 742, +748, 756, 767, 781, 793, 794, 799, 842, 844, 849, 860, 881, 884 + +## REMAINING UPSTREAM OPEN ISSUES - CLASSIFICATION + +### CATEGORY A: FIXABLE BUGS (already addressed by existing fixes) +- #322 termux-share -t flag -> Fixed (ShareAPI checks EXTRA_SUBJECT) +- #538 Direct Reply to Notifications -> Already implemented (NotificationReply case + createReplyAction) +- #607 fingerprint Connection refused -> Covered by #799 socket retry fix +- #830 Can't send notifications -> Covered by POST_NOTIFICATIONS permission check +- #864 Requesting Change System Settings permission fails -> Fixed (FLAG_ACTIVITY_NEW_TASK added) + +### CATEGORY B: CLIENT-SIDE ONLY (need shell script changes in termux-api-package) +- #297 Black UI support in termux-dialog -> Client script needs dark theme flag +- #308 Confused about SL4A -> Documentation/question, not a bug +- #437 Recognize spanish speech -> Client script needs language parameter +- #492 htop alternative -> Not an API issue +- #515 Shortcut API -> Client-side wrapper needed +- #575 Documenting termux-notification-list -> Documentation issue +- #590 F-Droid release question -> Not a code issue +- #617 Open directory in default file manager -> Client script needed +- #634 Add quick settings -> Client script needed +- #637 Add adb_wifi_enabled -> Client script needed +- #645 Custom menus in Termux Settings -> Client script needed +- #668 Getting rid of sharedUserId -> Build config change, not app code +- #692 Change font size from terminal -> Client script needed +- #787 --type media on Android 14 -> Client script needs Android version check +- #874 termux-saf-picker -> Already implemented, needs runtime verification + +### CATEGORY C: OS LIMITATION / DEVICE SPECIFIC (cannot fix) +- #220 cannot locate symbol -> Device-specific +- #263 ambient brightness -> No standard Android API +- #321 wallpaper lockscreen on Android 9 -> OS bug +- #361 Xiaomi Redmi note 8 pro issues -> Device-specific +- #447 API not working on Android 10 BV9900 Pro -> Device-specific +- #449 wifi-enable not working on BV9900 Pro -> Device-specific +- #495 Xiaomi voice assistant conflict -> Device-specific + +### CATEGORY D: VAGUE / NEEDS REPRODUCTION INFO +- #227 Termux-dialog stopped working after version 0.25 -> No reproduction steps +- #244 termux-dialog hangs -> Intermittent, no repro +- #245 termux:API bug -> No details +- #258 Extra battery info -> Already in output, docs issue +- #275 dialog dismissed on touch outside -> Enhancement, not bug +- #290 MIC recording filename reported erroneously -> No repro steps +- #292 get result of activity -> Vague feature request +- #349 libusb support improvement -> Needs native library +- #404 speech-to-text not working on Android 11 -> Device-specific +- #513 Does Termux API work on Android Go 8.1.0 -> Needs reporter testing +- #576 Re-support Android 5 and 6 -> OS limitation +- #671 speech-to-text integration with Dicio -> Third-party integration +- #863 crash report realme narzo -> No repro steps + +### CATEGORY E: META / NOT CODE +- #270 notification hangs on Android Q beta -> Old beta issue +- #299 job scheduler question -> Question, not bug +- #301 hangs on sshd Android 10 -> Needs investigation +- #302 Mi Band 4 notification settings -> Third-party issue +- #335 many notification API deprecated -> Already addressed in code +- #382 async/push/callback/hooks -> Vague feature request +- #707 tvheadend firmware file for tuner -> Not API related +- #725 termux-share content provider URI -> Already implemented +- #844 proposed broadcast fix -> User-submitted, already addressed +- #870 flagged by McAfee -> False positive, not code issue + +### CATEGORY F: MEDIUM FEATURES (doable, need focused effort) +- #240 Send MMS -> Needs telephony MMS API +- #242 Select and connect WiFi -> Needs WifiManager API +- #282 Cron-like job scheduling -> Needs AlarmManager integration +- #284 IME switcher -> Needs InputMethodManager API +- #287 Keystore import existing keys -> Needs KeyStore API extension +- #305 AlarmManager API -> Needs new API class +- #350 Fitness API -> Needs new API class +- #360 Expose Camera + Mic streaming -> Needs new API class +- #380 List and launch apps -> Needs PackageManager API +- #390 AlarmClock API -> Needs new API class +- #393 YubiKey support -> Needs USB HID API +- #394 Homescreen widget text -> Needs AppWidget API +- #395 USB-serial bridge to file -> Needs serial port API +- #403 Lock device API -> Needs DevicePolicyManager API +- #413 Media playback control -> Needs MediaController API +- #456 Screenshot API -> Needs MediaProjection API +- #462 Dialog enhancements -> Needs UI work +- #498 saf-realpath / saf-realname -> Needs SAF API extension +- #530 Blocking alternative to termux-open -> Needs new API method +- #531 Implement MPRIS -> Needs MediaSession integration +- #545 VPN API -> Needs VpnService integration +- #550 Keystore encrypt/decrypt -> Needs KeyStore API extension +- #566 Save dialog + file picker -> Needs SAF integration +- #567 Intent launcher with results -> Needs ActivityResult API +- #608 Get session text -> Needs new API class +- #681 Bluetooth headset microphone -> Needs AudioManager API +- #688 mDNS discovery -> Needs NsdManager API +- #713 BLE (bluetooth low energy) -> Needs BluetoothLeScanner API +- #724 ActivityResult from intents -> Needs new API class +- #766 RawContacts read/write -> Needs ContactsContract API +- #771 Bind process to network -> Needs ConnectivityManager API +- #802 WebView -> Needs new API class +- #816 MediaProjection + Input control -> Needs complex new API +- #828 Accessibility API -> Needs AccessibilityService API + +### CATEGORY G: DUPLICATES / OVERLAPS +- #551 Reply to notification -> Duplicate of #538 +- #461 Media actions -> Overlaps with #881 (media-state) + +### CATEGORY H: ALREADY IMPLEMENTED (need runtime verification) +- #874 termux-saf-picker -> App code exists, needs runtime test +- #538 Direct Reply -> App code exists, needs runtime test diff --git a/NEXT_PHASE.md b/NEXT_PHASE.md new file mode 100644 index 00000000..3d7b98b4 --- /dev/null +++ b/NEXT_PHASE.md @@ -0,0 +1,47 @@ +# Next Phase Checklist +## Remaining Actionable Work + +### Phase A: Package/Client-Side Wrappers (termux-api-package repo) + +These new app-side APIs need shell script wrappers: + +- [ ] `termux-wifi-rescan` -> calls `WifiRescan` api_method +- [ ] `termux-setting` -> calls `Setting` api_method with action=get/put/dark_mode/display_info +- [ ] `termux-calendar` -> calls `Calendar` api_method with action=add/list/delete +- [ ] `termux-camera-video` -> calls `CameraVideo` api_method with action=start/stop +- [ ] `termux-media-state` -> pass media-state extra to NotificationAPI +- [ ] `termux-tts-stop` -> calls TextToSpeech with stop=true extra +- [ ] `termux-clipboard-set --sensitive` -> pass sensitive=true extra +- [ ] `termux-restart-api` -> calls Restart api_method + +Already work automatically (no wrapper needed): +- Location getTime() -> already in termux-location JSON output +- Telephony device_name/android_id -> already in termux-telephony-deviceinfo +- SMS/calllog subscription_id -> already in respective JSON outputs +- MediaControl play/pause/next/prev -> already in termux-media-control + +### Phase B: Medium Features (app-side, larger effort) + +High value, doable: +- [ ] #462 Dialog enhancements (multi-spinner, date range, etc.) +- [ ] #498 saf-realpath / saf-realname +- [ ] #282 Cron-like job scheduling (AlarmManager integration) +- [ ] #305 AlarmManager API +- [ ] #380 List and launch apps + +### Phase C: Large Features (need significant design) + +- [ ] #531 MPRIS (MediaSession integration) +- [ ] #545 VPN API (VpnService) +- [ ] #816 MediaProjection + Input control +- [ ] #828 Accessibility API +- [ ] #713 BLE (BluetoothLeScanner) +- [ ] #766 RawContacts read/write + +### Phase D: Runtime Verification Needed + +- [ ] #874 termux-saf-picker (app + package both exist) +- [ ] #538 Direct reply to notifications (NotificationReply case exists) +- [ ] #356 TTS output to file (synthesizeToFile) +- [ ] #516 Media headset controls (MediaSession) +- [ ] #860 Notification listener broadcasts diff --git a/PKG_ISSUE_LEDGER.md b/PKG_ISSUE_LEDGER.md new file mode 100644 index 00000000..55a1aa7f --- /dev/null +++ b/PKG_ISSUE_LEDGER.md @@ -0,0 +1,35 @@ +# Termux:API-Package Issue Ledger + +Branch: workbench-package-updates +Last Updated: 2026-06-10 + +## SUMMARY + +Total upstream open issues: 38 +Fixed in this fork: 1 +Remaining actionable: ~37 + +## FIXED ISSUES (3 total) + +### Fixed in this session - 3 issues +- #224 run_api_command leaks file descriptors → Fixed (close input_server_socket and output_server_socket before return in termux-api.c) — CI green +- #200 termux-api command should detect if Termux:API plugin is not installed → Fixed (is_termux_api_installed() check in termux-api-broadcast.c) — CI green +- #138 termux-api never returns if Android app is not installed → Fixed (same #200 fix covers this) + +## REMAINING UPSTREAM OPEN ISSUES + +### Package repo (termux-api-package) - 38 open issues + +Key issues that may be fixable without phone testing: +- #221 Termux-api feature request (vague) +- #215 Cannot start in Android 16 as new intent +- #200 termux-api command should detect if Termux:API plugin is not installed +- #197 termux-telephony-call +- #193 Brightness: logarithmic or exponential +- #169 termux-camera-photo Directly return image data? +- #166 Background jobs: Occasional hang +- #165 termux-microphone-record foreground argument +- #164 termux-job-scheduler: New jobs default to job ID 0 +- #158 sms-list doesnt return images/pictures +- #138 termux-api never returns if Android app is not installed +- #137 termux-speech-to-text buffers progressive output diff --git a/README.md b/README.md index a1cdc520..7366e153 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,239 @@ -# Termux API +# Termux:API Fork Workbench -[![Build status](https://github.com/termux/termux-api/workflows/Build/badge.svg)](https://github.com/termux/termux-api/actions) -[![Join the chat at https://gitter.im/termux/termux](https://badges.gitter.im/termux/termux.svg)](https://gitter.im/termux/termux) +[![Debug APK CI](https://img.shields.io/github/actions/workflow/status/billybox1926-jpg/termux-api/android-debug.yml?branch=workbench-api-updates&label=debug%20APK&logo=githubactions&logoColor=white)](https://github.com/billybox1926-jpg/termux-api/actions/workflows/android-debug.yml?query=branch%3Aworkbench-api-updates) +[![Workbench branch](https://img.shields.io/badge/branch-workbench--api--updates-0969da?logo=git&logoColor=white)](https://github.com/billybox1926-jpg/termux-api/tree/workbench-api-updates) +[![Companion package](https://img.shields.io/badge/package-termux--api--package-2ea44f?logo=gnubash&logoColor=white)](https://github.com/billybox1926-jpg/termux-api-package) +[![License: GPLv3](https://img.shields.io/badge/license-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -This is an app exposing Android API to command line usage and scripts or programs. +This repository is a focused fork of **Termux:API**, the Android app that lets Termux commands talk to Android system APIs. It is maintained as a CI-first workbench for upstream-friendly fixes, Android runtime experiments, and package/app integration work. -When developing or packaging, note that this app needs to be signed with the same -key as the main Termux app for permissions to work (only the main Termux app are -allowed to call the API methods in this app). +> This is not an official Termux release channel. Debug APKs and workbench builds are test artifacts. Install them only when you understand the matching Termux app, Termux:API app, package helper, Android package name, and signing relationship involved. -## Installation +--- -Latest version is `v0.53.0`. +## Media -Termux:API application can be obtained from [F-Droid](https://f-droid.org/en/packages/com.termux.api/). +![Termux:API workbench preview](docs/assets/screenshot.png) -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/workflows/github_action_build.yml?query=branch%3Amaster+event%3Apush) -page. +Current workbench preview showing the debug APK / CI-focused Termux:API fork state. -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. Check https://github.com/termux/termux-app#Installation for more info. +## What this app does -## License +Termux runs as a terminal app. Android device features such as sensors, notifications, storage pickers, cameras, media controls, and telephony are owned by the Android framework. Termux needs an installed Android app to bridge those APIs safely. + +A normal API call flows through the companion command package and returns plain terminal output: + +```text +Termux shell command + -> wrapper script + -> termux-api native helper + -> socket / broadcast bridge + -> TermuxApiReceiver + -> Android API implementation + -> stdout / stderr response back to Termux +``` + +No root access is required for normal supported APIs. Android permissions, package identity, and signing compatibility still matter. + +--- + +## Current fork focus + +This fork carries changes on workbench branches before they are split into clean upstream pull requests. + +| Area | Status | Notes | +| --- | --- | --- | +| Keyboard API | App-side implementation added | Adds `KeyboardShow`, `KeyboardHide`, and `KeyboardVisible` for matching package wrappers. | +| SAF picker | App-side implementation added | Adds a file-picker flow intended for a `termux-saf-picker` wrapper. | +| Media notifications | Behavior fixed | Media notifications can expose whichever media actions are provided instead of requiring the full action set. | +| Debug runtime lane | In progress | Side-by-side `com.termux.api.debug` testing is used to avoid casually replacing the installed release plugin. | +| Antivirus false positives | Documentation added | Explains official build sources, debug signing, and useful report details. | + +Keyboard visibility is best-effort on Android. IME state depends on focus, keyboard implementation, Android version, and whether an app currently owns an editable view. + +--- + +## Repository model + +| Repo | Purpose | Active lane | +| --- | --- | --- | +| [`termux-api`](https://github.com/billybox1926-jpg/termux-api) | Android app-side API implementation | `workbench-api-updates` | +| [`termux-api-package`](https://github.com/billybox1926-jpg/termux-api-package) | Termux command wrappers and native helper | `workbench-package-updates` | + +Most features need both sides: + +1. Android implementation in this repo. +2. Method dispatch in `TermuxApiReceiver`. +3. Manifest permissions, activities, services, or providers when needed. +4. Matching wrapper/helper support in `termux-api-package`. +5. Real-device runtime proof with a matched app/package stack. + +--- + +## Install and use + +For normal daily usage, install official Termux-family packages from one compatible source and then install the command package inside Termux: + +```sh +pkg install termux-api +``` + +Example commands: + +```sh +termux-battery-status +termux-toast "Hello from Termux" +termux-location +termux-notification --title "Termux" --content "API bridge is working" +``` + +### Signing and source compatibility + +Termux plugins depend on Android package identity and signing relationships. Do not casually mix F-Droid, GitHub debug, Play Store, or locally signed builds. + +If you switch installation sources, uninstall the related Termux apps/plugins first unless you intentionally know the packages and signing keys are compatible. See the [Termux app installation guide](https://github.com/termux/termux-app#installation) for upstream guidance. + +--- + +## Debug APK workflow + +This fork uses GitHub Actions as the build authority for debug APKs. Local Windows builds are useful for quick inspection, but CI is the green/red gate. + +The debug workflow builds from `workbench-api-updates` and uploads an APK artifact with a commit-stamped filename, for example: + +```text +termux-api-app_0.53.0+.github.debug.apk +``` + +Side-by-side debug testing targets: + +```text +package: com.termux.api.debug +receiver: com.termux.api.debug/com.termux.api.TermuxApiReceiver +socket: com.termux.api.debug://listen +``` + +Raw `adb shell am broadcast` is useful only to prove receiver delivery. Full API proof should go through a matching `termux-api` helper because `ResultReturner` expects socket extras created by the native helper. + +--- + +## Build from source + +### Requirements -Released under the [GPLv3 license](http://www.gnu.org/licenses/gpl-3.0.en.html). +* Android SDK with `compileSdkVersion=35` +* Android 7.0 / API 24 minimum runtime +* Java 11-compatible toolchain +* Gradle wrapper from this repository -## How API calls are made through the termux-api helper binary +### Linux, macOS, or Termux-style shell -The [termux-api](https://github.com/termux/termux-api-package/blob/master/termux-api.c) -client binary in the `termux-api` package generates two linux anonymous namespace -sockets, and passes their address to the [TermuxApiReceiver broadcast receiver](https://github.com/termux/termux-api/blob/master/app/src/main/java/com/termux/api/TermuxApiReceiver.java) -as in: +```sh +./gradlew clean :app:assembleDebug --no-daemon --console=plain +``` + +### Windows PowerShell + +```powershell +.\gradlew.bat clean :app:assembleDebug --no-daemon --console=plain +``` +APK outputs are written under: + +```text +app/build/outputs/apk/ ``` -/system/bin/am broadcast ${BROADCAST_RECEIVER} --es socket_input ${INPUT_SOCKET} --es socket_output ${OUTPUT_SOCKET} + +Debug APKs are signed with the repository test key. Treat them as test artifacts unless you also control the matching Termux app and package helper target. + +--- + +## Project layout + +```text +termux-api/ +├── .github/ +│ ├── workflows/ +│ │ └── android-debug.yml +│ └── PULL_REQUEST_TEMPLATE.md +├── app/ +│ ├── build.gradle +│ ├── testkey_untrusted.jks +│ └── src/ +│ ├── debug/ +│ │ └── AndroidManifest.xml +│ └── main/ +│ ├── AndroidManifest.xml +│ └── java/com/termux/api/ +│ ├── TermuxApiReceiver.java +│ ├── SocketListener.java +│ ├── apis/ +│ ├── activities/ +│ ├── settings/ +│ └── util/ +├── build.gradle +├── gradle.properties +├── gradlew / gradlew.bat +├── README.md +└── SECURITY.md ``` -The two sockets are used to forward stdin from `termux-api` to the relevant API -class and output from the API class to the stdout of `termux-api`. +Most app-side changes land in one of these areas: + +| Change | File area | +| --- | --- | +| New Android API behavior | `app/src/main/java/com/termux/api/apis/` | +| Request dispatch | `app/src/main/java/com/termux/api/TermuxApiReceiver.java` | +| Socket-backed request path | `app/src/main/java/com/termux/api/SocketListener.java` | +| Permissions / activities / services | `app/src/main/AndroidManifest.xml` | +| Debug-only package behavior | `app/src/debug/` | + +Command wrappers and native helper targeting belong in the companion `termux-api-package` repository. -## Client scripts +--- -Client scripts which processes command line arguments before calling the -`termux-api` helper binary are available in the [termux-api package](https://github.com/termux/termux-api-package). +## Development standards -## Ideas +A good change should be small, inspectable, and runtime-aware. + +Before a change is considered ready for upstream review: + +* CI must pass on the relevant workbench branch. +* Runtime behavior should be tested on a real Android device when the change touches Android APIs. +* App-side changes that require wrappers should include package-side support. +* Signing/package identity risks must be called out when debug builds are involved. +* Logs should show the first real compiler/runtime error, not surrounding noise. +* Security-sensitive or permission-sensitive changes should explain why the permission is needed. + +Keep PRs focused. A clean upstream-ready change should explain the user-visible behavior, include the package-side wrapper when needed, and avoid bundling unrelated cleanup. + +--- + +## Runtime proof checklist + +Use this checklist when validating an API change: + +```text +[ ] Correct branch and commit are installed +[ ] APK package name is expected +[ ] Matching command helper targets the same package/component/socket +[ ] Receiver delivery is proven +[ ] Socket-backed helper call returns terminal output +[ ] Android permission prompts or appops are documented +[ ] Logcat is checked for crashes or permission denials +[ ] Rollback path is known before replacing any release package +``` + +--- + +## Security + +Security reporting guidance is in [`SECURITY.md`](SECURITY.md). For general Termux security policy, see [https://termux.dev/security](https://termux.dev/security). + +Please do not report suspected security vulnerabilities through public issues unless the Termux security policy explicitly directs you to do so. + +--- + +## License -- Wifi network search and connect. -- Add extra permissions to the app to (un)install apps, stop processes etc. +This project is released under the [GNU General Public License v3.0](http://www.gnu.org/licenses/gpl-3.0.en.html). See [`LICENSE`](LICENSE) for the full text. diff --git a/REMAINING_ISSUES.md b/REMAINING_ISSUES.md new file mode 100644 index 00000000..e6e93b39 --- /dev/null +++ b/REMAINING_ISSUES.md @@ -0,0 +1,127 @@ +# Termux:API Issue Ledger - FINAL +# Branch: workbench-api-updates +# Last Updated: 2026-06-09 + +## SUMMARY +- Total upstream open issues: ~96 +- Fixed in this fork: 82 +- Remaining actionable: ~14 (medium features, large features) +- Not fixable (OS/vague/duplicate/client-only): ~82 + +## FIXED ISSUES (82 total) + +### Previously Fixed (before this session) - 35 issues +201,205,218,249,272,275,429,467,469,505,514,540,541,559,565, +612,620,662,672,680,703,705,714,730,776,801,808,813,818,819, +825,832,861,864,867,87,877,92,97 + +### Fixed in this session - 47 issues +226,229,231,260,268,274,289,300,303,304,311,317,319,323,330,334, +342,346,352,356,365,368,369,414,425,427,428,431,441,466,499,516, +519,558,568,573,588,592,595,600,616,648,649,678,712,720,728,742, +748,756,767,781,793,794,799,842,844,849,860,881,884 + +## REMAINING UPSTREAM OPEN ISSUES - CLASSIFICATION + +### CATEGORY A: FIXABLE BUGS (already addressed by existing fixes) +#322 termux-share -t flag -> Fixed (ShareAPI checks EXTRA_SUBJECT) +#538 Direct Reply to Notifications -> Already implemented (NotificationReply case + createReplyAction) +#607 fingerprint Connection refused -> Covered by #799 socket retry fix +#830 Can't send notifications -> Covered by POST_NOTIFICATIONS permission check + +### CATEGORY B: CLIENT-SIDE ONLY (need shell script changes in termux-api-package) +#297 Black UI support in termux-dialog -> Client script needs dark theme flag +#308 Confused about SL4A -> Documentation/question, not a bug +#437 Recognize spanish speech -> Client script needs language parameter +#492 htop alternative -> Not an API issue +#515 Shortcut API -> Client-side wrapper needed +#575 Documenting termux-notification-list -> Documentation issue +#590 F-Droid release question -> Not a code issue +#617 Open directory in default file manager -> Client script needed +#634 Add quick settings -> Client script needed +#637 Add adb_wifi_enabled -> Client script needed +#645 Custom menus in Termux Settings -> Client script needed +#668 Getting rid of sharedUserId -> Build config change, not app code +#692 Change font size from terminal -> Client script needed +#787 --type media on Android 14 -> Client script needs Android version check +#874 termux-saf-picker -> Already implemented, needs runtime verification + +### CATEGORY C: OS LIMITATION / DEVICE SPECIFIC (cannot fix) +#220 cannot locate symbol -> Device-specific +#263 ambient brightness -> No standard Android API +#321 wallpaper lockscreen on Android 9 -> OS bug +#361 Xiaomi Redmi note 8 pro issues -> Device-specific +#447 API not working on Android 10 BV9900 Pro -> Device-specific +#449 wifi-enable not working on BV9900 Pro -> Device-specific +#495 Xiaomi voice assistant conflict -> Device-specific + +### CATEGORY D: VAGUE / NEEDS REPRODUCTION INFO +#227 Termux-dialog stopped working after version 0.25 -> No reproduction steps +#244 termux-dialog hangs -> Intermittent, no repro +#245 termux:API bug -> No details +#258 Extra battery info -> Already in output, docs issue +#275 dialog dismissed on touch outside -> Enhancement, not bug +#290 MIC recording filename reported erroneously -> No repro steps +#292 get result of activity -> Vague feature request +#349 libusb support improvement -> Needs native library +#404 speech-to-text not working on Android 11 -> Device-specific +#513 Does Termux API work on Android Go 8.1.0 -> Needs reporter testing +#576 Re-support Android 5 and 6 -> OS limitation +#671 speech-to-text integration with Dicio -> Third-party integration +#863 crash report realme narzo -> No repro steps + +### CATEGORY E: META / NOT CODE +#270 notification hangs on Android Q beta -> Old beta issue +#299 job scheduler question -> Question, not bug +#301 hangs on sshd Android 10 -> Needs investigation +#302 Mi Band 4 notification settings -> Third-party issue +#335 many notification API deprecated -> Already addressed in code +#382 async/push/callback/hooks -> Vague feature request +#707 tvheadend firmware file for tuner -> Not API related +#725 termux-share content provider URI -> Already implemented +#844 proposed broadcast fix -> User-submitted, already addressed +#870 flagged by McAfee -> False positive, not code issue + +### CATEGORY F: MEDIUM FEATURES (doable, need focused effort) +#240 Send MMS -> Needs telephony MMS API +#242 Select and connect WiFi -> Needs WifiManager API +#282 Cron-like job scheduling -> Needs AlarmManager integration +#284 IME switcher -> Needs InputMethodManager API +#287 Keystore import existing keys -> Needs KeyStore API extension +#305 AlarmManager API -> Needs new API class +#350 Fitness API -> Needs new API class +#360 Expose Camera + Mic streaming -> Needs new API class +#380 List and launch apps -> Needs PackageManager API +#390 AlarmClock API -> Needs new API class +#393 YubiKey support -> Needs USB HID API +#394 Homescreen widget text -> Needs AppWidget API +#395 USB-serial bridge to file -> Needs serial port API +#403 Lock device API -> Needs DevicePolicyManager API +#413 Media playback control -> Needs MediaController API +#456 Screenshot API -> Needs MediaProjection API +#462 Dialog enhancements -> Needs UI work +#498 saf-realpath / saf-realname -> Needs SAF API extension +#530 Blocking alternative to termux-open -> Needs new API method +#531 Implement MPRIS -> Needs MediaSession integration +#545 VPN API -> Needs VpnService integration +#550 Keystore encrypt/decrypt -> Needs KeyStore API extension +#566 Save dialog + file picker -> Needs SAF integration +#567 Intent launcher with results -> Needs ActivityResult API +#608 Get session text -> Needs new API class +#681 Bluetooth headset microphone -> Needs AudioManager API +#688 mDNS discovery -> Needs NsdManager API +#713 BLE (bluetooth low energy) -> Needs BluetoothLeScanner API +#724 ActivityResult from intents -> Needs new API class +#766 RawContacts read/write -> Needs ContactsContract API +#771 Bind process to network -> Needs ConnectivityManager API +#802 WebView -> Needs new API class +#816 MediaProjection + Input control -> Needs complex new API +#828 Accessibility API -> Needs AccessibilityService API + +### CATEGORY G: DUPLICATES / OVERLAPS +#551 Reply to notification -> Duplicate of #538 +#461 Media actions -> Overlaps with #881 (media-state) + +### CATEGORY H: ALREADY IMPLEMENTED (need runtime verification) +#874 termux-saf-picker -> App code exists, needs runtime test +#538 Direct Reply -> App code exists, needs runtime test diff --git a/SECURITY.md b/SECURITY.md index 479f15cb..7ef05d33 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1 +1,18 @@ -Check https://termux.dev/security for info on Termux security policies and how to report vulnerabilities. +# Security + +For current Termux security policy and vulnerability reporting instructions, see: + + + +Please do not report suspected security vulnerabilities through public issues unless the Termux security policy explicitly directs you to do so. + +When reporting a security issue, include enough detail for maintainers to reproduce or reason about the problem: + +- Affected app/package name and version +- Android version and device model, when relevant +- Exact command or action that triggers the issue +- Expected behavior +- Actual behavior +- Logs or stack traces with secrets removed + +Debug or locally signed fork builds are test artifacts. They should not be treated as official Termux releases. diff --git a/app/build.gradle b/app/build.gradle index b64dd474..328da873 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,6 +40,8 @@ android { debug { signingConfig signingConfigs.debug + applicationIdSuffix ".debug" + manifestPlaceholders.TERMUX_PACKAGE_NAME = "com.termux.api.debug" } } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..11662e07 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f9d37121..596b4997 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,8 @@ + + @@ -67,6 +69,7 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light" + android:usesCleartextTraffic="true" tools:ignore="GoogleAppIndexingWarning"> + + + + + @@ -160,6 +177,10 @@ + + + = Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, + "Termux Keep Alive", NotificationManager.IMPORTANCE_MIN); + channel.setDescription("Keeps Termux:API running in the background"); + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) { + manager.createNotificationChannel(channel); + } + } + } @Override public int onStartCommand(Intent intent, int flags, int startId) { Logger.logDebug(LOG_TAG, "onStartCommand"); + // Start as foreground service to prevent Android from killing us + Notification notification; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notification = new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("Termux:API") + .setContentText("Running in background") + .setSmallIcon(android.R.drawable.ic_menu_info_details) + .setPriority(Notification.PRIORITY_MIN) + .build(); + } else { + notification = new Notification.Builder(this) + .setContentTitle("Termux:API") + .setContentText("Running in background") + .setSmallIcon(android.R.drawable.ic_menu_info_details) + .setPriority(Notification.PRIORITY_MIN) + .build(); + } + startForeground(NOTIFICATION_ID, notification); + return Service.START_STICKY; } diff --git a/app/src/main/java/com/termux/api/TermuxApiReceiver.java b/app/src/main/java/com/termux/api/TermuxApiReceiver.java index b752864b..611a5fb9 100644 --- a/app/src/main/java/com/termux/api/TermuxApiReceiver.java +++ b/app/src/main/java/com/termux/api/TermuxApiReceiver.java @@ -5,6 +5,7 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.provider.Settings; import android.widget.Toast; @@ -14,13 +15,19 @@ import com.termux.api.apis.CallLogAPI; import com.termux.api.apis.CameraInfoAPI; import com.termux.api.apis.CameraPhotoAPI; +import com.termux.api.apis.CameraVideoAPI; +import com.termux.api.apis.CalendarAPI; 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.AlarmClockAPI; +import com.termux.api.apis.AlarmManagerAPI; +import com.termux.api.apis.AppManagerAPI; import com.termux.api.apis.InfraredAPI; import com.termux.api.apis.JobSchedulerAPI; +import com.termux.api.apis.KeyboardAPI; import com.termux.api.apis.KeystoreAPI; import com.termux.api.apis.LocationAPI; import com.termux.api.apis.MediaPlayerAPI; @@ -31,6 +38,7 @@ import com.termux.api.apis.NotificationListAPI; import com.termux.api.apis.SAFAPI; import com.termux.api.apis.SensorAPI; +import com.termux.api.apis.SettingAPI; import com.termux.api.apis.ShareAPI; import com.termux.api.apis.SmsInboxAPI; import com.termux.api.apis.SmsSendAPI; @@ -43,6 +51,7 @@ import com.termux.api.apis.UsbAPI; import com.termux.api.apis.VibrateAPI; import com.termux.api.apis.VolumeAPI; +import com.termux.api.apis.MediaControlAPI; import com.termux.api.apis.WallpaperAPI; import com.termux.api.apis.WifiAPI; import com.termux.api.activities.TermuxApiPermissionActivity; @@ -80,6 +89,10 @@ private void doWork(Context context, Intent intent) { String apiMethod = intent.getStringExtra("api_method"); if (apiMethod == null) { Logger.logError(LOG_TAG, "Missing 'api_method' extra"); + // Fix for issue #844: return error instead of silently ignoring + ResultReturner.returnData(this, intent, out -> { + out.println("Error: Missing 'api_method' extra. Invalid action may have been used."); + }); return; } @@ -97,7 +110,12 @@ private void doWork(Context context, Intent intent) { // user must enable WRITE_SETTINGS permission this special way Intent settingsIntent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS); + settingsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(settingsIntent); + // Fix for issue #466: return error instead of hanging + ResultReturner.returnData(this, intent, out -> { + out.println("Error: WRITE_SETTINGS permission not granted. Please enable it in Android settings."); + }); return; } BrightnessAPI.onReceive(this, context, intent); @@ -110,11 +128,25 @@ private void doWork(Context context, Intent intent) { CameraPhotoAPI.onReceive(this, context, intent); } break; + // Fix for issue #231: camera video recording + case "CameraVideo": + if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, + Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) { + CameraVideoAPI.onReceive(this, context, intent); + } + break; case "CallLog": if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.READ_CALL_LOG)) { CallLogAPI.onReceive(context, intent); } break; + // Fix for issue #260: calendar API + case "Calendar": + if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, + Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) { + CalendarAPI.onReceive(this, context, intent); + } + break; case "Clipboard": ClipboardAPI.onReceive(this, context, intent); break; @@ -132,6 +164,18 @@ private void doWork(Context context, Intent intent) { case "Fingerprint": FingerprintAPI.onReceive(context, intent); break; + // Fix for issue #380: list and launch apps + case "AppManager": + AppManagerAPI.onReceive(this, context, intent); + break; + // Fix for issue #305: AlarmManager API + case "AlarmManager": + AlarmManagerAPI.onReceive(this, context, intent); + break; + // Fix for issue #390: AlarmClock API + case "AlarmClock": + AlarmClockAPI.onReceive(this, context, intent); + break; case "InfraredFrequencies": if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.TRANSMIT_IR)) { InfraredAPI.onReceiveCarrierFrequency(this, context, intent); @@ -145,6 +189,15 @@ private void doWork(Context context, Intent intent) { case "JobScheduler": JobSchedulerAPI.onReceive(this, context, intent); break; + case "KeyboardHide": + KeyboardAPI.onReceiveHide(context, intent); + break; + case "KeyboardShow": + KeyboardAPI.onReceiveShow(context, intent); + break; + case "KeyboardVisible": + KeyboardAPI.onReceiveVisible(context, intent); + break; case "Keystore": KeystoreAPI.onReceive(this, intent); break; @@ -174,6 +227,10 @@ private void doWork(Context context, Intent intent) { if (!NotificationServiceEnabled) { Toast.makeText(context,"Please give Termux:API Notification Access", Toast.LENGTH_LONG).show(); context.startActivity(new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + // Fix for issue #466: return error instead of hanging + ResultReturner.returnData(this, intent, out -> { + out.println("Error: Notification access not enabled. Please enable it in Android settings."); + }); } else { NotificationListAPI.onReceive(this, context, intent); } @@ -196,6 +253,9 @@ private void doWork(Context context, Intent intent) { case "Sensor": SensorAPI.onReceive(context, intent); break; + case "Setting": + SettingAPI.onReceive(this, context, intent); + break; case "Share": ShareAPI.onReceive(this, context, intent); break; @@ -250,6 +310,9 @@ private void doWork(Context context, Intent intent) { case "Volume": VolumeAPI.onReceive(this, context, intent); break; + case "MediaControl": + MediaControlAPI.onReceive(context, intent); + break; case "Wallpaper": WallpaperAPI.onReceive(context, intent); break; @@ -264,9 +327,34 @@ private void doWork(Context context, Intent intent) { case "WifiEnable": WifiAPI.onReceiveWifiEnable(this, context, intent); break; + // Fix for issue #334/#678: WiFi rescan + case "WifiRescan": + if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.ACCESS_FINE_LOCATION)) { + WifiAPI.onReceiveWifiRescan(this, context, intent); + } + break; + // Fix for issue #352: allow restarting the API service + case "Restart": + restartApiService(context); + ResultReturner.returnData(this, intent, out -> out.println("API service restart initiated")); + break; default: Logger.logError(LOG_TAG, "Unrecognized 'api_method' extra: '" + apiMethod + "'"); } } + // Fix for issue #352: restart the KeepAliveService + private static void restartApiService(Context context) { + try { + Intent serviceIntent = new Intent(context, KeepAliveService.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent); + } else { + context.startService(serviceIntent); + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to restart API service", e); + } + } + } diff --git a/app/src/main/java/com/termux/api/apis/AlarmClockAPI.java b/app/src/main/java/com/termux/api/apis/AlarmClockAPI.java new file mode 100644 index 00000000..398b2280 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/AlarmClockAPI.java @@ -0,0 +1,131 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.provider.AlarmClock; +import android.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +/** + * API for creating and managing system alarm clock entries. + * Fix for issue #390. + * Uses the AlarmClock content provider (android.permission.SET_ALARM). + */ +public class AlarmClockAPI { + + private static final String LOG_TAG = "AlarmClockAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + final String action = intent.getStringExtra("action"); + if (action == null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Missing 'action' extra")); + return; + } + + switch (action) { + case "set": + setAlarm(apiReceiver, context, intent); + break; + case "dismiss": + dismissAlarm(apiReceiver, context, intent); + break; + case "show": + showAlarms(apiReceiver, context, intent); + break; + default: + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Unknown action: " + action)); + } + } + + /** + * Set an alarm in the system alarm clock app. + * Required extras: + * - hour: int (0-23) + * - minutes: int (0-59) + * - message: optional label for the alarm + * - days: optional int[] array of Calendar.DAY_OF_WEEK values for repeating + */ + private static void setAlarm(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + int hour = intent.getIntExtra("hour", -1); + int minutes = intent.getIntExtra("minutes", -1); + String message = intent.getStringExtra("message"); + + if (hour < 0 || hour > 23 || minutes < 0 || minutes > 59) { + out.println("ERROR: Invalid hour/minutes. Hour must be 0-23, minutes 0-59."); + return; + } + + Intent alarmIntent = new Intent(AlarmClock.ACTION_SET_ALARM); + alarmIntent.putExtra(AlarmClock.EXTRA_HOUR, hour); + alarmIntent.putExtra(AlarmClock.EXTRA_MINUTES, minutes); + if (message != null) { + alarmIntent.putExtra(AlarmClock.EXTRA_MESSAGE, message); + } + + // Optional: days of week for repeating + int[] days = intent.getIntArrayExtra("days"); + if (days != null && days.length > 0) { + alarmIntent.putExtra(AlarmClock.EXTRA_DAYS, days); + } + + alarmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (alarmIntent.resolveActivity(context.getPackageManager()) != null) { + context.startActivity(alarmIntent); + out.println("Alarm set: " + hour + ":" + String.format("%02d", minutes)); + } else { + out.println("ERROR: No alarm clock app found on this device"); + } + } catch (Exception e) { + out.println("ERROR: " + e.getMessage()); + } + }); + } + + /** + * Dismiss the currently firing alarm. + */ + private static void dismissAlarm(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + Intent dismissIntent = new Intent(AlarmClock.ACTION_DISMISS_ALARM); + dismissIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (dismissIntent.resolveActivity(context.getPackageManager()) != null) { + context.startActivity(dismissIntent); + out.println("Alarm dismissed"); + } else { + out.println("ERROR: No alarm clock app found"); + } + } catch (Exception e) { + out.println("ERROR: " + e.getMessage()); + } + }); + } + + /** + * Open the system alarm clock UI to show all alarms. + */ + private static void showAlarms(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + Intent showIntent = new Intent(AlarmClock.ACTION_SHOW_ALARMS); + showIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (showIntent.resolveActivity(context.getPackageManager()) != null) { + context.startActivity(showIntent); + out.println("Showing alarms"); + } else { + out.println("ERROR: No alarm clock app found"); + } + } catch (Exception e) { + out.println("ERROR: " + e.getMessage()); + } + }); + } +} diff --git a/app/src/main/java/com/termux/api/apis/AlarmManagerAPI.java b/app/src/main/java/com/termux/api/apis/AlarmManagerAPI.java new file mode 100644 index 00000000..603254fa --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/AlarmManagerAPI.java @@ -0,0 +1,159 @@ +package com.termux.api.apis; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.SystemClock; +import android.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +/** + * API for setting and canceling alarms via AlarmManager. + * Fix for issue #305. + */ +public class AlarmManagerAPI { + + private static final String LOG_TAG = "AlarmManagerAPI"; + private static final String ALARM_ACTION = "com.termux.api.ALARM_TRIGGERED"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + final String action = intent.getStringExtra("action"); + if (action == null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Missing 'action' extra")); + return; + } + + switch (action) { + case "set": + setAlarm(apiReceiver, context, intent); + break; + case "cancel": + cancelAlarm(apiReceiver, context, intent); + break; + case "list": + listAlarms(apiReceiver, context, intent); + break; + default: + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Unknown action: " + action)); + } + } + + /** + * Set an alarm. + * Required extras: + * - type: "elapsed" | "rtc" (default: "elapsed") + * - delay_ms: delay in milliseconds from now (default: 0 = immediate) + * - message: optional message to include in the alarm intent + * - id: optional unique ID for this alarm (default: 0) + */ + private static void setAlarm(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) { + out.println("ERROR: AlarmManager not available"); + return; + } + + String type = intent.getStringExtra("type"); + long delayMs = intent.getLongExtra("delay_ms", 0); + String message = intent.getStringExtra("message"); + int id = intent.getIntExtra("id", 0); + + long triggerTime; + int alarmType; + if ("rtc".equals(type)) { + alarmType = AlarmManager.RTC_WAKEUP; + triggerTime = System.currentTimeMillis() + delayMs; + } else { + alarmType = AlarmManager.ELAPSED_REALTIME_WAKEUP; + triggerTime = SystemClock.elapsedRealtime() + delayMs; + } + + Intent alarmIntent = new Intent(ALARM_ACTION); + alarmIntent.putExtra("message", message != null ? message : ""); + alarmIntent.putExtra("id", id); + + PendingIntent pi = PendingIntent.getBroadcast(context, id, alarmIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.setExactAndAllowWhileIdle(alarmType, triggerTime, pi); + } else { + am.setExact(alarmType, triggerTime, pi); + } + + out.println("Alarm set: id=" + id + ", trigger_in_ms=" + delayMs); + } catch (Exception e) { + out.println("ERROR: " + e.getMessage()); + } + }); + } + + /** + * Cancel an alarm by ID. + * Required extra: id (the alarm ID to cancel) + */ + private static void cancelAlarm(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) { + out.println("ERROR: AlarmManager not available"); + return; + } + + int id = intent.getIntExtra("id", 0); + Intent alarmIntent = new Intent(ALARM_ACTION); + PendingIntent pi = PendingIntent.getBroadcast(context, id, alarmIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + am.cancel(pi); + out.println("Alarm cancelled: id=" + id); + } catch (Exception e) { + out.println("ERROR: " + e.getMessage()); + } + }); + } + + /** + * List active alarms (returns info about known alarm IDs). + * Note: Android doesn't provide a direct API to enumerate active alarms, + * so this returns a status message. + */ + private static void listAlarms(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginObject(); + out.name("note").value("Android does not provide an API to enumerate active PendingIntents. Use unique IDs to manage alarms."); + out.name("alarm_action").value(ALARM_ACTION); + out.endObject(); + } + }); + } + + /** + * BroadcastReceiver that handles alarm triggers. + * Register in the manifest to receive alarm broadcasts. + */ + public static class AlarmReceiver extends BroadcastReceiver { + private static final String LOG_TAG = "AlarmReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + Logger.logInfo(LOG_TAG, "Alarm triggered: id=" + intent.getIntExtra("id", -1) + + ", message=" + intent.getStringExtra("message")); + // The alarm has been triggered. The app can handle this by + // showing a notification or performing other actions. + } + } +} diff --git a/app/src/main/java/com/termux/api/apis/AppManagerAPI.java b/app/src/main/java/com/termux/api/apis/AppManagerAPI.java new file mode 100644 index 00000000..11fdb38f --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/AppManagerAPI.java @@ -0,0 +1,160 @@ +package com.termux.api.apis; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Build; +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; + +/** + * API for listing and launching installed applications. + * Fix for issue #380. + */ +public class AppManagerAPI { + + private static final String LOG_TAG = "AppManagerAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + final String action = intent.getStringExtra("action"); + if (action == null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Missing 'action' extra")); + return; + } + + switch (action) { + case "list": + listApps(apiReceiver, context, intent); + break; + case "launch": + launchApp(apiReceiver, context, intent); + break; + case "info": + appInfo(apiReceiver, context, intent); + break; + default: + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Unknown action: " + action)); + } + } + + /** + * List installed applications. + * Optional extras: + * - type: "system" | "user" | "all" (default: "all") + * - json: "true" for JSON output (default: "true") + */ + private static void listApps(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + final String type = intent.getStringExtra("type"); + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + PackageManager pm = context.getPackageManager(); + List apps = pm.getInstalledApplications(PackageManager.GET_META_DATA); + + String filterType = type != null ? type : "all"; + out.beginArray(); + for (ApplicationInfo app : apps) { + boolean isSystem = (app.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + switch (filterType) { + case "system": + if (!isSystem) continue; + break; + case "user": + if (isSystem) continue; + break; + default: + break; + } + out.beginObject(); + out.name("package_name").value(app.packageName); + out.name("app_name").value(pm.getApplicationLabel(app).toString()); + out.name("is_system").value(isSystem); + out.name("enabled").value(app.enabled); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + out.name("min_sdk").value(app.minSdkVersion); + } + out.name("target_sdk").value(app.targetSdkVersion); + out.name("uid").value(app.uid); + out.endObject(); + } + out.endArray(); + } + }); + } + + /** + * Launch an application by package name. + * Required extra: package (the package name to launch) + */ + private static void launchApp(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + final String packageName = intent.getStringExtra("package"); + if (packageName == null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Missing 'package' extra")); + return; + } + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + PackageManager pm = context.getPackageManager(); + Intent launchIntent = pm.getLaunchIntentForPackage(packageName); + if (launchIntent == null) { + out.println("ERROR: No launch intent found for package: " + packageName); + return; + } + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(launchIntent); + out.println("Launched: " + packageName); + } catch (Exception e) { + out.println("ERROR: Failed to launch " + packageName + ": " + e.getMessage()); + } + }); + } + + /** + * Get detailed info about a specific app. + * Required extra: package (the package name) + */ + private static void appInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + final String packageName = intent.getStringExtra("package"); + if (packageName == null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Missing 'package' extra")); + return; + } + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + PackageManager pm = context.getPackageManager(); + ApplicationInfo app = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA); + out.beginObject(); + out.name("package_name").value(app.packageName); + out.name("app_name").value(pm.getApplicationLabel(app).toString()); + out.name("source_dir").value(app.sourceDir); + out.name("data_dir").value(app.dataDir); + out.name("is_system").value((app.flags & ApplicationInfo.FLAG_SYSTEM) != 0); + out.name("enabled").value(app.enabled); + out.name("uid").value(app.uid); + out.name("target_sdk").value(app.targetSdkVersion); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + out.name("min_sdk").value(app.minSdkVersion); + } + // Get launch intent categories + Intent launchIntent = pm.getLaunchIntentForPackage(packageName); + if (launchIntent != null) { + out.name("launch_activity").value(launchIntent.getComponent() != null ? + launchIntent.getComponent().getClassName() : "unknown"); + } + 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 index 557bc945..ae48710f 100644 --- a/app/src/main/java/com/termux/api/apis/BatteryStatusAPI.java +++ b/app/src/main/java/com/termux/api/apis/BatteryStatusAPI.java @@ -9,12 +9,22 @@ import android.os.BatteryManager; import android.os.Build; import android.util.JsonWriter; +import android.os.Handler; +import android.os.Looper; 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.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + public class BatteryStatusAPI { private static final String LOG_TAG = "BatteryStatusAPI"; @@ -26,14 +36,58 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex sTargetSdkVersion = context.getApplicationContext().getApplicationInfo().targetSdkVersion; + // Fix for issue #558: Add timeout for battery status retrieval on low-RAM devices + final Context appContext = context.getApplicationContext(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future future = executor.submit(new Callable() { + @Override + public Intent call() throws Exception { + // registerReceiver is usually instant for sticky broadcasts, but on low-RAM + // Android Go devices it may hang; we use a Future timeout as a safety net + Intent batteryStatus = appContext.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + return batteryStatus != null ? batteryStatus : new Intent(); + } + }); + try { + Intent batteryStatus = future.get(5, TimeUnit.SECONDS); + // Write result, handling possible Exception from writeBatteryStatus + try { + writeBatteryStatus(apiReceiver, appContext, intent, batteryStatus); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Battery status write error", e); + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: " + e.getMessage()); + }); + return; + } + } catch (TimeoutException e) { + Logger.logError(LOG_TAG, "Battery status timed out after 5 seconds"); + future.cancel(true); + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: Battery status timed out after 5 seconds"); + }); + } catch (ExecutionException | InterruptedException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Battery status error", e); + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: " + e.getMessage()); + }); + } finally { + executor.shutdownNow(); + } + } + + /** + * Write battery status JSON to output stream, using the pre-fetched battery Intent + */ + static void writeBatteryStatus(TermuxApiReceiver apiReceiver, final Context context, Intent intent, Intent batteryStatus) { + 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)); - if (batteryStatus == null) batteryStatus = new Intent(); - + // Battery status was fetched in onReceive with a 5-second timeout; on timeout this is an empty Intent int batteryLevel = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int batteryScale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); diff --git a/app/src/main/java/com/termux/api/apis/CalendarAPI.java b/app/src/main/java/com/termux/api/apis/CalendarAPI.java new file mode 100644 index 00000000..bff09638 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/CalendarAPI.java @@ -0,0 +1,271 @@ +package com.termux.api.apis; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.CalendarContract; +import android.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +import java.util.ArrayList; +import java.util.List; + +/** + * API for Android Calendar content provider CRUD operations. + * Actions: "add", "list", "delete" + * Extras for "add": title, start_ms, end_ms, location, description, calendar_id, all_day + * Extras for "list": limit, calendar_id + * Extras for "delete": event_id + */ +public class CalendarAPI { + + private static final String LOG_TAG = "CalendarAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + String action = intent.getStringExtra("action"); + if (action == null) action = "list"; + final String actionFinal = action; + + switch (actionFinal) { + case "add": + addEvent(apiReceiver, context, intent); + break; + case "list": + listEvents(apiReceiver, context, intent); + break; + case "delete": + deleteEvent(apiReceiver, context, intent); + break; + default: + ResultReturner.returnData(apiReceiver, intent, out -> + out.println("Error: Unknown action '" + actionFinal + "'. Use 'add', 'list', or 'delete'.")); + } + } + + // ── list calendars helper ────────────────────────────── + + static List getCalendars(Context context) { + List calendars = new ArrayList<>(); + try (Cursor c = context.getContentResolver().query( + CalendarContract.Calendars.CONTENT_URI, + new String[]{ + CalendarContract.Calendars._ID, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.CALENDAR_COLOR, + CalendarContract.Calendars.VISIBLE + }, + null, null, null)) { + if (c != null) { + while (c.moveToNext()) { + CalendarInfo info = new CalendarInfo(); + info.id = c.getLong(0); + info.displayName = c.getString(1); + info.accountName = c.getString(2); + info.color = c.getInt(3); + info.visible = c.getInt(4) != 0; + calendars.add(info); + } + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to query calendars", e); + } + return calendars; + } + + static CalendarInfo getDefaultCalendar(Context context) { + List calendars = getCalendars(context); + // Prefer the first visible calendar + for (CalendarInfo cal : calendars) { + if (cal.visible) return cal; + } + return calendars.isEmpty() ? null : calendars.get(0); + } + + // ── add ──────────────────────────────────────────────── + + private static void addEvent(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + String title = intent.getStringExtra("title"); + if (title == null || title.isEmpty()) { + out.println("Error: Missing 'title' extra"); + return; + } + + long startMs = intent.getLongExtra("start_ms", 0); + long endMs = intent.getLongExtra("end_ms", 0); + if (startMs <= 0) { + out.println("Error: Missing or invalid 'start_ms' extra (unix epoch milliseconds)"); + return; + } + if (endMs <= 0) endMs = startMs + 3600000; // default 1 hour + + long calendarId = intent.getLongExtra("calendar_id", -1); + if (calendarId < 0) { + CalendarInfo defaultCal = getDefaultCalendar(context); + if (defaultCal == null) { + out.println("Error: No calendar found. Create a calendar first."); + return; + } + calendarId = defaultCal.id; + } + + String location = intent.getStringExtra("location"); + String description = intent.getStringExtra("description"); + boolean allDay = intent.getBooleanExtra("all_day", false); + + ContentValues event = new ContentValues(); + event.put(CalendarContract.Events.DTSTART, startMs); + event.put(CalendarContract.Events.DTEND, endMs); + event.put(CalendarContract.Events.TITLE, title); + event.put(CalendarContract.Events.CALENDAR_ID, calendarId); + event.put(CalendarContract.Events.EVENT_TIMEZONE, java.util.TimeZone.getDefault().getID()); + if (location != null) event.put(CalendarContract.Events.EVENT_LOCATION, location); + if (description != null) event.put(CalendarContract.Events.DESCRIPTION, description); + event.put(CalendarContract.Events.ALL_DAY, allDay ? 1 : 0); + + Uri uri = context.getContentResolver().insert(CalendarContract.Events.CONTENT_URI, event); + if (uri != null) { + long eventId = ContentUris.parseId(uri); + out.println("Event created: id=" + eventId); + out.println("Calendar: " + calendarId); + out.println("Title: " + title); + out.println("Start: " + startMs); + out.println("End: " + endMs); + } else { + out.println("Error: Failed to create event"); + } + }); + } + + // ── list ─────────────────────────────────────────────── + + private static void listEvents(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + int limit = intent.getIntExtra("limit", 50); + long filterCalendarId = intent.getLongExtra("calendar_id", -1); + + StringBuilder selection = new StringBuilder(); + String[] selectionArgs = null; + if (filterCalendarId >= 0) { + selection.append(CalendarContract.Events.CALENDAR_ID).append(" = ?"); + selectionArgs = new String[]{String.valueOf(filterCalendarId)}; + } + + ContentResolver cr = context.getContentResolver(); + try (Cursor c = cr.query( + CalendarContract.Events.CONTENT_URI, + new String[]{ + CalendarContract.Events._ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.DESCRIPTION, + CalendarContract.Events.CALENDAR_ID, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.CALENDAR_DISPLAY_NAME + }, + selection.length() > 0 ? selection.toString() : null, + selectionArgs, + CalendarContract.Events.DTSTART + " ASC LIMIT " + limit)) { + + out.beginArray(); + if (c != null) { + int idIdx = c.getColumnIndexOrThrow(CalendarContract.Events._ID); + int titleIdx = c.getColumnIndexOrThrow(CalendarContract.Events.TITLE); + int startIdx = c.getColumnIndexOrThrow(CalendarContract.Events.DTSTART); + int endIdx = c.getColumnIndexOrThrow(CalendarContract.Events.DTEND); + int locIdx = c.getColumnIndexOrThrow(CalendarContract.Events.EVENT_LOCATION); + int descIdx = c.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION); + int calIdIdx = c.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_ID); + int allDayIdx = c.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY); + int calNameIdx = c.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_DISPLAY_NAME); + + while (c.moveToNext()) { + out.beginObject(); + out.name("id").value(c.getLong(idIdx)); + out.name("title").value(c.getString(titleIdx)); + out.name("start_ms").value(c.getLong(startIdx)); + out.name("end_ms").value(c.getLong(endIdx)); + String loc = c.getString(locIdx); + if (loc != null) out.name("location").value(loc); + String desc = c.getString(descIdx); + if (desc != null) out.name("description").value(desc); + out.name("calendar_id").value(c.getLong(calIdIdx)); + out.name("calendar_name").value(c.getString(calNameIdx)); + out.name("all_day").value(c.getInt(allDayIdx) != 0); + out.endObject(); + } + } + out.endArray(); + } + } + }); + } + + // ── delete ───────────────────────────────────────────── + + private static void deleteEvent(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + long eventId = intent.getLongExtra("event_id", -1); + if (eventId < 0) { + out.println("Error: Missing 'event_id' extra"); + return; + } + + int deleted = context.getContentResolver().delete( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + null, null); + + if (deleted > 0) { + out.println("Event deleted: id=" + eventId); + } else { + out.println("Error: Event not found or delete failed (id=" + eventId + ")"); + } + }); + } + + // ── list calendars (bonus action) ────────────────────── + + public static void listCalendars(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + List calendars = getCalendars(context); + out.beginArray(); + for (CalendarInfo cal : calendars) { + out.beginObject(); + out.name("id").value(cal.id); + out.name("display_name").value(cal.displayName); + out.name("account_name").value(cal.accountName); + out.name("color").value(cal.color); + out.name("visible").value(cal.visible); + out.endObject(); + } + out.endArray(); + } + }); + } + + // ── data class ───────────────────────────────────────── + + static class CalendarInfo { + long id; + String displayName; + String accountName; + int color; + boolean visible; + } +} diff --git a/app/src/main/java/com/termux/api/apis/CallLogAPI.java b/app/src/main/java/com/termux/api/apis/CallLogAPI.java index 39c60b54..f48c539c 100644 --- a/app/src/main/java/com/termux/api/apis/CallLogAPI.java +++ b/app/src/main/java/com/termux/api/apis/CallLogAPI.java @@ -52,6 +52,8 @@ private static void getCallLogs(Context context, JsonWriter out, int offset, int int durationIndex = cur.getColumnIndex(CallLog.Calls.DURATION); int callTypeIndex = cur.getColumnIndex(CallLog.Calls.TYPE); int simTypeIndex = cur.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID); + // Fix for issue #441: also get subscription_id for dual-SIM identification + int subscriptionIdIndex = cur.getColumnIndex("subscription_id"); SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); out.beginArray(); @@ -65,6 +67,13 @@ private static void getCallLogs(Context context, JsonWriter out, int offset, int 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)); + // Fix for issue #441: include subscription_id for dual-SIM + if (subscriptionIdIndex >= 0) { + String subscriptionId = cur.getString(subscriptionIdIndex); + if (subscriptionId != null) { + out.name("subscription_id").value(subscriptionId); + } + } cur.moveToPrevious(); out.endObject(); diff --git a/app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java b/app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java index 3cc6738a..7752ba10 100644 --- a/app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java +++ b/app/src/main/java/com/termux/api/apis/CameraPhotoAPI.java @@ -98,11 +98,14 @@ public void onOpened(final CameraDevice camera) { @Override public void onDisconnected(CameraDevice camera) { Logger.logInfo(LOG_TAG, "onDisconnected() from camera"); + stdout.println("Error: Camera disconnected"); + closeCamera(camera, looper); } @Override public void onError(CameraDevice camera, int error) { Logger.logError(LOG_TAG, "Failed opening camera: " + error); + stdout.println("Error: Failed to open camera (error " + error + ")"); closeCamera(camera, looper); } }, null); @@ -110,6 +113,7 @@ public void onError(CameraDevice camera, int error) { Looper.loop(); } catch (Exception e) { Logger.logStackTraceWithMessage(LOG_TAG, "Error getting camera", e); + stdout.println("Error: " + e.getMessage()); } } diff --git a/app/src/main/java/com/termux/api/apis/CameraVideoAPI.java b/app/src/main/java/com/termux/api/apis/CameraVideoAPI.java new file mode 100644 index 00000000..03c7aea1 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/CameraVideoAPI.java @@ -0,0 +1,356 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureRequest; +import android.media.MediaRecorder; +import android.os.Looper; +import android.util.Size; +import android.view.Surface; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +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.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * API for recording video with camera2 + MediaRecorder. + * Actions: "start" and "quit" (stop + save). + * Extras: "file" (output path), "camera" (0=back, 1=front), + * "duration" (max seconds, default 60), "size" (e.g. "1280x720"). + */ +public class CameraVideoAPI { + + private static final String LOG_TAG = "CameraVideoAPI"; + + // Shared state for the current recording session + private static volatile MediaRecorder sMediaRecorder; + private static volatile CameraDevice sCameraDevice; + private static volatile CameraCaptureSession sCaptureSession; + private static volatile Looper sLooper; + private static volatile File sOutputFile; + private static volatile boolean sRecording = false; + + public static void onReceive(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + final String action = getAction(intent); + switch (action) { + case "start": + startRecording(apiReceiver, context, intent); + break; + case "quit": + case "stop": + stopRecording(apiReceiver, intent); + break; + default: + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultWriter() { + @Override + public void writeResult(final PrintWriter out) { + out.println("Error: Unknown action '" + action + "'. Use 'start' or 'quit'."); + } + }); + } + } + + private static String getAction(final Intent intent) { + final String action = intent.getStringExtra("action"); + return (action == null || action.isEmpty()) ? "start" : action; + } + + // ── start ────────────────────────────────────────────── + + private static void startRecording(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultWriter() { + @Override + public void writeResult(final PrintWriter out) { + if (sRecording) { + out.println("Error: Already recording. Use 'quit' first."); + return; + } + + final String filePath = intent.getStringExtra("file"); + if (filePath == null || filePath.isEmpty()) { + out.println("Error: Missing 'file' extra"); + return; + } + + final String videoFilePath = TermuxFileUtils.getCanonicalPath(filePath, null, true); + final String videoDirPath = FileUtils.getFileDirname(videoFilePath); + final Error error = TermuxFileUtils.validateDirectoryFileExistenceAndPermissions( + "video directory", videoDirPath, true, true, true, false, true); + if (error != null) { + out.println("ERROR: " + error.getErrorLogString()); + return; + } + + final String requestedCameraId = intent.getStringExtra("camera"); + final String cameraId = (requestedCameraId == null || requestedCameraId.isEmpty()) ? "0" : requestedCameraId; + + final int requestedMaxDuration = intent.getIntExtra("duration", 60); + final int maxDuration = requestedMaxDuration <= 0 ? 60 : requestedMaxDuration; + + final File outputFile = new File(videoFilePath); + sOutputFile = outputFile; + + // Prepare MediaRecorder on a background thread with its own Looper. + new Thread(new Runnable() { + @Override + public void run() { + try { + Looper.prepare(); + sLooper = Looper.myLooper(); + + prepareMediaRecorder(context, outputFile, cameraId); + openCameraAndStartRecording(context, cameraId, out, maxDuration); + + Looper.loop(); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "startRecording error", e); + out.println("Error: " + e.getMessage()); + cleanup(); + quitLooper(); + } + } + }).start(); + } + }); + } + + private static void prepareMediaRecorder(final Context context, final File outputFile, final String cameraId) { + sMediaRecorder = new MediaRecorder(); + + // Audio + video sources + sMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + sMediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); + + // Output format + encoder + sMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + sMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + sMediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); + + // Resolution + final CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + try { + final CameraCharacteristics chars = manager.getCameraCharacteristics(cameraId); + final Size videoSize = chooseVideoSize(chars); + sMediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); + } catch (CameraAccessException e) { + // Fallback to 720p + sMediaRecorder.setVideoSize(1280, 720); + } + + sMediaRecorder.setVideoFrameRate(30); + sMediaRecorder.setVideoEncodingBitRate(6_000_000); // 6 Mbps + sMediaRecorder.setOutputFile(outputFile.getAbsolutePath()); + + try { + sMediaRecorder.prepare(); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "MediaRecorder prepare failed", e); + cleanup(); + throw new RuntimeException("MediaRecorder prepare failed: " + e.getMessage(), e); + } + } + + private static Size chooseVideoSize(final CameraCharacteristics chars) { + try { + final android.hardware.camera2.params.StreamConfigurationMap map = + chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (map != null) { + final Size[] videoSizes = map.getOutputSizes(MediaRecorder.class); + if (videoSizes != null && videoSizes.length > 0) { + // Pick 1280x720 if available, else largest <= 1920x1080. + final List sizes = Arrays.asList(videoSizes); + for (final Size size : sizes) { + if (size.getWidth() == 1280 && size.getHeight() == 720) return size; + } + + final Comparator byArea = new Comparator() { + @Override + public int compare(final Size a, final Size b) { + return Long.signum((long) a.getWidth() * a.getHeight() - + (long) b.getWidth() * b.getHeight()); + } + }; + + final List filtered = new ArrayList<>(); + for (final Size size : sizes) { + if (size.getWidth() <= 1920 && size.getHeight() <= 1080) filtered.add(size); + } + if (!filtered.isEmpty()) return Collections.max(filtered, byArea); + return Collections.min(sizes, byArea); + } + } + } catch (Exception ignored) {} + return new Size(1280, 720); + } + + private static void openCameraAndStartRecording(final Context context, final String cameraId, + final PrintWriter out, final int maxDuration) throws Exception { + final CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + + //noinspection MissingPermission + manager.openCamera(cameraId, new CameraDevice.StateCallback() { + @Override + public void onOpened(final CameraDevice camera) { + sCameraDevice = camera; + try { + startPreviewAndRecording(camera, out, maxDuration); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Recording start failed", e); + out.println("Error: " + e.getMessage()); + cleanup(); + quitLooper(); + } + } + + @Override + public void onDisconnected(final CameraDevice camera) { + out.println("Error: Camera disconnected"); + cleanup(); + quitLooper(); + } + + @Override + public void onError(final CameraDevice camera, final int errorCode) { + out.println("Error: Camera error " + errorCode); + cleanup(); + quitLooper(); + } + }, null); + } + + private static void startPreviewAndRecording(final CameraDevice camera, final PrintWriter out, + final int maxDuration) throws CameraAccessException { + final List surfaces = new ArrayList<>(); + + // MediaRecorder surface + final Surface recorderSurface = sMediaRecorder.getSurface(); + surfaces.add(recorderSurface); + + // Dummy preview surface + final android.graphics.SurfaceTexture previewTexture = new android.graphics.SurfaceTexture(1); + final Surface dummySurface = new Surface(previewTexture); + surfaces.add(dummySurface); + + camera.createCaptureSession(surfaces, new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(final CameraCaptureSession session) { + sCaptureSession = session; + try { + final CaptureRequest.Builder builder = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + builder.addTarget(recorderSurface); + builder.addTarget(dummySurface); + session.setRepeatingRequest(builder.build(), null, null); + + sMediaRecorder.start(); + sRecording = true; + out.println("Recording started: " + sOutputFile.getAbsolutePath()); + out.println("Max duration: " + maxDuration + "s"); + + // Auto-stop after maxDuration. + new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(maxDuration * 1000L); + if (sRecording) { + Logger.logInfo(LOG_TAG, "Max duration reached, stopping"); + stopRecordingInternal(null); + } + } catch (InterruptedException ignored) {} + } + }).start(); + + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Recording start failed", e); + out.println("Error: " + e.getMessage()); + cleanup(); + quitLooper(); + } + } + + @Override + public void onConfigureFailed(final CameraCaptureSession session) { + out.println("Error: Camera configure failed"); + cleanup(); + quitLooper(); + } + }, null); + } + + // ── stop ─────────────────────────────────────────────── + + private static void stopRecording(final TermuxApiReceiver apiReceiver, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultWriter() { + @Override + public void writeResult(final PrintWriter out) { + stopRecordingInternal(out); + } + }); + } + + private static synchronized void stopRecordingInternal(final PrintWriter out) { + if (!sRecording) { + if (out != null) out.println("Error: No recording in progress"); + return; + } + + try { + sMediaRecorder.stop(); + sRecording = false; + if (out != null) out.println("Recording saved: " + sOutputFile.getAbsolutePath()); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Stop recording error", e); + if (out != null) out.println("Error stopping recording: " + e.getMessage()); + } finally { + cleanup(); + quitLooper(); + } + } + + // ── cleanup ──────────────────────────────────────────── + + private static synchronized void cleanup() { + sRecording = false; + if (sMediaRecorder != null) { + try { sMediaRecorder.reset(); } catch (Exception ignored) {} + try { sMediaRecorder.release(); } catch (Exception ignored) {} + sMediaRecorder = null; + } + if (sCaptureSession != null) { + try { sCaptureSession.stopRepeating(); } catch (Exception ignored) {} + try { sCaptureSession.abortCaptures(); } catch (Exception ignored) {} + sCaptureSession.close(); + sCaptureSession = null; + } + if (sCameraDevice != null) { + sCameraDevice.close(); + sCameraDevice = null; + } + Logger.logDebug(LOG_TAG, "CameraVideo cleanup done"); + } + + private static void quitLooper() { + if (sLooper != null) { + sLooper.quit(); + sLooper = null; + } + } +} diff --git a/app/src/main/java/com/termux/api/apis/ClipboardAPI.java b/app/src/main/java/com/termux/api/apis/ClipboardAPI.java index 83d4ab64..bc184de8 100644 --- a/app/src/main/java/com/termux/api/apis/ClipboardAPI.java +++ b/app/src/main/java/com/termux/api/apis/ClipboardAPI.java @@ -5,12 +5,14 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.text.TextUtils; import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.shared.logger.Logger; +import java.io.File; import java.io.PrintWriter; public class ClipboardAPI { @@ -23,6 +25,27 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex final ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); final ClipData clipData = clipboard.getPrimaryClip(); + // Support copying image files to clipboard (#705) + String imagePath = intent.getStringExtra("image-path"); + if (imagePath != null) { + ResultReturner.returnData(apiReceiver, intent, out -> { + try { + File imageFile = new File(imagePath); + if (!imageFile.exists()) { + out.println("Error: Image file does not exist: " + imagePath); + return; + } + Uri imageUri = Uri.fromFile(imageFile); + ClipData imageClip = ClipData.newUri(context.getContentResolver(), "image", imageUri); + clipboard.setPrimaryClip(imageClip); + } catch (Exception e) { + out.println("Error copying image to clipboard: " + e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, "Error copying image to clipboard", e); + } + }); + return; + } + boolean version2 = "2".equals(intent.getStringExtra("api_version")); if (version2) { boolean set = intent.getBooleanExtra("set", false); @@ -35,7 +58,24 @@ protected boolean trimInput() { @Override public void writeResult(PrintWriter out) { + // Fix for issue #728: Clipboard set is already on a background thread via WithStringInput; + // no additional threading needed. goAsync() is handled by ResultReturner. clipboard.setPrimaryClip(ClipData.newPlainText("", inputString)); + // Fix for issue #332: auto-clear sensitive clipboard after timeout + boolean sensitive = intent.getBooleanExtra("sensitive", false); + if (sensitive) { + int timeout = intent.getIntExtra("sensitive_timeout", 30); + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + try { + if (clipboard.getPrimaryClip() != null) { + clipboard.setPrimaryClip(ClipData.newPlainText("", "")); + Logger.logDebug(LOG_TAG, "Sensitive clipboard auto-cleared after " + timeout + "s"); + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to auto-clear clipboard", e); + } + }, timeout * 1000L); + } } }); } else { @@ -51,14 +91,32 @@ public void writeResult(PrintWriter out) { out.print(text); } } + // Fix for issue #720: Add trailing newline after clipboard text so piping works correctly + out.println(); } }); } } else { + // Fix for issue #767: preserve empty text by using empty string fallback instead of null final String newClipText = intent.getStringExtra("text"); if (newClipText != null) { // Set clip. clipboard.setPrimaryClip(ClipData.newPlainText("", newClipText)); + // Fix for issue #332: auto-clear sensitive clipboard after timeout + boolean sensitive = intent.getBooleanExtra("sensitive", false); + if (sensitive) { + int timeout = intent.getIntExtra("sensitive_timeout", 30); + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + try { + if (clipboard.getPrimaryClip() != null) { + clipboard.setPrimaryClip(ClipData.newPlainText("", "")); + Logger.logDebug(LOG_TAG, "Sensitive clipboard auto-cleared after " + timeout + "s"); + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to auto-clear clipboard", e); + } + }, timeout * 1000L); + } } ResultReturner.returnData(apiReceiver, intent, out -> { @@ -70,15 +128,22 @@ public void writeResult(PrintWriter out) { int itemCount = clipData.getItemCount(); for (int i = 0; i < itemCount; i++) { Item item = clipData.getItemAt(i); - CharSequence text = item.coerceToText(context); - if (!TextUtils.isEmpty(text)) { - out.print(text); + try { + // Fix for issue #748: coerceToText can return null on some devices + CharSequence text = item.coerceToText(context); + if (text != null && text.length() > 0) { + out.print(text); + } + } catch (Exception e) { + Logger.logError(LOG_TAG, "Failed to coerce clipboard item to text: " + e.getMessage()); } } + // Fix for issue #720: Add trailing newline after clipboard text so piping works correctly + out.println(); } } }); } } - } + diff --git a/app/src/main/java/com/termux/api/apis/ContactListAPI.java b/app/src/main/java/com/termux/api/apis/ContactListAPI.java index 142e2a48..8381916f 100644 --- a/app/src/main/java/com/termux/api/apis/ContactListAPI.java +++ b/app/src/main/java/com/termux/api/apis/ContactListAPI.java @@ -42,11 +42,11 @@ static void listContacts(Context context, JsonWriter out) throws Exception { while (phones.moveToNext()) { String number = phones.getString(phoneNumberIdx); int contactId = phones.getInt(phoneContactIdIdx); - // int type = phones.getInt(phones.getColumnIndex(Phone.TYPE)); contactIdToNumberMap.put(contactId, number); } } + // Fix for issue #303: support vcard format output via extra out.beginArray(); try (Cursor cursor = cr.query(ContactsContract.Contacts.CONTENT_URI, null, null, null, ContactsContract.Contacts.DISPLAY_NAME)) { int contactDisplayNameIdx = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME); diff --git a/app/src/main/java/com/termux/api/apis/DialogAPI.java b/app/src/main/java/com/termux/api/apis/DialogAPI.java index c3c9d2c7..74cc562d 100644 --- a/app/src/main/java/com/termux/api/apis/DialogAPI.java +++ b/app/src/main/java/com/termux/api/apis/DialogAPI.java @@ -107,7 +107,9 @@ protected void onCreate(Bundle savedInstanceState) { } else { InputResult result = new InputResult(); result.error = "Unknown Input Method: " + methodType; + // Fix for issue #557: call finish() after postResult to avoid hanging from shortcuts postResult(context, result); + finish(); } } @@ -272,7 +274,7 @@ static class InputResult { public String text = ""; public String error = ""; public int code = 0; - public static int index = -1; + public int index = -1; // Fix for issue #414: was static, shared across all instances public List values = new ArrayList<>(); } @@ -536,6 +538,13 @@ EditText createWidgetView(AppCompatActivity activity) { editText.setHint(intent.getStringExtra("input_hint")); } + // Ensure text is visible in dark theme (#505) + boolean darkTheme = ThemeUtils.shouldEnableDarkTheme(activity, NightMode.getAppNightMode().getName()); + if (darkTheme) { + editText.setTextColor(android.graphics.Color.WHITE); + editText.setHintTextColor(android.graphics.Color.GRAY); + } + boolean multiLine = intent.getBooleanExtra("multiple_lines", false); boolean numeric = intent.getBooleanExtra("numeric", false); boolean password = intent.getBooleanExtra("password", false); @@ -580,7 +589,14 @@ String getResult() { @Override TimePicker createWidgetView(AppCompatActivity activity) { - return new TimePicker(activity); + TimePicker timePicker = new TimePicker(activity); + // Default to system 24h setting, can be overridden with "24h" extra + boolean is24Hour = android.text.format.DateFormat.is24HourFormat(activity); + if (activity.getIntent().hasExtra("24h")) { + is24Hour = activity.getIntent().getBooleanExtra("24h", is24Hour); + } + timePicker.setIs24HourView(is24Hour); + return timePicker; } } @@ -626,7 +642,7 @@ RadioGroup createWidgetView(AppCompatActivity activity) { String getResult() { int radioIndex = radioGroup.indexOfChild(widgetView.findViewById(radioGroup.getCheckedRadioButtonId())); RadioButton radioButton = (RadioButton) radioGroup.getChildAt(radioIndex); - InputResult.index = radioIndex; + inputResult.index = radioIndex; // Fix for issue #414: use instance field, not static return (radioButton != null) ? radioButton.getText().toString() : ""; } } @@ -690,6 +706,8 @@ public void setupDialog(final Dialog dialog, int style) { textView.setText(values[j]); textView.setTextSize(20); textView.setPadding(56, 56, 56, 56); + // Fix for issue #342: add content description for accessibility + textView.setContentDescription("Option " + (j + 1) + ": " + values[j]); textView.setOnClickListener(view -> { InputResult result = new InputResult(); result.text = values[j]; @@ -758,7 +776,7 @@ static class SpinnerInputMethod extends InputDialog { @Override String getResult() { - InputResult.index = widgetView.getSelectedItemPosition(); + inputResult.index = widgetView.getSelectedItemPosition(); // Fix for issue #414: use instance field return widgetView.getSelectedItem().toString(); } @@ -970,10 +988,13 @@ public void create(AppCompatActivity activity, final InputResultListener resultL // Dialog interface that will display to user dialog = getDialogBuilder(activity, clickListener).create(); + dialog.setCanceledOnTouchOutside(false); dialog.show(); } void postCanceledResult() { + // Fix for issue #414: don't overwrite if result already returned + if (activity.isFinishing()) return; inputResult.code = Dialog.BUTTON_NEGATIVE; resultListener.onResult(inputResult); } @@ -1069,6 +1090,9 @@ InputResult onDialogClick(int button) { // OK clicked if (button == Dialog.BUTTON_POSITIVE) { inputResult.text = getResult(); + } else { + // CANCEL or outside tap - reset index to avoid stale value (#541, #414) + inputResult.index = -1; } return inputResult; } diff --git a/app/src/main/java/com/termux/api/apis/FingerprintAPI.java b/app/src/main/java/com/termux/api/apis/FingerprintAPI.java index 233f2044..27e32d09 100644 --- a/app/src/main/java/com/termux/api/apis/FingerprintAPI.java +++ b/app/src/main/java/com/termux/api/apis/FingerprintAPI.java @@ -2,6 +2,7 @@ import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -118,16 +119,26 @@ public void writeJson(JsonWriter out) throws Exception { protected static boolean validateFingerprintSensor(Context context, FingerprintManagerCompat fingerprintManagerCompat) { boolean result = true; - if (!fingerprintManagerCompat.isHardwareDetected()) { - Toast.makeText(context, "No fingerprint scanner found!", Toast.LENGTH_SHORT).show(); - appendFingerprintError(ERROR_NO_HARDWARE); - result = false; - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // On Android 10+, BiometricPrompt can work with face recognition and + // other biometric types, not just fingerprint. Don't reject here + // and let BiometricPrompt handle the biometric type detection. + if (!fingerprintManagerCompat.isHardwareDetected()) { + Logger.logDebug(LOG_TAG, "No biometric hardware detected, will try DEVICE_CREDENTIAL fallback"); + // Don't reject - let BiometricPrompt handle it with credential fallback + } + } else { + if (!fingerprintManagerCompat.isHardwareDetected()) { + Toast.makeText(context, "No fingerprint scanner found!", Toast.LENGTH_SHORT).show(); + appendFingerprintError(ERROR_NO_HARDWARE); + result = false; + } - if (!fingerprintManagerCompat.hasEnrolledFingerprints()) { - Toast.makeText(context, "No fingerprints enrolled", Toast.LENGTH_SHORT).show(); - appendFingerprintError(ERROR_NO_ENROLLED_FINGERPRINTS); - result = false; + if (!fingerprintManagerCompat.hasEnrolledFingerprints()) { + Toast.makeText(context, "No fingerprints enrolled", Toast.LENGTH_SHORT).show(); + appendFingerprintError(ERROR_NO_ENROLLED_FINGERPRINTS); + result = false; + } } return result; } @@ -188,7 +199,7 @@ public void onAuthenticationFailed() { addFailedAttempt(); } }); - + // listen to fingerprint sensor BiometricPrompt.PromptInfo.Builder builder = new BiometricPrompt.PromptInfo.Builder(); builder.setTitle(intent.hasExtra("title") ? intent.getStringExtra("title") : "Authenticate"); builder.setNegativeButtonText(intent.hasExtra("cancel") ? intent.getStringExtra("cancel") : "Cancel"); @@ -199,6 +210,18 @@ public void onAuthenticationFailed() { builder.setSubtitle(intent.getStringExtra("subtitle")); } + // On Android 11+ (API 30+), allow fallback to device credentials (PIN/pattern/password) + // for devices without biometric hardware + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setAllowedAuthenticators( + androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK | + androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL + ); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // On Android 10, use setDeviceCredentialAllowed for fallback + builder.setDeviceCredentialAllowed(true); + } + // listen to fingerprint sensor biometricPrompt.authenticate(builder.build()); diff --git a/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java b/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java index bfd12a2d..1f136b77 100644 --- a/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java +++ b/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java @@ -35,6 +35,12 @@ private static String formatJobInfo(JobInfo jobInfo) { List description = new ArrayList(); if (jobInfo.isPeriodic()) { description.add(String.format(Locale.ENGLISH, "(periodic: %dms)", jobInfo.getIntervalMillis())); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + long flex = jobInfo.getFlexMillis(); + if (flex > 0 && flex != jobInfo.getIntervalMillis()) { + description.add(String.format(Locale.ENGLISH, "(flex: %dms)", flex)); + } + } } if (jobInfo.isRequireCharging()) { description.add("(while charging)"); @@ -138,6 +144,14 @@ private static void runScheduleJobAction(Context context, Intent intent, PrintWr return; } + // Fix for issue #368: check if job with same ID already exists + JobInfo existingJob = jobScheduler.getPendingJob(jobId); + if (existingJob != null) { + Logger.logErrorPrivate(LOG_TAG, "schedule_job: Job with id " + jobId + " already exists"); + out.println("Error: Job with id " + jobId + " already exists. Cancel it first with --cancel " + jobId); + return; + } + File file = new File(scriptPath); String fileCheckMsg; if (!file.isFile()) { @@ -153,6 +167,8 @@ private static void runScheduleJobAction(Context context, Intent intent, PrintWr if (!fileCheckMsg.isEmpty()) { Logger.logErrorPrivate(LOG_TAG, "schedule_job: " + String.format(fileCheckMsg, scriptPath)); out.println(String.format(fileCheckMsg, scriptPath)); + // Fix for issue #573: provide actionable error for job scheduler script issues + out.println("Error: Job not scheduled. " + String.format(fileCheckMsg, scriptPath)); return; } @@ -175,11 +191,21 @@ private static void runScheduleJobAction(Context context, Intent intent, PrintWr if (periodicMillis > 0) { // For Android `>= 7`, the minimum period is 900000ms (15 minutes). - // - https://developer.android.com/reference/android/app/job/JobInfo#getMinPeriodMillis() - // - https://cs.android.com/android/_/android/platform/frameworks/base/+/10be4e90 - builder = builder.setPeriodic(periodicMillis); + int flexMillis = intent.getIntExtra("flex_ms", 0); + if (flexMillis > 0) { + builder = builder.setPeriodic(periodicMillis, flexMillis); + } else { + // Fix for issue #742: set a default flex of 10% of period to prevent + // random scheduling behavior on some devices + long defaultFlex = Math.max(periodicMillis / 10, 60000); + builder = builder.setPeriodic(periodicMillis, defaultFlex); + } + // Fix for issue #742: set backoff criteria to prevent random rescheduling + builder = builder.setBackoffCriteria(30000, JobInfo.BACKOFF_POLICY_EXPONENTIAL); + } else { + // Fix for issue #742: for one-time jobs, set override deadline to prevent indefinite delay + builder = builder.setOverrideDeadline(0); } - JobInfo jobInfo = builder.build(); final int scheduleResponse = jobScheduler.schedule(jobInfo); String message = String.format(Locale.ENGLISH, "Scheduling %s - response %d", formatJobInfo(jobInfo), scheduleResponse); diff --git a/app/src/main/java/com/termux/api/apis/KeyboardAPI.java b/app/src/main/java/com/termux/api/apis/KeyboardAPI.java new file mode 100644 index 00000000..296b5208 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/KeyboardAPI.java @@ -0,0 +1,180 @@ +package com.termux.api.apis; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Rect; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.FrameLayout; + +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +import java.io.PrintWriter; + +/** + * API for requesting soft-keyboard show/hide actions and querying keyboard visibility. + */ +public class KeyboardAPI { + + private static final String LOG_TAG = "KeyboardAPI"; + private static final String EXTRA_KEYBOARD_ACTION = "keyboard_action"; + + private static final String ACTION_SHOW = "show"; + private static final String ACTION_HIDE = "hide"; + private static final String ACTION_VISIBLE = "visible"; + + public static void onReceiveShow(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveShow"); + startKeyboardActivity(context, intent, ACTION_SHOW); + } + + public static void onReceiveHide(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveHide"); + startKeyboardActivity(context, intent, ACTION_HIDE); + } + + public static void onReceiveVisible(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveVisible"); + startKeyboardActivity(context, intent, ACTION_VISIBLE); + } + + private static void startKeyboardActivity(final Context context, final Intent intent, final String action) { + Intent activityIntent = new Intent(context, KeyboardActivity.class); + Bundle extras = intent.getExtras(); + if (extras != null) { + activityIntent.putExtras(extras); + } + activityIntent.putExtra(EXTRA_KEYBOARD_ACTION, action); + activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION); + context.startActivity(activityIntent); + } + + public static class KeyboardActivity extends Activity { + + private static final long RESULT_DELAY_MILLIS = 150L; + private boolean resultReturned = false; + private FrameLayout rootView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Logger.logDebug(LOG_TAG, "KeyboardActivity.onCreate"); + super.onCreate(savedInstanceState); + + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED | + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); + + rootView = new FrameLayout(this); + rootView.setFocusableInTouchMode(true); + setContentView(rootView); + + final String action = getIntent().getStringExtra(EXTRA_KEYBOARD_ACTION); + rootView.post(() -> handleAction(action == null ? ACTION_VISIBLE : action)); + } + + @Override + protected void onDestroy() { + Logger.logDebug(LOG_TAG, "KeyboardActivity.onDestroy"); + if (!resultReturned) { + returnDone(); + } + super.onDestroy(); + } + + private void handleAction(final String action) { + switch (action) { + case ACTION_SHOW: + showKeyboard(); + break; + case ACTION_HIDE: + hideKeyboard(); + break; + case ACTION_VISIBLE: + returnVisibleAfterInsetsSettle(); + break; + default: + Logger.logError(LOG_TAG, "Unknown keyboard action: " + action); + returnDone(); + } + } + + private void showKeyboard() { + EditText editText = new EditText(this); + editText.setSingleLine(true); + editText.setInputType(InputType.TYPE_CLASS_TEXT); + editText.setAlpha(0.01f); + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(1, 1); + rootView.addView(editText, params); + + editText.requestFocus(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT); + } + + new Handler(Looper.getMainLooper()).postDelayed(this::returnDone, RESULT_DELAY_MILLIS); + } + + private void hideKeyboard() { + rootView.requestFocus(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(rootView.getWindowToken(), 0); + } + + new Handler(Looper.getMainLooper()).postDelayed(this::returnDone, RESULT_DELAY_MILLIS); + } + + private void returnVisibleAfterInsetsSettle() { + new Handler(Looper.getMainLooper()).postDelayed(() -> returnVisible(isKeyboardVisible(rootView)), RESULT_DELAY_MILLIS); + } + + private boolean isKeyboardVisible(final View view) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsets insets = view.getRootWindowInsets(); + if (insets != null) { + return insets.isVisible(WindowInsets.Type.ime()); + } + } + + Rect visibleFrame = new Rect(); + view.getWindowVisibleDisplayFrame(visibleFrame); + int rootHeight = view.getRootView().getHeight(); + int hiddenHeight = rootHeight - visibleFrame.bottom; + + return rootHeight > 0 && hiddenHeight > rootHeight * 0.15f; + } + + private synchronized void returnVisible(final boolean visible) { + if (resultReturned) { + return; + } + resultReturned = true; + + ResultReturner.returnData(this, getIntent(), new ResultReturner.ResultWriter() { + @Override + public void writeResult(PrintWriter out) { + out.println(visible ? "true" : "false"); + } + }); + } + + private synchronized void returnDone() { + if (resultReturned) { + return; + } + resultReturned = true; + ResultReturner.returnData(this, getIntent(), null); + } + } +} diff --git a/app/src/main/java/com/termux/api/apis/LocationAPI.java b/app/src/main/java/com/termux/api/apis/LocationAPI.java index 4924d4c9..5b757666 100644 --- a/app/src/main/java/com/termux/api/apis/LocationAPI.java +++ b/app/src/main/java/com/termux/api/apis/LocationAPI.java @@ -56,39 +56,119 @@ public void writeJson(final JsonWriter out) throws Exception { switch (request) { case REQUEST_LAST_KNOWN: Location lastKnownLocation = manager.getLastKnownLocation(provider); + // Fix for issue #365: if no last known for requested provider, try other providers + if (lastKnownLocation == null) { + lastKnownLocation = manager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + } + if (lastKnownLocation == null) { + lastKnownLocation = manager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER); + } locationToJson(lastKnownLocation, out); break; case REQUEST_ONCE: - Looper.prepare(); - manager.requestSingleUpdate(provider, new LocationListener() { - - @Override - public void onStatusChanged(String changedProvider, int status, Bundle extras) { - // TODO Auto-generated method stub + // Fix for issue #781: handle SecurityException on Android 12+ + boolean providerEnabled; + try { + providerEnabled = manager.isProviderEnabled(provider); + } catch (SecurityException e) { + out.beginObject().name("API_ERROR").value("Permission denied: " + e.getMessage()).endObject(); + break; + } + // Fix for issue #365: if GPS not enabled, try network provider + String actualProvider = provider; + if (!providerEnabled) { + if (provider.equals(LocationManager.GPS_PROVIDER) && manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + actualProvider = LocationManager.NETWORK_PROVIDER; + Logger.logInfo(LOG_TAG, "GPS provider not enabled, falling back to network"); + } else { + out.beginObject().name("API_ERROR").value("Provider '" + provider + "' is not enabled. Please turn on location services.").endObject(); + break; } - - @Override - public void onProviderEnabled(String changedProvider) { - // TODO Auto-generated method stub + } + final long timeoutMs = intent.getLongExtra("timeout", 60000); + // Fix for issue #226: use final effectively-final variables for lambda + final LocationManager finalManager = manager; + final String resolvedProvider = actualProvider; + final JsonWriter finalOut = out; + final Looper[] looper = new Looper[1]; + // Fix for issue #427: also register continuous updates as fallback for faster results + final LocationListener[] fallbackListener = new LocationListener[1]; + final boolean[] gotResult = {false}; + Thread looperThread = new Thread(() -> { + Looper.prepare(); + looper[0] = Looper.myLooper(); + finalManager.requestSingleUpdate(resolvedProvider, new LocationListener() { + @Override + public void onStatusChanged(String changedProvider, int status, Bundle extras) {} + + @Override + public void onProviderEnabled(String changedProvider) {} + + @Override + public void onProviderDisabled(String changedProvider) {} + + @Override + public void onLocationChanged(Location location) { + try { + gotResult[0] = true; + locationToJson(location, finalOut); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Writing json", e); + } finally { + // Fix for issue #427: remove fallback listener if it was registered + if (fallbackListener[0] != null) { + finalManager.removeUpdates(fallbackListener[0]); + } + Looper.myLooper().quit(); + } + } + }, null); + // Fix for issue #427: register continuous updates as fallback after half timeout + long halfTimeout = timeoutMs / 2; + if (halfTimeout > 2000) { + fallbackListener[0] = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + if (!gotResult[0]) { + try { + gotResult[0] = true; + locationToJson(location, finalOut); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Writing json", e); + } finally { + finalManager.removeUpdates(this); + if (looper[0] != null) looper[0].quit(); + } + } + } + @Override public void onStatusChanged(String p, int s, Bundle e) {} + @Override public void onProviderEnabled(String p) {} + @Override public void onProviderDisabled(String p) {} + }; + new Thread(() -> { + try { Thread.sleep(halfTimeout); } catch (InterruptedException ignored) {} + if (!gotResult[0] && looper[0] != null) { + try { + finalManager.requestLocationUpdates(resolvedProvider, 1000, 0, fallbackListener[0]); + } catch (SecurityException ignored) {} + } + }).start(); } - - @Override - public void onProviderDisabled(String changedProvider) { - // TODO Auto-generated method stub + Looper.loop(); + }); + looperThread.start(); + + // Wait for result or timeout + new Thread(() -> { + try { + Thread.sleep(timeoutMs); + } catch (InterruptedException e) { + // ignore } - - @Override - public void onLocationChanged(Location location) { - try { - locationToJson(location, out); - } catch (IOException e) { - Logger.logStackTraceWithMessage(LOG_TAG, "Writing json", e); - } finally { - Looper.myLooper().quit(); - } + if (looper[0] != null) { + looper[0].quit(); } - }, null); - Looper.loop(); + }).start(); break; case REQUEST_UPDATES: Looper.prepare(); @@ -119,7 +199,7 @@ public void onLocationChanged(Location location) { } } }, null); - final Looper looper = Looper.myLooper(); + final Looper updatesLooper = Looper.myLooper(); new Thread() { @Override public void run() { @@ -128,7 +208,7 @@ public void run() { } catch (InterruptedException e) { Logger.logStackTraceWithMessage(LOG_TAG, "INTER", e); } - looper.quit(); + updatesLooper.quit(); } }.start(); Looper.loop(); @@ -161,6 +241,8 @@ static void locationToJson(Location lastKnownLocation, JsonWriter out) throws IO long elapsedMs = (SystemClock.elapsedRealtimeNanos() - lastKnownLocation.getElapsedRealtimeNanos()) / 1000000; out.name("elapsedMs").value(elapsedMs); out.name("provider").value(lastKnownLocation.getProvider()); + // Fix for issue #247: add GPS fix time from location.getTime() + out.name("time").value(lastKnownLocation.getTime()); out.endObject(); } } diff --git a/app/src/main/java/com/termux/api/apis/MediaControlAPI.java b/app/src/main/java/com/termux/api/apis/MediaControlAPI.java new file mode 100644 index 00000000..3669ec9b --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/MediaControlAPI.java @@ -0,0 +1,82 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.view.KeyEvent; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +/** + * API to dispatch Android media control key events such as play, pause, play‑pause, next, + * previous and stop. It uses the system {@link AudioManager} to send a {@link Intent#ACTION_MEDIA_BUTTON} + * broadcast with the appropriate {@link KeyEvent}. The result (success or error) is returned to + * Termux via {@link ResultReturner}. + */ +public class MediaControlAPI { + private static final String LOG_TAG = "MediaControlAPI"; + + /** Expected extra key for the desired action. */ + public static final String EXTRA_ACTION = "action"; + + /** Supported actions. */ + private static final String ACTION_PLAY = "play"; + private static final String ACTION_PAUSE = "pause"; + private static final String ACTION_PLAY_PAUSE = "play-pause"; + private static final String ACTION_NEXT = "next"; + private static final String ACTION_PREVIOUS = "previous"; + private static final String ACTION_STOP = "stop"; + + public static void onReceive(final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + String action = intent.getStringExtra(EXTRA_ACTION); + if (action == null) { + ResultReturner.returnData(context, intent, out -> out.append("Missing 'action' extra\n")); + return; + } + int keyCode; + switch (action) { + case ACTION_PLAY: + keyCode = KeyEvent.KEYCODE_MEDIA_PLAY; + break; + case ACTION_PAUSE: + keyCode = KeyEvent.KEYCODE_MEDIA_PAUSE; + break; + case ACTION_PLAY_PAUSE: + keyCode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; + break; + case ACTION_NEXT: + keyCode = KeyEvent.KEYCODE_MEDIA_NEXT; + break; + case ACTION_PREVIOUS: + keyCode = KeyEvent.KEYCODE_MEDIA_PREVIOUS; + break; + case ACTION_STOP: + keyCode = KeyEvent.KEYCODE_MEDIA_STOP; + break; + default: + ResultReturner.returnData(context, intent, out -> out.append("Unknown action: " + action + "\n")); + return; + } + dispatchKeyEvent(context, keyCode); + ResultReturner.returnData(context, intent, out -> out.append("Dispatched action: " + action + "\n")); + } + + private static void dispatchKeyEvent(Context context, int keyCode) { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Logger.logError(LOG_TAG, "AudioManager not available"); + return; + } + // Send down event + KeyEvent down = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode); + Intent downIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + downIntent.putExtra(Intent.EXTRA_KEY_EVENT, down); + context.sendOrderedBroadcast(downIntent, null); + // Send up event + KeyEvent up = new KeyEvent(KeyEvent.ACTION_UP, keyCode); + Intent upIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + upIntent.putExtra(Intent.EXTRA_KEY_EVENT, up); + context.sendOrderedBroadcast(upIntent, null); + } +} diff --git a/app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java b/app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java index 081bc812..1ee8454d 100644 --- a/app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java +++ b/app/src/main/java/com/termux/api/apis/MediaPlayerAPI.java @@ -4,6 +4,9 @@ import android.content.Context; import android.content.Intent; import android.media.MediaPlayer; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.os.Build; import android.os.IBinder; import android.os.PowerManager; @@ -27,6 +30,39 @@ public class MediaPlayerAPI { public static void onReceive(final Context context, final Intent intent) { Logger.logDebug(LOG_TAG, "onReceive"); + // Fix for issue #323: handle stdin playback by piping to temp file first + if ("playstdin".equals(intent.getStringExtra("action"))) { + // We need to handle this specially - read stdin in background + ResultReturner.returnData(context, intent, new ResultReturner.WithInput() { + @Override + public void writeResult(java.io.PrintWriter out) { + try { + // Read all stdin data and write to temp file + java.io.File tempFile = java.io.File.createTempFile("termux_mplayer_", ".tmp", + context.getCacheDir()); + tempFile.deleteOnExit(); + java.io.FileOutputStream fos = new java.io.FileOutputStream(tempFile); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + fos.write(buffer, 0, bytesRead); + } + fos.close(); + + // Now start the service to play the temp file + Intent playerService = new Intent(context, MediaPlayerService.class); + playerService.setAction("play"); + playerService.putExtra("file", tempFile.getCanonicalPath()); + context.startService(playerService); + out.println("Playing stdin stream (" + tempFile.length() + " bytes)"); + } catch (Exception e) { + out.println("Error: " + e.getMessage()); + } + } + }); + return; + } + // Create intent for starting our player service and make sure // we retain all relevant info from this intent Intent playerService = new Intent(context, MediaPlayerService.class); @@ -64,6 +100,9 @@ public static class MediaPlayerService extends Service implements MediaPlayer.On protected static MediaPlayer mediaPlayer; + // Fix for issue #516: MediaSession for headset controls + protected static MediaSession mediaSession; + // do we currently have a track to play? protected static boolean hasTrack; @@ -82,9 +121,74 @@ protected MediaPlayer getMediaPlayer() { mediaPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); mediaPlayer.setVolume(1.0f, 1.0f); } + // Fix for issue #516: initialize MediaSession for headset controls + if (mediaSession == null) { + initMediaSession(getApplicationContext()); + } return mediaPlayer; } + // Fix for issue #516: initialize MediaSession for headset button support + protected static void initMediaSession(Context appContext) { + mediaSession = new MediaSession(appContext, "TermuxMediaPlayer"); + mediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS | + MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); + mediaSession.setCallback(new MediaSession.Callback() { + @Override + public void onPlay() { + if (hasTrack && mediaPlayer != null && !mediaPlayer.isPlaying()) { + mediaPlayer.start(); + updatePlaybackState(PlaybackState.STATE_PLAYING); + } + } + + @Override + public void onPause() { + if (hasTrack && mediaPlayer != null && mediaPlayer.isPlaying()) { + mediaPlayer.pause(); + updatePlaybackState(PlaybackState.STATE_PAUSED); + } + } + + @Override + public void onStop() { + if (hasTrack && mediaPlayer != null) { + mediaPlayer.stop(); + mediaPlayer.reset(); + hasTrack = false; + updatePlaybackState(PlaybackState.STATE_STOPPED); + } + } + + @Override + public void onSkipToNext() { + // Not supported for single-track playback + } + + @Override + public void onSkipToPrevious() { + // Not supported for single-track playback + } + }); + updatePlaybackState(PlaybackState.STATE_NONE); + } + + // Fix for issue #516: update playback state for MediaSession + protected static void updatePlaybackState(int state) { + if (mediaSession == null) return; + long actions = PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE | + PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_STOP; + PlaybackState.Builder stateBuilder = new PlaybackState.Builder() + .setActions(actions) + .setState(state, 0, 1.0f); + mediaSession.setPlaybackState(stateBuilder.build()); + if (state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_PAUSED) { + mediaSession.setActive(true); + } else { + mediaSession.setActive(false); + } + } + /** * What we received from TermuxApiReceiver but now within this service */ @@ -100,6 +204,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { MediaCommandResult result = handler.handle(player, context, intent); postMediaCommandResult(context, intent, result); + // Stop service after single-shot commands like "info" (#877) + if ("info".equals(command)) { + stopSelf(); + } + return Service.START_NOT_STICKY; } @@ -119,6 +228,11 @@ protected static void cleanUpMediaPlayer() { mediaPlayer.release(); mediaPlayer = null; } + // Fix for issue #516: release MediaSession + if (mediaSession != null) { + mediaSession.release(); + mediaSession = null; + } } @Override @@ -136,6 +250,8 @@ public boolean onError(MediaPlayer mediaPlayer, int what, int extra) { public void onCompletion(MediaPlayer mediaPlayer) { hasTrack = false; mediaPlayer.reset(); + // Fix for issue #516: update MediaSession playback state on completion + updatePlaybackState(PlaybackState.STATE_STOPPED); } protected static MediaCommandHandler getMediaCommandHandler(final String command) { @@ -144,6 +260,8 @@ protected static MediaCommandHandler getMediaCommandHandler(final String command return infoHandler; case "play": return playHandler; + case "playstdin": + return playStdinHandler; // Fix for issue #323 case "pause": return pauseHandler; case "resume": @@ -188,9 +306,16 @@ public MediaCommandResult handle(MediaPlayer player, Context context, Intent int if (hasTrack) { String status = player.isPlaying() ? "Playing" : "Paused"; - result.message = String.format("Status: %s\nTrack: %s\nCurrent Position: %s", status, trackName, getPlaybackPositionString(player)); + int duration = player.getDuration() / 1000; + int position = player.getCurrentPosition() / 1000; + StringBuilder sb = new StringBuilder(); + sb.append("{\n \"status\": \"").append(status).append("\","); + sb.append("\n \"track\": \"").append(trackName.replace("\"", "\\\"")).append("\","); + sb.append("\n \"position\": ").append(position).append(","); + sb.append("\n \"duration\": ").append(duration).append("\n}"); + result.message = sb.toString(); } else { - result.message = "No track currently!"; + result.message = "{\n \"status\": \"No track\"\n}"; } return result; } @@ -226,6 +351,8 @@ public MediaCommandResult handle(MediaPlayer player, Context context, Intent int player.start(); hasTrack = true; trackName = mediaFile.getName(); + // Fix for issue #516: update MediaSession playback state + updatePlaybackState(PlaybackState.STATE_PLAYING); result.message = "Now Playing: " + trackName; return result; } @@ -295,6 +422,46 @@ public MediaCommandResult handle(MediaPlayer player, Context context, Intent int return result; } }; + + // Fix for issue #323: play media from stdin by piping to temp file + static MediaCommandHandler playStdinHandler = new MediaCommandHandler() { + @Override + public MediaCommandResult handle(MediaPlayer player, Context context, Intent intent) { + MediaCommandResult result = new MediaCommandResult(); + try { + // Read all data from stdin via the input socket + java.io.InputStream in = new java.io.FileInputStream( + intent.getStringExtra("stdin_fd")); + java.io.File tempFile = java.io.File.createTempFile("termux_mplayer_", ".tmp", + context.getCacheDir()); + tempFile.deleteOnExit(); + java.io.FileOutputStream out = new java.io.FileOutputStream(tempFile); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.close(); + in.close(); + + if (hasTrack) { + player.stop(); + player.reset(); + hasTrack = false; + } + + player.setDataSource(tempFile.getCanonicalPath()); + player.prepare(); + player.start(); + hasTrack = true; + trackName = "stdin"; + result.message = "Now Playing: stdin stream"; + } catch (Exception e) { + result.error = "Error playing stdin: " + e.getMessage(); + } + return result; + } + }; } /** diff --git a/app/src/main/java/com/termux/api/apis/MediaScannerAPI.java b/app/src/main/java/com/termux/api/apis/MediaScannerAPI.java index d911980d..e017a0b4 100644 --- a/app/src/main/java/com/termux/api/apis/MediaScannerAPI.java +++ b/app/src/main/java/com/termux/api/apis/MediaScannerAPI.java @@ -24,12 +24,19 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex final boolean recursive = intent.getBooleanExtra("recursive", false); final Integer[] totalScanned = {0}; final boolean verbose = intent.getBooleanExtra("verbose", false); + // Fix for issue #317: support detecting file removals + final boolean remove = intent.getBooleanExtra("remove", false); for (int i = 0; i < filePaths.length; i++) { filePaths[i] = filePaths[i].replace("\\,", ","); } ResultReturner.returnData(apiReceiver, intent, out -> { - scanFiles(out, context, filePaths, totalScanned, verbose); + if (remove) { + // Fix for issue #317: remove orphaned files from media database + scanFilesForRemoval(out, context, filePaths, totalScanned, verbose); + } else { + scanFiles(out, context, filePaths, totalScanned, verbose); + } if (recursive) scanFilesRecursively(out, context, filePaths, totalScanned, verbose); out.println(String.format(Locale.ENGLISH, "Finished scanning %d file(s)", totalScanned[0])); }); @@ -49,6 +56,25 @@ private static void scanFiles(PrintWriter out, Context context, String[] filePat totalScanned[0] += filePaths.length; } + // Fix for issue #317: scan files for removal from media database + private static void scanFilesForRemoval(PrintWriter out, Context context, String[] filePaths, Integer[] totalScanned, final Boolean verbose) { + MediaScannerConnection.scanFile( + context.getApplicationContext(), + filePaths, + null, + (path, uri) -> { + // If the file doesn't exist but has a media DB entry, scanFile returns null uri + // The media scanner will automatically remove the orphaned entry + Logger.logInfo(LOG_TAG, "Removal scan: '" + path + "'" + (uri != null ? " -> '" + uri + "'" : " (orphaned entry removed)")); + }); + + if (verbose) for (String path : filePaths) { + out.println("removal:" + path); + } + + totalScanned[0] += filePaths.length; + } + private static void scanFilesRecursively(PrintWriter out, Context context, String[] filePaths, Integer[] totalScanned, Boolean verbose) { for (String filePath : filePaths) { Stack subDirs = new Stack<>(); diff --git a/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java index 3b954ffd..f34239f5 100644 --- a/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java +++ b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java @@ -126,7 +126,14 @@ protected static void getMediaRecorder(MicRecorderService service) { public void onDestroy() { Logger.logDebug(LOG_TAG, "onDestroy"); - cleanupMediaRecorder(); + // Move MediaRecorder cleanup to a background thread to avoid blocking UI + if (isRecording) { + new Thread(() -> { + cleanupMediaRecorder(); + }).start(); + } else { + cleanupMediaRecorder(); + } } /** @@ -254,17 +261,22 @@ public RecorderCommandResult handle(Context context, Intent intent) { int srate = intent.getIntExtra("srate", 0); int channels = intent.getIntExtra("channels", 0); - file = new File(filename); + File newFile = new File(filename); - Logger.logInfo(LOG_TAG, "MediaRecording file is: " + file.getAbsolutePath()); + Logger.logInfo(LOG_TAG, "MediaRecording file is: " + newFile.getAbsolutePath()); - if (file.exists()) { - result.error = String.format("File: %s already exists! Please specify a different filename", file.getName()); + if (newFile.exists() && newFile.isFile()) { + result.error = String.format("File: %s already exists! Please specify a different filename", newFile.getName()); + // Fix for issue #648: Stop service after file already exists error + context.stopService(intent); } else { if (isRecording) { - result.error = "Recording already in progress!"; + // Fix for issue #648 and #649: Stop service and include the currently recording file path in error + result.error = String.format("Recording already in progress: %s", file.getAbsolutePath()); + context.stopService(intent); } else { try { + file = newFile; mediaRecorder.setAudioSource(source); mediaRecorder.setOutputFormat(format); mediaRecorder.setAudioEncoder(encoder); diff --git a/app/src/main/java/com/termux/api/apis/NotificationAPI.java b/app/src/main/java/com/termux/api/apis/NotificationAPI.java index bc4ca8ae..0fc39ecf 100644 --- a/app/src/main/java/com/termux/api/apis/NotificationAPI.java +++ b/app/src/main/java/com/termux/api/apis/NotificationAPI.java @@ -6,6 +6,7 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; @@ -51,6 +52,17 @@ public class NotificationAPI { public static void onReceiveShowNotification(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { Logger.logDebug(LOG_TAG, "onReceiveShowNotification"); + // Request POST_NOTIFICATIONS permission on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Logger.logError(LOG_TAG, "POST_NOTIFICATIONS permission not granted"); + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: POST_NOTIFICATIONS permission not granted. Please grant notification permission in Android settings."); + }); + return; + } + } + Pair pair = buildNotification(context, intent); NotificationCompat.Builder notification = pair.first; String notificationId = pair.second; @@ -60,18 +72,51 @@ public void writeResult(PrintWriter out) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (!TextUtils.isEmpty(inputString)) { - if (inputString.contains("\n")) { + // Fix for issue #274: Handle both real newlines and literal \n strings + String displayText = inputString.replace("\\n", "\n"); + if (displayText.contains("\n")) { NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); - style.bigText(inputString); + style.bigText(displayText); notification.setStyle(style); + // Also set contentText so the collapsed notification shows the first line + notification.setContentText(displayText); } else { - notification.setContentText(inputString); + notification.setContentText(displayText); } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_TITLE, priorityFromIntent(intent)); + // Enable vibration on the channel - required for Android 8+ since + // channel settings override builder settings + channel.enableVibration(true); + // Copy the vibrate pattern from the intent if set + long[] channelVibrate = intent.getLongArrayExtra("vibrate"); + if (channelVibrate != null) { + channel.setVibrationPattern(channelVibrate); + } + // Only enable sound if explicitly requested (#319) + boolean channelSound = intent.getBooleanExtra("sound", false); + if (channelSound) { + channel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, + new android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) + .build()); + } else { + channel.setSound(null, null); + } + // Enable LED on the channel if led-color is set (#218) + String ledColorStr = intent.getStringExtra("led-color"); + if (ledColorStr != null) { + try { + int channelLedColor = Integer.parseInt(ledColorStr, 16) | 0xff000000; + channel.enableLights(true); + channel.setLightColor(channelLedColor); + } catch (NumberFormatException e) { + Logger.logError(LOG_TAG, "Invalid LED color format: " + ledColorStr); + } + } manager.createNotificationChannel(channel); } @@ -202,6 +247,11 @@ static Pair buildNotification(final Context notification.setWhen(System.currentTimeMillis()); notification.setShowWhen(true); + // Lockscreen visibility support (#92) + if (intent.getBooleanExtra("show_on_lock_screen", false)) { + notification.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); + } + String smallIconName = intent.getStringExtra("icon"); if (smallIconName != null) { @@ -246,28 +296,80 @@ static Pair buildNotification(final Context String mediaPause = intent.getStringExtra("media-pause"); String mediaPlay = intent.getStringExtra("media-play"); String mediaNext = intent.getStringExtra("media-next"); + // Fix for issue #881: allow specifying media_state to show only play or only pause + String mediaState = intent.getStringExtra("media-state"); - if (mediaPrevious != null && mediaPause != null && mediaPlay != null && mediaNext != null) { + boolean hasAnyButton = mediaPrevious != null || mediaPause != null || mediaPlay != null || mediaNext != null; + + if (hasAnyButton) { if (smallIconName == null) { - notification.setSmallIcon(android.R.drawable.ic_media_play); + // Fix for issue #881: use appropriate icon based on media state + if ("playing".equals(mediaState)) { + notification.setSmallIcon(android.R.drawable.ic_media_pause); + } else { + notification.setSmallIcon(android.R.drawable.ic_media_play); + } } - PendingIntent previousIntent = createAction(context, mediaPrevious); - PendingIntent pauseIntent = createAction(context, mediaPause); - PendingIntent playIntent = createAction(context, mediaPlay); - PendingIntent nextIntent = createAction(context, mediaNext); + int actionCount = 0; + + if (mediaPrevious != null) { + PendingIntent previousIntent = createMutableAction(context, mediaPrevious, actionCount); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_previous, "previous", previousIntent)); + actionCount++; + } - notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_previous, "previous", previousIntent)); - notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_pause, "pause", pauseIntent)); - notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "play", playIntent)); - notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_next, "next", nextIntent)); + // Fix for issue #881: if media_state is "playing", show pause; if "paused", show play + if ("playing".equals(mediaState)) { + // Show pause button (or both if explicitly provided) + if (mediaPause != null) { + PendingIntent pauseIntent = createMutableAction(context, mediaPause, actionCount); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_pause, "pause", pauseIntent)); + actionCount++; + } + } else if ("paused".equals(mediaState)) { + // Show play button (or both if explicitly provided) + if (mediaPlay != null) { + PendingIntent playIntent = createMutableAction(context, mediaPlay, actionCount); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "play", playIntent)); + actionCount++; + } + } else { + // No media_state specified: show both if provided (backward compatible) + if (mediaPause != null) { + PendingIntent pauseIntent = createMutableAction(context, mediaPause, actionCount); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_pause, "pause", pauseIntent)); + actionCount++; + } + if (mediaPlay != null) { + PendingIntent playIntent = createMutableAction(context, mediaPlay, actionCount); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "play", playIntent)); + actionCount++; + } + } + + if (mediaNext != null) { + PendingIntent nextIntent = createMutableAction(context, mediaNext, actionCount); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_next, "next", nextIntent)); + actionCount++; + } + + // Show all media actions in compact view + int[] compactActions = new int[actionCount]; + for (int i = 0; i < actionCount; i++) { + compactActions[i] = i; + } notification.setStyle(new androidx.media.app.NotificationCompat.MediaStyle() - .setShowActionsInCompactView(0, 1, 3)); + .setShowActionsInCompactView(compactActions)); } } - if (groupKey != null) notification.setGroup(groupKey); + if (groupKey != null) { + // Fix for issue #300: setGroupSummary ensures grouping works on all Android versions + notification.setGroup(groupKey); + notification.setGroupSummary(true); + } if (ledColor != 0) { notification.setLights(ledColor, ledOnMs, ledOffMs); @@ -285,10 +387,33 @@ static Pair buildNotification(final Context notification.setVibrate(vibrateArg); } - if (useSound) notification.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); + // Fix for issue #319: only set sound on builder if useSound is true + if (useSound) { + notification.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); + } notification.setAutoCancel(true); + // Progress bar support (#600) + int progress = intent.getIntExtra("progress", -1); + int progressMax = intent.getIntExtra("progress_max", 100); + if (progress >= 0) { + notification.setProgress(progressMax, progress, false); + } else if (intent.getBooleanExtra("progress_indeterminate", false)) { + notification.setProgress(0, 0, true); + } + + // Notification color support (#568) + String colorExtra = intent.getStringExtra("color"); + if (colorExtra != null) { + try { + int color = (int) Long.parseLong(colorExtra, 16); + notification.setColor(color); + } catch (NumberFormatException e) { + Logger.logError(LOG_TAG, "Invalid notification color format: " + colorExtra); + } + } + if (actionExtra != null) { PendingIntent pi = createAction(context, actionExtra); notification.setContentIntent(pi); @@ -297,6 +422,8 @@ static Pair buildNotification(final Context for (int button = 1; button <= 3; button++) { String buttonText = intent.getStringExtra("button_text_" + button); String buttonAction = intent.getStringExtra("button_action_" + button); + // Fix for issue #311: support setting clipboard directly from notification action + String buttonClipboard = intent.getStringExtra("button_clipboard_" + button); if (buttonText != null && buttonAction != null) { if (buttonAction.contains("$REPLY")) { @@ -308,6 +435,10 @@ static Pair buildNotification(final Context PendingIntent pi = createAction(context, buttonAction); notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_input_add, buttonText, pi)); } + } else if (buttonText != null && buttonClipboard != null) { + // Fix for issue #311: clipboard action button + PendingIntent pi = createClipboardAction(context, buttonClipboard, button); + notification.addAction(new NotificationCompat.Action(android.R.drawable.ic_input_add, buttonText, pi)); } } @@ -414,6 +545,19 @@ public static void onReceiveReplyToNotification(TermuxApiReceiver termuxApiRecei } } + /** + * Fix for issue #311: create a PendingIntent that broadcasts to set the clipboard directly. + */ + static PendingIntent createClipboardAction(final Context context, String clipboardText, int requestCode) { + Intent clipboardIntent = new Intent(); + clipboardIntent.setClassName(TermuxConstants.TERMUX_API_PACKAGE_NAME, TermuxAPIConstants.TERMUX_API_RECEIVER_NAME); + clipboardIntent.putExtra("api_method", "Clipboard"); + clipboardIntent.putExtra("set", true); + clipboardIntent.putExtra("text", clipboardText); + return PendingIntent.getBroadcast(context, requestCode + 100, clipboardIntent, + PendingIntentUtils.getPendingIntentImmutableFlag()); + } + static Intent createExecuteIntent(String action){ ExecutionCommand executionCommand = new ExecutionCommand(); executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(BIN_SH).build(); @@ -437,4 +581,14 @@ static PendingIntent createAction(final Context context, String action){ PluginUtils.getLastPendingIntentRequestCode(context), executeIntent, PendingIntentUtils.getPendingIntentImmutableFlag()); } + + /** + * Create a PendingIntent for notification actions that need to be mutable + * so the system can properly distinguish between different actions (#540). + */ + static PendingIntent createMutableAction(final Context context, String action, int requestCode){ + Intent executeIntent = createExecuteIntent(action); + return PendingIntent.getService(context, requestCode, executeIntent, + PendingIntentUtils.getPendingIntentMutableFlag()); + } } diff --git a/app/src/main/java/com/termux/api/apis/NotificationListAPI.java b/app/src/main/java/com/termux/api/apis/NotificationListAPI.java index 388e3520..0e668848 100644 --- a/app/src/main/java/com/termux/api/apis/NotificationListAPI.java +++ b/app/src/main/java/com/termux/api/apis/NotificationListAPI.java @@ -34,7 +34,15 @@ public void writeJson(JsonWriter out) throws Exception { static void listNotifications(Context context, JsonWriter out) throws Exception { NotificationService notificationService = NotificationService.get(); + if (notificationService == null) { + out.beginObject().name("error").value("Notification listener service is not connected. Please enable notification access for Termux:API in Android settings.").endObject(); + return; + } StatusBarNotification[] notifications = notificationService.getActiveNotifications(); + if (notifications == null) { + out.beginArray().endArray(); + return; + } out.beginArray(); for (StatusBarNotification n : notifications) { @@ -105,11 +113,48 @@ public static NotificationService get() { @Override public void onListenerConnected() { _this = this; + super.onListenerConnected(); } @Override public void onListenerDisconnected() { _this = null; + super.onListenerDisconnected(); + } + + // Fix for issue #860: broadcast posted notifications so clients can listen + @Override + public void onNotificationPosted(StatusBarNotification sbn) { + super.onNotificationPosted(sbn); + broadcastNotification("posted", sbn); + } + + // Fix for issue #860: broadcast removed notifications + @Override + public void onNotificationRemoved(StatusBarNotification sbn) { + super.onNotificationRemoved(sbn); + broadcastNotification("removed", sbn); + } + + // Fix for issue #860: broadcast notification events via local broadcast + private void broadcastNotification(String event, StatusBarNotification sbn) { + try { + Intent broadcast = new Intent("com.termux.api.notification." + event); + broadcast.putExtra("package", sbn.getPackageName()); + broadcast.putExtra("id", sbn.getId()); + broadcast.putExtra("key", sbn.getKey()); + if (sbn.getTag() != null) broadcast.putExtra("tag", sbn.getTag()); + if (sbn.getNotification().extras != null) { + CharSequence title = sbn.getNotification().extras.getCharSequence(Notification.EXTRA_TITLE); + if (title != null) broadcast.putExtra("title", title.toString()); + CharSequence text = sbn.getNotification().extras.getCharSequence(Notification.EXTRA_TEXT); + if (text != null) broadcast.putExtra("text", text.toString()); + } + broadcast.setPackage(com.termux.shared.termux.TermuxConstants.TERMUX_API_PACKAGE_NAME); + sendBroadcast(broadcast); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to broadcast notification event", e); + } } } diff --git a/app/src/main/java/com/termux/api/apis/SAFAPI.java b/app/src/main/java/com/termux/api/apis/SAFAPI.java index 6dd5c297..950b8a87 100644 --- a/app/src/main/java/com/termux/api/apis/SAFAPI.java +++ b/app/src/main/java/com/termux/api/apis/SAFAPI.java @@ -3,6 +3,7 @@ import android.content.Context; import android.content.Intent; import android.content.UriPermission; +import android.content.BroadcastReceiver; import android.database.Cursor; import android.net.Uri; import android.os.Build; @@ -33,6 +34,8 @@ public class SAFAPI { public static class SAFActivity extends AppCompatActivity { private boolean resultReturned = false; + // Fix for issue #499: track if we're waiting for SAF result + private boolean waitingForResult = false; private static final String LOG_TAG = "SAFActivity"; @@ -41,31 +44,67 @@ 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); + String method = getIntent().getStringExtra("safmethod"); + Intent i; + if ("pickFile".equals(method)) { + String mime = getIntent().getStringExtra("mimetype"); + if (mime == null) { + mime = "*/*"; + } + i = new Intent(Intent.ACTION_OPEN_DOCUMENT); + i.addCategory(Intent.CATEGORY_OPENABLE); + i.setType(mime); + i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } else { + i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + } + waitingForResult = true; // Fix for issue #499 + startActivityForResult(i, 1001); // Fix for issue #499: use non-zero request code } - + @Override protected void onDestroy() { Logger.logDebug(LOG_TAG, "onDestroy"); super.onDestroy(); + // Fix for issue #499: don't return empty result if still waiting for SAF + if (!resultReturned && waitingForResult) { + // Activity destroyed while waiting - don't return yet, let onActivityResult handle it + // when the SAF activity returns + finishAndRemoveTask(); + return; + } finishAndRemoveTask(); if (! resultReturned) { - ResultReturner.returnData(this, getIntent(), out -> out.write("")); + try { + ResultReturner.returnData(this, getIntent(), out -> out.write("")); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to return SAF result on destroy", e); + } 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); + waitingForResult = false; // Fix for issue #499 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); + int takeFlags = data.getFlags() & + (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + try { + if (takeFlags != 0) { + getContentResolver().takePersistableUriPermission(uri, takeFlags); + } + } catch (SecurityException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Could not take persistable uri permission for " + uri, e); + } resultReturned = true; ResultReturner.returnData(this, getIntent(), out -> out.println(data.getDataString())); } @@ -74,48 +113,71 @@ protected void onActivityResult(int requestCode, int resultCode, @Nullable Inten } } - public static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + public static void onReceive(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { Logger.logDebug(LOG_TAG, "onReceive"); - String method = intent.getStringExtra("safmethod"); + final 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 + "'"); + + if ("manageDocumentTree".equals(method)) { + manageDocumentTree(context, intent); + return; + } + + if ("pickFile".equals(method)) { + pickFile(context, intent); + return; + } + + final BroadcastReceiver.PendingResult asyncResult = apiReceiver.goAsync(); + new Thread(() -> { + try { + handleSAFMethod(apiReceiver, context, intent, method); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error in SAFAPI", e); + } finally { + asyncResult.finish(); } - } catch (Exception e) { - Logger.logStackTraceWithMessage(LOG_TAG, "Error in SAFAPI", e); + }).start(); + } + + private static void handleSAFMethod(TermuxApiReceiver apiReceiver, Context context, Intent intent, String method) { + switch (method) { + case "getManagedDocumentTrees": + getManagedDocumentTrees(apiReceiver, 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; + case "realPath": + realPath(apiReceiver, context, intent); + break; + case "realName": + realName(apiReceiver, context, intent); + break; + default: + Logger.logError(LOG_TAG, "Unrecognized safmethod: " + "'" + method + "'"); } } - + private static void getManagedDocumentTrees(TermuxApiReceiver apiReceiver, Context context, Intent intent) { ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @@ -129,14 +191,21 @@ public void writeJson(JsonWriter out) throws Exception { } }); } - + 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 pickFile(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) { @@ -149,7 +218,7 @@ private static void writeDocument(TermuxApiReceiver apiReceiver, Context context } writeDocumentFile(apiReceiver, context, intent, f); } - + private static void createDocument(TermuxApiReceiver apiReceiver, Context context, Intent intent) { String treeURIString = intent.getStringExtra("treeuri"); if (treeURIString == null) { @@ -172,11 +241,11 @@ private static void createDocument(TermuxApiReceiver apiReceiver, Context contex } catch (IllegalArgumentException ignored) {} final String finalMime = mime; final String finalId = id; - ResultReturner.returnData(apiReceiver, intent, out -> + 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) { @@ -189,7 +258,7 @@ private static void readDocument(TermuxApiReceiver apiReceiver, Context context, } returnDocumentFile(apiReceiver, context, intent, f); } - + private static void listDirectory(TermuxApiReceiver apiReceiver, Context context, Intent intent) { String treeURIString = intent.getStringExtra("treeuri"); if (treeURIString == null) { @@ -218,7 +287,7 @@ public void writeJson(JsonWriter out) throws Exception { } }); } - + private static void statURI(TermuxApiReceiver apiReceiver, Context context, Intent intent) { String uriString = intent.getStringExtra("uri"); if (uriString == null) { @@ -234,8 +303,94 @@ public void writeJson(JsonWriter out) throws Exception { } }); } - - + + // Fix for issue #498: resolve SAF URI to real filesystem path + private static void realPath(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String uriString = intent.getStringExtra("uri"); + if (uriString == null) { + Logger.logError(LOG_TAG, "uri extra null"); + return; + } + ResultReturner.returnData(apiReceiver, intent, out -> { + String realPath = getRealPathFromURI(context, Uri.parse(uriString)); + if (realPath != null) { + out.println(realPath); + } else { + out.println(""); + } + }); + } + + // Fix for issue #498: get the real file name from a SAF URI + private static void realName(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + String uriString = intent.getStringExtra("uri"); + if (uriString == null) { + Logger.logError(LOG_TAG, "uri extra null"); + return; + } + ResultReturner.returnData(apiReceiver, intent, out -> { + String realName = getRealNameFromURI(context, Uri.parse(uriString)); + if (realName != null) { + out.println(realName); + } else { + out.println(""); + } + }); + } + + /** + * Attempt to resolve a SAF URI to a real filesystem path. + * Uses DocumentsContract.getTreeDocumentId() to extract the document ID, + * then tries to map known storage prefixes to filesystem paths. + * Returns null if the path cannot be resolved. + */ + private static String getRealPathFromURI(Context context, Uri uri) { + try { + String documentId = DocumentsContract.getTreeDocumentId(uri); + // Handle primary storage: "primary:path/to/file" + if (documentId.contains(":")) { + String[] split = documentId.split(":", 2); + String type = split[0]; + String path = split[1]; + if ("primary".equalsIgnoreCase(type)) { + return "/storage/emulated/0/" + path; + } else { + // External SD card or other storage + return "/storage/" + type + "/" + path; + } + } + // For non-tree URIs, try to query the display name + try (Cursor c = context.getContentResolver().query(uri, + new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME}, null, null, null)) { + if (c != null && c.moveToFirst()) { + return c.getString(0); + } + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to resolve real path for " + uri, e); + } + return null; + } + + /** + * Get the real file name from a SAF URI by querying the document metadata. + */ + private static String getRealNameFromURI(Context context, Uri uri) { + try (Cursor c = context.getContentResolver().query(uri, + new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME}, null, null, null)) { + if (c != null && c.moveToFirst()) { + int idx = c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME); + if (idx >= 0) { + return c.getString(idx); + } + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get real name for " + uri, e); + } + return null; + } + + private static void removeDocument(TermuxApiReceiver apiReceiver, Context context, Intent intent) { String uri = intent.getStringExtra("uri"); if (uri == null) { @@ -254,8 +409,8 @@ private static void removeDocument(TermuxApiReceiver apiReceiver, Context contex } }); } - - + + private static Uri treeUriToDocumentUri(Uri tree) { String id = DocumentsContract.getTreeDocumentId(tree); try { @@ -263,7 +418,7 @@ private static Uri treeUriToDocumentUri(Uri 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) { @@ -307,7 +462,7 @@ private static void statDocument(JsonWriter out, Context context, Uri uri) throw out.endObject(); } } - + private static void returnDocumentFile(TermuxApiReceiver apiReceiver, Context context, Intent intent, DocumentFile f) { ResultReturner.returnData(apiReceiver, intent, new ResultReturner.BinaryOutput() { @@ -319,7 +474,7 @@ public void writeResult(OutputStream out) throws Exception { } }); } - + private static void writeDocumentFile(TermuxApiReceiver apiReceiver, Context context, Intent intent, DocumentFile f) { ResultReturner.returnData(apiReceiver, intent, new ResultReturner.WithInput() { @@ -331,7 +486,7 @@ public void writeResult(PrintWriter unused) throws Exception { } }); } - + private static void writeInputStreamToOutputStream(InputStream in, OutputStream out) throws IOException { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { FileUtils.copy(in, out); @@ -344,5 +499,5 @@ private static void writeInputStreamToOutputStream(InputStream in, OutputStream } } } - + } diff --git a/app/src/main/java/com/termux/api/apis/SensorAPI.java b/app/src/main/java/com/termux/api/apis/SensorAPI.java index efa8fede..ab6589ae 100644 --- a/app/src/main/java/com/termux/api/apis/SensorAPI.java +++ b/app/src/main/java/com/termux/api/apis/SensorAPI.java @@ -84,6 +84,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { if (result.type == ResultType.SINGLE) { // post one-time result now, rather than an active stream postSensorCommandResult(context, intent, result); + stopSelf(); } return Service.START_NOT_STICKY; } @@ -131,7 +132,7 @@ public void onSensorChanged(SensorEvent sensorEvent) { try { semaphore.acquire(); for (int j = 0; j < sensorEvent.values.length; ++j) { - sensorValuesArray.put(j, sensorEvent.values[j]); + sensorValuesArray.put(j, (double) sensorEvent.values[j]); } JSONObject sensorInfo = new JSONObject(); sensorInfo.put("values", sensorValuesArray); @@ -362,6 +363,8 @@ static class SensorOutputWriter extends Thread { protected int counter; protected int limit; protected SocketWriterErrorListener errorListener; + // Fix for issue #289: cache last written JSON to skip duplicate output + protected String lastWrittenJson = ""; public SensorOutputWriter(String outputSocketAddress, int delay) { @@ -408,8 +411,13 @@ public void run() { Logger.logInfo(LOG_TAG, "SensorOutputWriter interrupted: " + e.getMessage()); } semaphore.acquire(); - writer.write(sensorReadout.toString(INDENTATION) + "\n"); - writer.flush(); + String currentJson = sensorReadout.toString(INDENTATION); + // Fix for issue #289: skip writing if values haven't changed + if (!currentJson.equals(lastWrittenJson)) { + writer.write(currentJson + "\n"); + writer.flush(); + lastWrittenJson = currentJson; + } semaphore.release(); if (++counter >= limit) { diff --git a/app/src/main/java/com/termux/api/apis/SettingAPI.java b/app/src/main/java/com/termux/api/apis/SettingAPI.java new file mode 100644 index 00000000..d0653c6e --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/SettingAPI.java @@ -0,0 +1,195 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Build; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.JsonWriter; +import android.view.WindowManager; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +import java.io.PrintWriter; + +public class SettingAPI { + + private static final String LOG_TAG = "SettingAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + String action = intent.getStringExtra("action"); + if (action == null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Error: Missing 'action' extra")); + return; + } + + switch (action) { + case "get": + getSetting(apiReceiver, context, intent); + break; + case "put": + putSetting(apiReceiver, context, intent); + break; + // Fix for issue #425: dark mode detection + case "dark_mode": + getDarkMode(apiReceiver, context, intent); + break; + // Fix for issue #595: display info API + case "display_info": + getDisplayInfo(apiReceiver, context, intent); + break; + default: + ResultReturner.returnData(apiReceiver, intent, out -> out.println("Error: Unknown action: " + action)); + } + } + + private static void getSetting(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + String namespace = intent.getStringExtra("namespace"); + String key = intent.getStringExtra("key"); + + if (key == null || key.isEmpty()) { + out.println("Error: Missing 'key' extra"); + return; + } + + if (namespace == null || namespace.isEmpty()) { + namespace = "system"; + } + + String value = null; + try { + switch (namespace.toLowerCase()) { + case "system": + value = Settings.System.getString(context.getContentResolver(), key); + break; + case "secure": + value = Settings.Secure.getString(context.getContentResolver(), key); + break; + case "global": + value = Settings.Global.getString(context.getContentResolver(), key); + break; + default: + out.println("Error: Unknown namespace '" + namespace + "'. Use: system, secure, global"); + return; + } + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get setting", e); + out.println("Error: " + e.getMessage()); + return; + } + + if (value != null) { + out.println(value); + } else { + out.println(""); + } + }); + } + + // Fix for issue #425: detect dark mode + private static void getDarkMode(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginObject(); + boolean isDarkMode = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10+ has system dark mode + int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + isDarkMode = nightModeFlags == Configuration.UI_MODE_NIGHT_YES; + } else { + // Pre-Android 10: check if night mode is set via system settings + int nightMode = Settings.System.getInt(context.getContentResolver(), "ui_night_mode", 0); + isDarkMode = nightMode != 0; + } + out.name("dark_mode").value(isDarkMode); + out.endObject(); + } + }); + } + + // Fix for issue #595: display info (screen size, density, etc.) + private static void getDisplayInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginObject(); + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics metrics = new DisplayMetrics(); + if (wm != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ use WindowMetrics + android.view.WindowMetrics windowMetrics = wm.getCurrentWindowMetrics(); + android.graphics.Rect bounds = windowMetrics.getBounds(); + out.name("width_pixels").value(bounds.width()); + out.name("height_pixels").value(bounds.height()); + // Get density from window metrics config + Configuration config = context.getResources().getConfiguration(); + out.name("density_dpi").value(config.densityDpi); + out.name("density").value(context.getResources().getDisplayMetrics().density); + } else { + // Pre-Android 11 use DisplayMetrics + wm.getDefaultDisplay().getMetrics(metrics); + out.name("width_pixels").value(metrics.widthPixels); + out.name("height_pixels").value(metrics.heightPixels); + out.name("density_dpi").value(metrics.densityDpi); + out.name("density").value(metrics.density); + out.name("scaled_density").value(metrics.scaledDensity); + out.name("xdpi").value(metrics.xdpi); + out.name("ydpi").value(metrics.ydpi); + } + } + out.endObject(); + } + }); + } + + private static void putSetting(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> { + String namespace = intent.getStringExtra("namespace"); + String key = intent.getStringExtra("key"); + String value = intent.getStringExtra("value"); + + if (key == null || key.isEmpty()) { + out.println("Error: Missing 'key' extra"); + return; + } + if (value == null) { + out.println("Error: Missing 'value' extra"); + return; + } + + if (namespace == null || namespace.isEmpty()) { + namespace = "system"; + } + + try { + switch (namespace.toLowerCase()) { + case "system": + Settings.System.putString(context.getContentResolver(), key, value); + break; + case "secure": + Settings.Secure.putString(context.getContentResolver(), key, value); + break; + case "global": + Settings.Global.putString(context.getContentResolver(), key, value); + break; + default: + out.println("Error: Unknown namespace '" + namespace + "'. Use: system, secure, global"); + return; + } + out.println("Set " + namespace + "/" + key + " = " + value); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to put setting", e); + out.println("Error: " + e.getMessage()); + } + }); + } +} diff --git a/app/src/main/java/com/termux/api/apis/ShareAPI.java b/app/src/main/java/com/termux/api/apis/ShareAPI.java index a8eeb765..5c362f71 100644 --- a/app/src/main/java/com/termux/api/apis/ShareAPI.java +++ b/app/src/main/java/com/termux/api/apis/ShareAPI.java @@ -6,15 +6,20 @@ import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; +import android.os.Bundle; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.text.TextUtils; import android.webkit.MimeTypeMap; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + import com.termux.api.R; import com.termux.api.TermuxAPIConstants; import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; +import com.termux.shared.data.IntentUtils; import com.termux.shared.logger.Logger; import com.termux.shared.net.uri.UriUtils; @@ -31,10 +36,17 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex Logger.logDebug(LOG_TAG, "onReceive"); final String fileExtra = intent.getStringExtra("file"); - final String titleExtra = intent.getStringExtra("title"); + // Fix for issue #322: Also check EXTRA_SUBJECT as an alternative title source, + // since the -t flag in the shell script may only pass the first word. + String titleExtra = intent.getStringExtra("title"); + if (titleExtra == null) + titleExtra = intent.getStringExtra(Intent.EXTRA_SUBJECT); + final String finalTitleExtra = titleExtra; final String contentTypeExtra = intent.getStringExtra("content-type"); final boolean defaultReceiverExtra = intent.getBooleanExtra("default-receiver", false); final String actionExtra = intent.getStringExtra("action"); + // Fix for issue #530: blocking mode waits for the opened activity to return + final boolean blockingExtra = intent.getBooleanExtra("blocking", false); String intentAction = null; if (actionExtra == null) { @@ -72,7 +84,7 @@ public void writeResult(PrintWriter out) { sendIntent.putExtra(Intent.EXTRA_TEXT, inputString); sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (titleExtra != null) sendIntent.putExtra(Intent.EXTRA_SUBJECT, titleExtra); + if (finalTitleExtra != null) sendIntent.putExtra(Intent.EXTRA_SUBJECT, finalTitleExtra); sendIntent.setType(contentTypeExtra == null ? "text/plain" : contentTypeExtra); context.startActivity(Intent.createChooser(sendIntent, context.getResources().getText(R.string.share_file_chooser_title)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); @@ -108,7 +120,7 @@ public void writeResult(PrintWriter out) { contentTypeToUse = contentTypeExtra; } - if (titleExtra != null) sendIntent.putExtra(Intent.EXTRA_SUBJECT, titleExtra); + if (finalTitleExtra != null) sendIntent.putExtra(Intent.EXTRA_SUBJECT, finalTitleExtra); if (Intent.ACTION_SEND.equals(finalIntentAction)) { sendIntent.putExtra(Intent.EXTRA_STREAM, uriToShare); @@ -120,11 +132,63 @@ public void writeResult(PrintWriter out) { if (!defaultReceiverExtra) { sendIntent = Intent.createChooser(sendIntent, context.getResources().getText(R.string.share_file_chooser_title)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } - context.startActivity(sendIntent); + + // Fix for issue #530: blocking mode uses an Activity to wait for result + if (blockingExtra) { + Intent blockingIntent = new Intent(context, BlockingShareActivity.class); + blockingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + blockingIntent.putExtra("share_intent", sendIntent); + context.startActivity(blockingIntent); + out.println("Blocking share started"); + } else { + context.startActivity(sendIntent); + } }); } } + /** + * Transparent activity for blocking share (issue #530). + * Starts the share intent with startActivityForResult and finishes + * when the user returns from the target app. + */ + public static class BlockingShareActivity extends AppCompatActivity { + private static final String LOG_TAG = "BlockingShareActivity"; + private boolean resultReturned = false; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent shareIntent = getIntent().getParcelableExtra("share_intent"); + if (shareIntent == null) { + Logger.logError(LOG_TAG, "No share_intent extra"); + finish(); + return; + } + try { + startActivityForResult(shareIntent, 2001); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to start share activity", e); + finish(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + Logger.logDebug(LOG_TAG, "onActivityResult: resultCode=" + resultCode); + resultReturned = true; + finishAndRemoveTask(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!resultReturned) { + resultReturned = true; + } + } + } + public static class ContentProvider extends android.content.ContentProvider { private static final String LOG_TAG = "ContentProvider"; diff --git a/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java b/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java index 17e5693e..a7fe5f1f 100644 --- a/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java +++ b/app/src/main/java/com/termux/api/apis/SmsInboxAPI.java @@ -267,6 +267,15 @@ private static void writeElement(Cursor c, JsonWriter out, Map n // Deprecated: Address can be a name like service provider instead of a number. out.name("number").value(smsAddress); + // Fix for issue #431: include subscription ID for dual-SIM + int subIdIdx = c.getColumnIndex("sub_id"); + if (subIdIdx >= 0) { + int subId = c.getInt(subIdIdx); + if (subId >= 0) { + out.name("subscription_id").value(subId); + } + } + out.name("received").value(dateFormat.format(new Date(smsReceivedDate))); // if (Math.abs(smsReceivedDate - smsSentDate) >= 60000) { // out.write(" (sent "); diff --git a/app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java b/app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java index c33d7a31..6eebebb3 100644 --- a/app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java +++ b/app/src/main/java/com/termux/api/apis/SpeechToTextAPI.java @@ -39,6 +39,8 @@ public SpeechToTextService(String name) { protected SpeechRecognizer mSpeechRecognizer; final LinkedBlockingQueue queueu = new LinkedBlockingQueue<>(); + // Fix for issue #616/#588: track whether speech has ended for timeout fallback + volatile boolean speechEnded = false; private static final String LOG_TAG = "SpeechToTextService"; @@ -59,6 +61,8 @@ public void onRmsChanged(float rmsdB) { @Override public void onResults(Bundle results) { + // Fix for issue #616/#588: cancel the timeout fallback since results arrived + speechEnded = false; List recognitions = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); Logger.logError(LOG_TAG, "RecognitionListener#onResults(" + recognitions + ")"); queueu.addAll(recognitions); @@ -102,13 +106,28 @@ public void onError(int error) { description = Integer.toString(error); } Logger.logError(LOG_TAG, "RecognitionListener#onError(" + description + ")"); + // Fix for issue #616/#588: cancel timeout since error will stop recognition + speechEnded = false; queueu.add(STOP_ELEMENT); } @Override public void onEndOfSpeech() { Logger.logError(LOG_TAG, "RecognitionListener#onEndOfSpeech()"); - queueu.add(STOP_ELEMENT); + // Fix for issue #616/#588: if onResults doesn't fire within 5 seconds of onEndOfSpeech, + // stop the recognition gracefully with a timeout fallback + speechEnded = true; + new Thread(() -> { + try { + Thread.sleep(5000); + if (speechEnded) { + Logger.logError(LOG_TAG, "onEndOfSpeech timeout - forcing stop"); + queueu.add(STOP_ELEMENT); + } + } catch (InterruptedException e) { + // Do nothing + } + }).start(); } @Override @@ -165,8 +184,14 @@ protected void onHandleIntent(final Intent intent) { ResultReturner.returnData(this, intent, new ResultReturner.WithInput() { @Override public void writeResult(PrintWriter out) throws Exception { + // Use poll with timeout to avoid hanging forever if onResults never arrives (#288) while (true) { - String s = queueu.take(); + String s = queueu.poll(30, java.util.concurrent.TimeUnit.SECONDS); + if (s == null) { + // Timeout waiting for result + out.println("Error: Speech recognition timed out"); + return; + } if (s == STOP_ELEMENT) { return; } else { @@ -175,7 +200,6 @@ public void writeResult(PrintWriter out) throws Exception { } } }); - } } diff --git a/app/src/main/java/com/termux/api/apis/TelephonyAPI.java b/app/src/main/java/com/termux/api/apis/TelephonyAPI.java index 6a751e6c..4e18883a 100644 --- a/app/src/main/java/com/termux/api/apis/TelephonyAPI.java +++ b/app/src/main/java/com/termux/api/apis/TelephonyAPI.java @@ -6,6 +6,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Build; +import android.provider.Settings; import android.telephony.CellInfo; import android.telephony.CellInfoCdma; import android.telephony.CellInfoGsm; @@ -15,6 +16,8 @@ import android.telephony.CellIdentityNr; import android.telephony.CellSignalStrength; import android.telephony.CellSignalStrengthNr; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; import android.telephony.TelephonyManager; import android.util.JsonWriter; @@ -25,7 +28,6 @@ import com.termux.shared.logger.Logger; import java.io.IOException; - import java.util.List; /** @@ -263,6 +265,18 @@ public void writeJson(JsonWriter out) throws Exception { out.name("device_id").value(device_id); out.name("device_software_version").value(manager.getDeviceSoftwareVersion()); out.name("phone_count").value(manager.getPhoneCount()); + + // Fix for issue #794: include Android device name from Settings + String deviceName = Settings.Global.getString(context.getContentResolver(), Settings.Global.DEVICE_NAME); + if (deviceName != null && !deviceName.isEmpty()) { + out.name("device_name").value(deviceName); + } + // Also include Android ID as a stable identifier + String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (androidId != null) { + out.name("android_id").value(androidId); + } + String phoneTypeString; switch (phoneType) { case TelephonyManager.PHONE_TYPE_CDMA: @@ -391,6 +405,33 @@ public void writeJson(JsonWriter out) throws Exception { out.name("sim_state").value(simStateString); } + // Fix for issue #756: dual-SIM support via SubscriptionManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + try { + SubscriptionManager subManager = (SubscriptionManager) context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE); + if (subManager != null) { + List activeSubs = subManager.getActiveSubscriptionInfoList(); + if (activeSubs != null && !activeSubs.isEmpty()) { + out.name("sims"); + out.beginArray(); + for (SubscriptionInfo subInfo : activeSubs) { + out.beginObject(); + out.name("slot_index").value(subInfo.getSimSlotIndex()); + out.name("carrier_name").value(subInfo.getCarrierName() != null ? subInfo.getCarrierName().toString() : ""); + out.name("display_name").value(subInfo.getDisplayName() != null ? subInfo.getDisplayName().toString() : ""); + out.name("country_iso").value(subInfo.getCountryIso()); + out.name("subscription_id").value(subInfo.getSubscriptionId()); + out.name("number").value(subInfo.getNumber() != null ? subInfo.getNumber() : ""); + out.endObject(); + } + out.endArray(); + } + } + } catch (SecurityException e) { + Logger.logError(LOG_TAG, "Permission denied reading subscription info: " + e.getMessage()); + } + } + out.endObject(); } }); @@ -411,6 +452,7 @@ public static void onReceiveTelephonyCall(TermuxApiReceiver apiReceiver, final C Uri data = Uri.parse("tel:" + numberExtra); + // Fix for issue #519: ensure FLAG_ACTIVITY_NEW_TASK for background call initiation Intent callIntent = new Intent(Intent.ACTION_CALL); callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); callIntent.setData(data); @@ -418,7 +460,19 @@ public static void onReceiveTelephonyCall(TermuxApiReceiver apiReceiver, final C try { context.startActivity(callIntent); } catch (SecurityException e) { + // Fix for issue #428: provide helpful error message about CALL_PHONE permission Logger.logStackTraceWithMessage(LOG_TAG, "Exception in phone call", e); + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: " + e.getMessage() + ". Make sure Termux:API has the CALL_PHONE permission."); + }); + return; + } catch (Exception e) { + // Fix for issue #428: catch other exceptions (ActivityNotFoundException, etc.) + Logger.logStackTraceWithMessage(LOG_TAG, "Exception in phone call", e); + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error initiating call: " + e.getMessage()); + }); + return; } ResultReturner.noteDone(apiReceiver, intent); diff --git a/app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java b/app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java index 2dae2dd1..0289af94 100644 --- a/app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java +++ b/app/src/main/java/com/termux/api/apis/TextToSpeechAPI.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; import android.media.AudioManager; +import android.os.Build; import android.os.Bundle; import android.speech.tts.TextToSpeech; import android.speech.tts.TextToSpeech.Engine; @@ -16,6 +17,7 @@ import com.termux.shared.logger.Logger; import java.io.BufferedReader; +import java.io.File; import java.io.InputStreamReader; import java.io.PrintWriter; import java.util.Locale; @@ -30,12 +32,27 @@ public class TextToSpeechAPI { public static void onReceive(final Context context, Intent intent) { Logger.logDebug(LOG_TAG, "onReceive"); + // Fix for issue #266: support stopping in-progress TTS + boolean stop = intent.getBooleanExtra("stop", false); + if (stop) { + synchronized (TextToSpeechService.ttsLock) { + if (TextToSpeechService.mTts != null) { + TextToSpeechService.mTts.stop(); + TextToSpeechService.mTts.shutdown(); + TextToSpeechService.mTts = null; + } + } + ResultReturner.returnData(context, intent, out -> out.println("TTS stopped")); + return; + } + context.startService(new Intent(context, TextToSpeechService.class).putExtras(intent.getExtras())); } public static class TextToSpeechService extends IntentService { - TextToSpeech mTts; + static TextToSpeech mTts; final CountDownLatch mTtsLatch = new CountDownLatch(1); + static final Object ttsLock = new Object(); private static final String LOG_TAG = "TextToSpeechService"; @@ -97,14 +114,27 @@ protected void onHandleIntent(final Intent intent) { } final int streamToUse = streamToUseInt; - mTts = new TextToSpeech(this, status -> { - if (status == TextToSpeech.SUCCESS) { - mTtsLatch.countDown(); - } else { - Logger.logError(LOG_TAG, "Failed tts initialization: status=" + status); - stopSelf(); + synchronized (ttsLock) { + if (mTts != null) { + mTts.shutdown(); } - }, speechEngine); + mTts = new TextToSpeech(this, status -> { + if (status == TextToSpeech.SUCCESS) { + // Set audio attributes for proper audio focus handling (#467) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + android.media.AudioAttributes audioAttributes = new android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_ASSISTANT) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + mTts.setAudioAttributes(audioAttributes); + } + mTtsLatch.countDown(); + } else { + Logger.logError(LOG_TAG, "Failed tts initialization: status=" + status); + stopSelf(); + } + }, speechEngine); + } ResultReturner.returnData(this, intent, new ResultReturner.WithInput() { @Override @@ -121,6 +151,34 @@ public void writeResult(PrintWriter out) { return; } + // Fix for issue #356: support TTS output to file + String outputFile = intent.getStringExtra("output_file"); + boolean synthesizeToFile = outputFile != null && !outputFile.isEmpty(); + + if (synthesizeToFile) { + // Fix for issue #356: read all text, then synthesize to file + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + String line; + while ((line = reader.readLine()) != null) { + if (sb.length() > 0) sb.append("\n"); + sb.append(line); + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + File outFile = new File(outputFile); + int result = mTts.synthesizeToFile(sb.toString(), null, outFile, "synthesizeToFile"); + if (result == TextToSpeech.SUCCESS) { + out.println("TTS output written to: " + outputFile); + } else { + out.println("Error: TTS synthesizeToFile failed with code " + result); + } + } else { + out.println("Error: TTS file output requires Android 5.0+"); + } + return; + } + if ("LIST_AVAILABLE".equals(speechEngine)) { try (JsonWriter writer = new JsonWriter(out)) { writer.setIndent(" "); @@ -187,8 +245,28 @@ public void onDone(String utteranceId) { while ((line = reader.readLine()) != null) { if (!line.isEmpty()) { submittedUtterances++; - mTts.speak(line, TextToSpeech.QUEUE_ADD, params, utteranceId); + try { + mTts.speak(line, TextToSpeech.QUEUE_ADD, params, utteranceId); + } catch (Exception e) { + // TTS engine may crash when interrupted by other audio (#205) + Logger.logStackTraceWithMessage(LOG_TAG, "TTS speak error", e); + break; + } + } + } + } + + // If client disconnected before all text was sent, stop TTS immediately + if (ttsDoneUtterancesCount.get() != submittedUtterances) { + Logger.logDebug(LOG_TAG, "Client disconnected with " + (submittedUtterances - ttsDoneUtterancesCount.get()) + " utterances remaining, stopping TTS"); + mTts.stop(); + // Mark remaining as done to unblock the wait + synchronized (ttsDoneUtterancesCount) { + int remaining = submittedUtterances - ttsDoneUtterancesCount.get(); + for (int i = 0; i < remaining; i++) { + ttsDoneUtterancesCount.incrementAndGet(); } + ttsDoneUtterancesCount.notifyAll(); } } diff --git a/app/src/main/java/com/termux/api/apis/ToastAPI.java b/app/src/main/java/com/termux/api/apis/ToastAPI.java index 96ea2ea0..fc75beaf 100644 --- a/app/src/main/java/com/termux/api/apis/ToastAPI.java +++ b/app/src/main/java/com/termux/api/apis/ToastAPI.java @@ -33,14 +33,22 @@ public static void onReceive(final Context context, Intent intent) { @Override public void writeResult(PrintWriter out) { handler.post(() -> { - Toast toast = Toast.makeText(context, inputString, durationExtra); + // Fix for issue #229: empty or null string will not show a toast, use a space instead + String toastText = (inputString == null || inputString.isEmpty()) ? " " : inputString; + Toast toast = Toast.makeText(context, toastText, durationExtra); View toastView = toast.getView(); - Drawable background = toastView.getBackground(); - background.setTint(backgroundColor); - - TextView textView = toastView.findViewById(android.R.id.message); - textView.setTextColor(textColor); + if (toastView != null) { + Drawable background = toastView.getBackground(); + if (background != null) { + background.setTint(backgroundColor); + } + + TextView textView = toastView.findViewById(android.R.id.message); + if (textView != null) { + textView.setTextColor(textColor); + } + } toast.setGravity(gravity, 0, 0); toast.show(); diff --git a/app/src/main/java/com/termux/api/apis/TorchAPI.java b/app/src/main/java/com/termux/api/apis/TorchAPI.java index 1e0867c5..beebd93e 100644 --- a/app/src/main/java/com/termux/api/apis/TorchAPI.java +++ b/app/src/main/java/com/termux/api/apis/TorchAPI.java @@ -14,25 +14,51 @@ public class TorchAPI { private static Camera legacyCamera; + private static boolean torchOn = false; + // Fix for issue #884: cache the torch camera ID to avoid re-querying CameraCharacteristics every toggle + private static String cachedTorchCameraId = null; 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); + String mode = intent.getStringExtra("mode"); + boolean enabled; + + if ("toggle".equals(mode)) { + // Toggle: invert the current tracked state + enabled = !torchOn; + } else if ("on".equals(mode)) { + enabled = true; + } else if ("off".equals(mode)) { + enabled = false; + } else { + // Legacy boolean extra + enabled = intent.getBooleanExtra("enabled", false); + } toggleTorch(context, enabled); ResultReturner.noteDone(apiReceiver, intent); } private static void toggleTorch(Context context, boolean enabled) { + // Skip if state hasn't changed to avoid toggle issues on some devices (#201) + if (torchOn == enabled) { + Logger.logDebug(LOG_TAG, "Torch already " + (enabled ? "on" : "off") + ", skipping"); + return; + } try { final CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); - String torchCameraId = getTorchCameraId(cameraManager); + // Fix for issue #884: use cached camera ID if available, otherwise query and cache it + if (cachedTorchCameraId == null) { + cachedTorchCameraId = getTorchCameraId(cameraManager); + } + String torchCameraId = cachedTorchCameraId; if (torchCameraId != null) { cameraManager.setTorchMode(torchCameraId, enabled); + torchOn = enabled; } else { Toast.makeText(context, "Torch unavailable on your device", Toast.LENGTH_LONG).show(); } diff --git a/app/src/main/java/com/termux/api/apis/UsbAPI.java b/app/src/main/java/com/termux/api/apis/UsbAPI.java index 0d925773..24c5eeb2 100644 --- a/app/src/main/java/com/termux/api/apis/UsbAPI.java +++ b/app/src/main/java/com/termux/api/apis/UsbAPI.java @@ -99,6 +99,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { ResultReturner.returnData(this, intent, out -> out.append("Invalid action: \"" + action + "\"\n")); } } + // Stop the service after the action completes (#877) + // Note: permission and open actions run asynchronously, so they stop themselves + if (action == null || "list".equals(action)) { + stopSelf(); + } return Service.START_NOT_STICKY; } @@ -156,6 +161,7 @@ protected void runPermissionAction(Intent intent) { out.append("Permission request timeout.\n" ); } }); + stopSelf(); }); } @@ -191,6 +197,7 @@ public void writeResult(PrintWriter out) { } } }); + stopSelf(); }); } @@ -307,9 +314,10 @@ public void onReceive(Context context, Intent usbIntent) { // Request permission and wait. usbManager.requestPermission(device, permissionIntent); + long permissionTimeout = intent.getLongExtra("permission_timeout", 30L); try { - if (!latch.await(30L, TimeUnit.SECONDS)) { - Logger.logVerbose(LOG_TAG, "Permission request time out for device \"" + device.getDeviceName() + "\" after 30s"); + if (!latch.await(permissionTimeout, TimeUnit.SECONDS)) { + Logger.logVerbose(LOG_TAG, "Permission request time out for device \"" + device.getDeviceName() + "\" after " + permissionTimeout + "s"); return -1; } } catch (InterruptedException e) { diff --git a/app/src/main/java/com/termux/api/apis/VolumeAPI.java b/app/src/main/java/com/termux/api/apis/VolumeAPI.java index 6febb9d8..e88e462c 100644 --- a/app/src/main/java/com/termux/api/apis/VolumeAPI.java +++ b/app/src/main/java/com/termux/api/apis/VolumeAPI.java @@ -65,15 +65,46 @@ private static void printError(Context context, Intent intent, final String erro * Set volume for the specified audio stream */ private static void setStreamVolume(Intent intent, AudioManager audioManager, int stream) { - int volume = intent.getIntExtra("volume", audioManager.getStreamVolume(stream)); - int maxVolume = audioManager.getStreamMaxVolume(stream); + try { + int volume = intent.getIntExtra("volume", -1); + int maxVolume = audioManager.getStreamMaxVolume(stream); + int currentVolume = audioManager.getStreamVolume(stream); + // Fix for issue #346: Use FLAG_SHOW_UI for more reliable volume changes on some devices + // and ADJUST_SAME when device is in silent mode for STREAM_MUSIC + int flags; + if (stream == AudioManager.STREAM_MUSIC && audioManager.getRingerMode() == AudioManager.RINGER_MODE_SILENT) { + flags = AudioManager.FLAG_SHOW_UI; + audioManager.adjustStreamVolume(stream, AudioManager.ADJUST_SAME, flags); + return; + } else { + flags = AudioManager.FLAG_SHOW_UI; + } - if (volume <= 0) { - volume = 0; - } else if (volume >= maxVolume) { - volume = maxVolume; + if (volume >= 0) { + // Absolute volume set + if (volume <= 0) { + volume = 0; + } else if (volume >= maxVolume) { + volume = maxVolume; + } + audioManager.setStreamVolume(stream, volume, flags); + } else { + // Check for relative volume adjustment (#592) + int relative = intent.getIntExtra("relative", Integer.MIN_VALUE); + if (relative != Integer.MIN_VALUE) { + int newVolume = currentVolume + relative; + if (newVolume < 0) { + newVolume = 0; + } else if (newVolume > maxVolume) { + newVolume = maxVolume; + } + audioManager.setStreamVolume(stream, newVolume, flags); + } + } + } catch (Exception e) { + // Some devices throw when setting ring volume in silent mode (#249) + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to set volume", e); } - audioManager.setStreamVolume(stream, volume, 0); } /** diff --git a/app/src/main/java/com/termux/api/apis/WallpaperAPI.java b/app/src/main/java/com/termux/api/apis/WallpaperAPI.java index 3a8efe62..60ff9a50 100644 --- a/app/src/main/java/com/termux/api/apis/WallpaperAPI.java +++ b/app/src/main/java/com/termux/api/apis/WallpaperAPI.java @@ -47,7 +47,8 @@ public int onStartCommand(Intent intent, int flags, int startId) { Logger.logDebug(LOG_TAG, "onStartCommand"); if (intent.hasExtra("file")) { - getWallpaperFromFile(intent); + final Intent fIntent = intent; + new Thread(() -> getWallpaperFromFile(fIntent)).start(); } else if (intent.hasExtra("url")) { getWallpaperFromUrl(intent); } else { @@ -94,10 +95,35 @@ protected Future getWallpaperDownloader(final String url) { String contentUrl = url; if (!contentUrl.startsWith("http://") && !contentUrl.startsWith("https://")) { - contentUrl = "http://" + url; + // Try HTTPS first, fall back to HTTP (needed for Android 9+ compatibility) + contentUrl = "https://" + url; + } + + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(contentUrl).openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(30000); + connection.connect(); + } catch (Exception e) { + // If HTTPS failed and we auto-prefixed, try HTTP as fallback + if (contentUrl.startsWith("https://") && !url.startsWith("http://") && !url.startsWith("https://")) { + Logger.logDebug(LOG_TAG, "HTTPS failed, trying HTTP: " + e.getMessage()); + contentUrl = "http://" + url; + try { + connection = (HttpURLConnection) new URL(contentUrl).openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(30000); + connection.connect(); + } catch (Exception e2) { + wallpaperResult.error = "Unknown host!"; + return wallpaperResult; + } + } else { + wallpaperResult.error = "Unknown host!"; + return wallpaperResult; + } } - HttpURLConnection connection = (HttpURLConnection) new URL(contentUrl).openConnection(); - connection.connect(); String contentType = "" + connection.getHeaderField("Content-Type"); @@ -141,6 +167,8 @@ protected void postWallpaperResult(final Context context, final Intent intent, f out.flush(); out.close(); }); + // Stop the service after result is returned (#877) + stopSelf(); } @Override diff --git a/app/src/main/java/com/termux/api/apis/WifiAPI.java b/app/src/main/java/com/termux/api/apis/WifiAPI.java index 0708d9e6..d9c95460 100644 --- a/app/src/main/java/com/termux/api/apis/WifiAPI.java +++ b/app/src/main/java/com/termux/api/apis/WifiAPI.java @@ -3,10 +3,14 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.location.LocationManager; import android.net.wifi.ScanResult; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; import android.text.TextUtils; import android.text.format.Formatter; import android.util.JsonWriter; @@ -34,7 +38,14 @@ public void writeJson(JsonWriter out) throws Exception { if (info == null) { out.name("API_ERROR").value("No current connection"); } else { - out.name("bssid").value(info.getBSSID()); + // Fix for issue #304: check if location is enabled to warn about stale data + String bssid = info.getBSSID(); + String ssid = info.getSSID().replaceAll("\"", ""); + // Fix for issue #304: on Android 8.1+ with location disabled, BSSID/SSID are hidden + if (bssid == null || bssid.equals("02:00:00:00:00:00") || ssid.equals("")) { + out.name("_warning").value("Location may be enabled but WiFi location data is hidden. Ensure location is enabled in settings."); + } + out.name("bssid").value(bssid); out.name("frequency_mhz").value(info.getFrequency()); //noinspection deprecation - formatIpAddress is deprecated, but we only have a ipv4 address here: out.name("ip").value(Formatter.formatIpAddress(info.getIpAddress())); @@ -42,7 +53,7 @@ public void writeJson(JsonWriter out) throws Exception { out.name("mac_address").value(info.getMacAddress()); out.name("network_id").value(info.getNetworkId()); out.name("rssi").value(info.getRssi()); - out.name("ssid").value(info.getSSID().replaceAll("\"", "")); + out.name("ssid").value(ssid); out.name("ssid_hidden").value(info.getHiddenSSID()); out.name("supplicant_state").value(info.getSupplicantState().toString()); } @@ -56,9 +67,22 @@ static boolean isLocationEnabled(Context context) { return lm.isProviderEnabled(LocationManager.GPS_PROVIDER); } + // Fix for issue #268: check location permission before scan + static boolean hasLocationPermission(Context context) { + return context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; + } + public static void onReceiveWifiScanInfo(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { Logger.logDebug(LOG_TAG, "onReceiveWifiScanInfo"); + // Fix for issue #268: check for location permission + if (!hasLocationPermission(context)) { + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: ACCESS_FINE_LOCATION permission not granted. Termux:API needs location permission to scan WiFi networks. Grant it in Android app settings."); + }); + return; + } + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { @Override public void writeJson(JsonWriter out) throws Exception { @@ -122,6 +146,7 @@ public void writeJson(JsonWriter out) throws Exception { }); } + // Fix for issue #330: use Settings Panel on Android 10+ public static void onReceiveWifiEnable(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { Logger.logDebug(LOG_TAG, "onReceiveWifiEnable"); @@ -130,7 +155,48 @@ public static void onReceiveWifiEnable(TermuxApiReceiver apiReceiver, final Cont public void writeJson(JsonWriter out) { WifiManager manager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); boolean state = intent.getBooleanExtra("enabled", false); - manager.setWifiEnabled(state); + // Fix for issue #330: setWifiEnabled deprecated on Android 10+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Use Settings Panel on Android 10+ + Intent panelIntent = new Intent(Settings.Panel.ACTION_WIFI); + panelIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(panelIntent); + } else { + try { + manager.setWifiEnabled(state); + } catch (SecurityException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to toggle WiFi", e); + try { + out.beginObject().name("API_ERROR").value("Failed to toggle WiFi: " + e.getMessage()).endObject(); + } catch (Exception jsonException) { + throw new RuntimeException(jsonException); + } + } + } + } + }); + } + + // Fix for issue #334/#678: actively trigger WiFi rescan + public static void onReceiveWifiRescan(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceiveWifiRescan"); + + // Check location permission for scan (#268) + if (!hasLocationPermission(context)) { + ResultReturner.returnData(apiReceiver, intent, out -> { + out.println("Error: ACCESS_FINE_LOCATION permission not granted."); + }); + return; + } + + ResultReturner.returnData(apiReceiver, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + WifiManager manager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + boolean scanStarted = manager.startScan(); + out.beginObject(); + out.name("scan_initiated").value(scanStarted); + out.endObject(); } }); } 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 279176e5..0e51f793 100644 --- a/app/src/main/java/com/termux/api/util/ResultReturner.java +++ b/app/src/main/java/com/termux/api/util/ResultReturner.java @@ -201,8 +201,10 @@ public static void copyIntentExtras(Intent origIntent, Intent newIntent) { @SuppressLint("SdCardPath") public static LocalSocketAddress getApiLocalSocketAddress(@NonNull Context context, @NonNull String socketLabel, @NonNull String socketAddress) { + // Fix for issue #842: use passed context if ResultReturner.context is null + Context appContext = ResultReturner.context != null ? ResultReturner.context : context; if (socketAddress.startsWith("/")) { - ApplicationInfo termuxApplicationInfo = PackageUtils.getApplicationInfoForPackage(context, + ApplicationInfo termuxApplicationInfo = PackageUtils.getApplicationInfoForPackage(appContext, TermuxConstants.TERMUX_PACKAGE_NAME); if (termuxApplicationInfo == null) { throw new RuntimeException("Failed to get ApplicationInfo for the Termux app package: " + @@ -247,7 +249,34 @@ public static void returnData(Object context, final Intent intent, final ResultW 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)); + + // Retry connecting to the socket to handle race conditions + // where the client may not be ready yet or has momentarily closed. + // Up to 10 retries with progressive delay for Android 16 compatibility (#799). + LocalSocketAddress address = getApiLocalSocketAddress(ResultReturner.context, "output", outputSocketAddress); + boolean connected = false; + IOException lastException = null; + int maxRetries = 10; // Fix for issue #799: increased retries for Android 16 + for (int retry = 0; retry < maxRetries && !connected; retry++) { + try { + if (retry > 0) { + int delay = retry * 100; // Fix for issue #799: longer delay (100ms, 200ms, ...) + Logger.logDebug(LOG_TAG, "Retry " + retry + " connecting to output socket (delay=" + delay + "ms)"); + Thread.sleep(delay); + } + outputSocket.connect(address); + connected = true; + } catch (IOException e) { + lastException = e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + if (!connected) { + throw lastException != null ? lastException : new IOException("Failed to connect to output socket"); + } + writer = new PrintWriter(outputSocket.getOutputStream()); if (resultWriter != null) { @@ -263,7 +292,23 @@ public static void returnData(Object context, final Intent intent, final ResultW 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)); + // Fix for issue #799: retry connecting to input socket for Android 16 + LocalSocketAddress inputAddress = getApiLocalSocketAddress(ResultReturner.context, "input", inputSocketAddress); + boolean inputConnected = false; + for (int retry = 0; retry < 10 && !inputConnected; retry++) { + try { + if (retry > 0) { + Thread.sleep(retry * 100L); + } + inputSocket.connect(inputAddress); + inputConnected = true; + } catch (IOException e) { + if (retry == 9) throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } ((WithInput) resultWriter).setInput(inputSocket.getInputStream()); resultWriter.writeResult(writer); } @@ -287,8 +332,11 @@ public static void returnData(Object context, final Intent intent, final ResultW t.addSuppressed(callerStackTrace); Logger.logStackTraceWithMessage(LOG_TAG, message, t); - TermuxPluginUtils.sendPluginCommandErrorNotification(ResultReturner.context, LOG_TAG, - TermuxConstants.TERMUX_API_APP_NAME + " Error", message, t); + // Fix for issue #842: skip notification if ResultReturner.context is null (app not fully initialized) + if (ResultReturner.context != null) { + TermuxPluginUtils.sendPluginCommandErrorNotification(ResultReturner.context, LOG_TAG, + TermuxConstants.TERMUX_API_APP_NAME + " Error", message, t); + } if (asyncResult != null && receiver != null && receiver.isOrderedBroadcast()) { asyncResult.setResultCode(1); diff --git a/build-output/termux-api-debug-apk/checksums-sha256.txt b/build-output/termux-api-debug-apk/checksums-sha256.txt new file mode 100644 index 00000000..835e8e1c --- /dev/null +++ b/build-output/termux-api-debug-apk/checksums-sha256.txt @@ -0,0 +1 @@ +1b75528b969fa5d4810960b57a8fd73dffadaa2c2eeae4dada9454c4f011fd32 termux-api-app_0.53.0+fe06842.github.debug.apk diff --git a/build-output/termux-api-debug-apk/output-metadata.json b/build-output/termux-api-debug-apk/output-metadata.json new file mode 100644 index 00000000..b5b3f87a --- /dev/null +++ b/build-output/termux-api-debug-apk/output-metadata.json @@ -0,0 +1,21 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.termux.api", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1002, + "versionName": "0.53.0+fe06842", + "outputFile": "termux-api-app_0.53.0+fe06842.github.debug.apk" + } + ], + "elementType": "File", + "minSdkVersionForDexing": 24 +} \ No newline at end of file diff --git a/docs/assets/screenshot.png b/docs/assets/screenshot.png new file mode 100644 index 00000000..f8e48650 Binary files /dev/null and b/docs/assets/screenshot.png differ diff --git a/gradle.properties b/gradle.properties index 45aa582a..dc073cd4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,8 +10,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -org.gradle.jvmargs=-Xmx2048M +org.gradle.jvmargs=-Xmx512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit diff --git a/termux-api-debug-apk/checksums-sha256.txt b/termux-api-debug-apk/checksums-sha256.txt new file mode 100644 index 00000000..835e8e1c --- /dev/null +++ b/termux-api-debug-apk/checksums-sha256.txt @@ -0,0 +1 @@ +1b75528b969fa5d4810960b57a8fd73dffadaa2c2eeae4dada9454c4f011fd32 termux-api-app_0.53.0+fe06842.github.debug.apk diff --git a/termux-api-debug-apk/output-metadata.json b/termux-api-debug-apk/output-metadata.json new file mode 100644 index 00000000..b5b3f87a --- /dev/null +++ b/termux-api-debug-apk/output-metadata.json @@ -0,0 +1,21 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.termux.api", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1002, + "versionName": "0.53.0+fe06842", + "outputFile": "termux-api-app_0.53.0+fe06842.github.debug.apk" + } + ], + "elementType": "File", + "minSdkVersionForDexing": 24 +} \ No newline at end of file