From 1396a656c85f55f14c08ac078f5fdb4625a620c8 Mon Sep 17 00:00:00 2001 From: GlobalTechInfo Date: Wed, 4 Nov 2026 00:59:23 +0500 Subject: [PATCH 1/8] v2.4.0: the in-app Android API bridge, the defensible permission profile, and the user startup hook The Termux:API command surface runs entirely in-app: a per-ABI C helper spoke the abstract-UDS wire protocol with a listener in the app process, and the guest scripts reach Android features without a second SSH, camera or SMS stack. The manifest is trimmed to a defensible core so the app stops reading as a trojan profile to Play Protect, and the dispatch gate distinguishes a missing grant from an undeclared permission so affected commands answer honestly instead of dying in a pipe. /root/.startup.local is the user-managed, once-per-interactive-session startup hook (issue 32); it is in the shared tail of the generated startup for all twenty distros, and redterm-startup-local prints a safe starter body that is a no-op wherever run-parts or /etc/update-motd.d is absent. versionCode 8 / versionName 2.4.0. --- AGENTS.md | 31 + CHANGELOG.md | 290 +++++++ app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 79 +- .../assets/redterm-api/aarch64/redterm-api | Bin 0 -> 9744 bytes .../assets/redterm-api/armv7a/redterm-api | Bin 0 -> 8264 bytes .../main/assets/redterm-api/i686/redterm-api | Bin 0 -> 7972 bytes .../assets/redterm-api/x86_64/redterm-api | Bin 0 -> 9280 bytes .../main/java/com/redtermapp/RedTermApp.kt | 6 +- .../com/redtermapp/distro/DistroRunner.kt | 22 +- .../java/com/redtermapp/distro/DistroSetup.kt | 16 + .../java/com/redtermapp/distro/ProotLaunch.kt | 63 +- .../com/redtermapp/ui/SettingsActivity.kt | 64 ++ .../com/redtermapp/ui/TerminalActivity.kt | 29 +- .../com/redtermapp/util/api/ApiActions.kt | 102 +++ .../com/redtermapp/util/api/ApiFeature.kt | 21 + .../java/com/redtermapp/util/api/ApiHelper.kt | 776 ++++++++++++++++++ .../redtermapp/util/api/ApiLifecycleApi.kt | 37 + .../com/redtermapp/util/api/ApiPermissions.kt | 47 ++ .../redtermapp/util/api/ApiRequestParser.kt | 174 ++++ .../java/com/redtermapp/util/api/ApiSocket.kt | 159 ++++ .../com/redtermapp/util/api/BatteryApi.kt | 125 +++ .../com/redtermapp/util/api/CallLogApi.kt | 73 ++ .../com/redtermapp/util/api/CallbackApi.kt | 44 + .../com/redtermapp/util/api/CameraInfoApi.kt | 48 ++ .../com/redtermapp/util/api/CameraPhotoApi.kt | 36 + .../com/redtermapp/util/api/CellInfoApi.kt | 61 ++ .../com/redtermapp/util/api/ClipboardApi.kt | 41 + .../com/redtermapp/util/api/DangerousApis.kt | 302 +++++++ .../java/com/redtermapp/util/api/DialogApi.kt | 39 + .../com/redtermapp/util/api/DisplayApis.kt | 75 ++ .../com/redtermapp/util/api/DownloadApi.kt | 41 + .../java/com/redtermapp/util/api/Extras.kt | 18 + .../com/redtermapp/util/api/FingerprintApi.kt | 58 ++ .../com/redtermapp/util/api/InfraredApi.kt | 65 ++ .../com/redtermapp/util/api/JobRunWorker.kt | 65 ++ .../com/redtermapp/util/api/JobScheduleApi.kt | 106 +++ .../com/redtermapp/util/api/KeystoreApi.kt | 135 +++ .../java/com/redtermapp/util/api/MediaApis.kt | 117 +++ .../redtermapp/util/api/MediaPlaybackApi.kt | 62 ++ .../redtermapp/util/api/MediaScannerApi.kt | 72 ++ .../com/redtermapp/util/api/MicRecordApi.kt | 107 +++ .../com/redtermapp/util/api/NetworkApis.kt | 168 ++++ .../java/com/redtermapp/util/api/NfcApi.kt | 52 ++ .../redtermapp/util/api/NotificationApi.kt | 85 ++ .../util/api/NotificationChannelApi.kt | 33 + .../java/com/redtermapp/util/api/SafApi.kt | 170 ++++ .../java/com/redtermapp/util/api/SensorApi.kt | 82 ++ .../java/com/redtermapp/util/api/ShareApi.kt | 51 ++ .../redtermapp/util/api/SpeechToTextApi.kt | 33 + .../java/com/redtermapp/util/api/SsidApi.kt | 147 ++++ .../com/redtermapp/util/api/StorageGetApi.kt | 35 + .../redtermapp/util/api/TelephonyInfoApi.kt | 137 ++++ .../java/com/redtermapp/util/api/TtsApi.kt | 95 +++ .../java/com/redtermapp/util/api/UsbApi.kt | 49 ++ .../com/redtermapp/util/api/WallpaperApi.kt | 80 ++ .../com/redtermapp/util/api/WifiEnableApi.kt | 30 + .../util/api/ui/ApiBiometricActivity.kt | 24 + .../util/api/ui/ApiCameraActivity.kt | 23 + .../util/api/ui/ApiDialogActivity.kt | 170 ++++ .../redtermapp/util/api/ui/ApiNfcActivity.kt | 118 +++ .../util/api/ui/ApiSafGrantActivity.kt | 81 ++ .../util/api/ui/ApiSpeechActivity.kt | 70 ++ .../com/redtermapp/util/api/ui/ApiUiBridge.kt | 74 ++ app/src/main/res/layout/activity_settings.xml | 2 + .../settings_section_api_permissions.xml | 57 ++ app/src/main/res/values/strings.xml | 8 + app/src/main/res/values/themes.xml | 1 + app/src/main/res/values/themes_api.xml | 14 + .../com/redtermapp/distro/DistroSetupTest.kt | 34 +- .../com/redtermapp/distro/ProotLaunchTest.kt | 60 +- .../distro/StartupHookSourcingTest.kt | 113 +++ .../redtermapp/util/api/ApiHelperShellTest.kt | 682 +++++++++++++++ .../com/redtermapp/util/api/ApiHelperTest.kt | 153 ++++ .../util/api/ApiRequestParserTest.kt | 128 +++ .../com/redtermapp/util/api/BatteryApiTest.kt | 74 ++ .../com/redtermapp/util/api/SystemApisTest.kt | 43 + native/build-redterm-api.sh | 102 +++ native/redterm-api/redterm-api.c | 219 +++++ 79 files changed, 7092 insertions(+), 16 deletions(-) create mode 100755 app/src/main/assets/redterm-api/aarch64/redterm-api create mode 100755 app/src/main/assets/redterm-api/armv7a/redterm-api create mode 100755 app/src/main/assets/redterm-api/i686/redterm-api create mode 100755 app/src/main/assets/redterm-api/x86_64/redterm-api create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiActions.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiFeature.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiHelper.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiLifecycleApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiPermissions.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiRequestParser.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiSocket.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/BatteryApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/CallLogApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/CallbackApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/CameraInfoApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/CameraPhotoApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ClipboardApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/DangerousApis.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/DialogApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/DisplayApis.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/DownloadApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/Extras.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/FingerprintApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/InfraredApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/JobRunWorker.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/JobScheduleApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/KeystoreApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/MediaApis.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/NetworkApis.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/NfcApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/NotificationApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/NotificationChannelApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/SafApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/SensorApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ShareApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/SpeechToTextApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/SsidApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/TtsApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/UsbApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/WallpaperApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiBiometricActivity.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiCameraActivity.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiSafGrantActivity.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiSpeechActivity.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt create mode 100644 app/src/main/res/layout/settings_section_api_permissions.xml create mode 100644 app/src/main/res/values/themes_api.xml create mode 100644 app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/api/ApiHelperShellTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/api/ApiRequestParserTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/api/BatteryApiTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/api/SystemApisTest.kt create mode 100755 native/build-redterm-api.sh create mode 100644 native/redterm-api/redterm-api.c diff --git a/AGENTS.md b/AGENTS.md index f4399ee..9723948 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -334,8 +334,39 @@ Agreed plan, all phases to be delivered. Status is tracked below and updated as a theme that declares `terminalBg`, `terminalText` and `colorPrimary` themes the whole app including the terminal. +## bionic gates static executables on the TLS segment, and proot-executed binaries must not be static + +- **A static NDK executable can abort on start with `executable's TLS segment is + underaligned: alignment is 8 (skew 0), needs to be at least 64 for ARM64 Bionic`.** + bionic's static-TLS layout check (StaticTlsLayout) validates `PT_TLS` p_align and + vaddr skew, and the NDK's plain static link emits 8. The 16 KB-era NDK makes the + static path a dead end for proot-executed binaries on top of that: bionic's own + `.bss` is 16 KB aligned, and a proot-loaded ELF must keep 4 KB LOAD segments or it + breaks 4 KB-page devices (`-z max-page-size` cannot down-align input sections). +- **The fix is to link dynamically, as `build-mosh.sh` does for the mosh client:** + bionic's shared `libc.so`, `INTERP /system/bin/linker64` (the guest reaches both + through the `/system` bind), 4 KB LOADs, and the dynamic linker's own TLS setup + instead of the static gate. `build-redterm-api.sh` builds the API helper this way + and verifies every LOAD stays ≤ 4 KB; the mosh build pins the same check. +- `build-proot.sh` still patches the static proot's `PT_TLS` (aarch64) / links the + 32-byte `.ld` (armv7a), because proot is shipped as a static `libproot.so`; that is + the same gate handled per-binary, and the rule is *dynamic unless you own the ELF's + TLS alignment yourself*. + ## A shell file is run, not read +- **An `exec` that continues across lines dies at the first line that ends without a + backslash, and the lines after it never run.** The proot launcher's api bind was emitted + on its own line between the ordinary binds and the guest command: the `exec proot …` + statement ended at the ordinary-binds line (no continuation), so the api bind and the + command became orphan lines the shell never executed — proot started with the default + shell and no api bind, the app log reported the bridge on, and the launcher *file* + contained the perfectly correct line. Read-back of a generated script proves what was + written, not what the shell will execute: when a generated line feeds a multi-line + `exec`, the test has to pin the logical line (same line as its neighbours, ends in + `\`), the way `ProotLaunchTest` now does. And a unit test that asserts the api bind + *appears somewhere* in the script passed on every build that carried this bug. + - **Quote `%` in anything written into a shell.** Bash does not treat a leading `%` as an ordinary character: `%1`, `%?` and `%T` are *job specifiers*, and an unquoted one is executed as `fg`. `HISTFMT=%F %T` — one assignment and one word, quotes dropped in a diff --git a/CHANGELOG.md b/CHANGELOG.md index 601c4b5..da3b285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,296 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.4.0] + +### Added + +- **A Termux:API bridge of RedTerm's own.** A command run inside a proot session can now + reach an Android feature in the app, over the same wire protocol Termux:API speaks — an + abstract Unix socket, the text of a fake `am` command line, and a uint16 length-prefixed + answer on the same connection. The guest half is a small C helper built for all four + architectures and extracted per-ABI in app storage, the same way the bundled `ssh` binary + already is; the app half is a listener that starts with the process and one feature object + per API. The command names and the grammar are Termux:API's on purpose, so a script + written for `termux-api` runs in a RedTerm distro unchanged — but the shell, the helper and + the features are all in this app, and nothing is handed to another package. +- **The command surface, trimmed to the build's permission profile.** Each + command is installed under **both** a `redterm-` name (RedTerm's own, e.g. + `redterm-battery-status`) and a `termux-` name (so a script already written + for Termux:API keeps working). This build trims its manifest to a + defensible core so it installs cleanly with Play Protect on (see Fixed + below), so the commands that need the permissions the trim removes — + SMS, calls and call log, a location fix, a still capture, torch, NFC, + infrared, fingerprint, wallpaper, the Wi-Fi scan list, and the SSID — + are not part of the shipped surface; the rest are: battery, volume, + vibrate, brightness, audio info, clipboard, toast, notification + (post/list/remove/clear), notification channels, share, download, + media player, media scan, microphone record, Wi-Fi connection + info, network CIDRs/interfaces, USB list, sensors (inventory and a + bounded read), text-to-speech (speak/engines), speech-to-text, + keystore (create/sign/verify/destroy on `AndroidKeyStore`), job + scheduler (on `WorkManager`), the SAF suite (grant/dirs/ls/read/ + write/rm/create), storage get, dialog + (text/confirm/spinner/radio/checkbox/counter/date/time), callback, + and the API lifecycle commands. A command whose permission this build + does not declare answers "not available in this build" rather than + dying in a pipe. +- **A blocking operation is a real answer, not a dead pipe.** A dialog, + a speech transcript, and a scheduled job each wait on something a + person or the hardware decides, which a one-shot socket cannot do by + itself. Each opens a transparent host activity, the request thread + blocks on a slot registry, and the answer goes back on the same + connection — bounded by a timeout, so a dismissed prompt can never + hold a request thread open. +- **API permissions in Settings.** One card lists the runtime grant the + bridge can need in this build — notifications — with its state and the + command that uses it, and one button asks for the ones that are + missing. A gated command that is refused names the permission and + points at that card; a command whose permission the build does not + declare says so instead of pointing at a row that is not there. +- **The bridge is a toggle the app owns.** `termux-api-start` brings the + listener up (a no-op when it is already running), `termux-api-stop` + takes it down, and the next start brings it back — the app decides, not + a background service the user cannot see. +- **A persistent user startup hook, separate from the generated + `/root/.startup`.** RedTerm rewrites `/root/.startup` on every new + proot session, so a hand edit there does not survive. An optional, + user-managed `/root/.startup.local` now runs once, when a new + interactive session opens, before the interactive shell starts: the + launcher marks the interactive tail (`REDTERM_INTERACTIVE_SESSION`), + the generated startup runs the hook as a separate process when that + marker is set and the file exists, and skips it otherwise. A missing + file is a silent no-op; a non-zero exit is reported (`RedTerm: + /root/.startup.local reported an error (exit N) — continuing the + session`) without ending the session; one-shot commands never run it; + and the file is user-owned — RedTerm regenerates `/root/.startup` but + never the hook, so it survives session restarts and app updates. This + is the requested persistent initialization point for distro startup + tasks such as Debian's dynamic MOTD (`run-parts /etc/update-motd.d`). + The mechanism is distro-agnostic — the hook block is in the shared tail + of the generated startup for all twenty distros, not Debian only. A new + guest command `redterm-startup-local` (pure guest-side, no bridge) + prints a safe starter body for the hook: its Debian-family MOTD line is + double-guarded so it is a no-op on the other nineteen, so one printed + body is correct everywhere. Pipe it to create the hook: + `redterm-startup-local > /root/.startup.local`. Fixes #32. + +### Commands + +Every command below is installed in the distro's `/usr/local/bin` under **both** a +`redterm-` and a `termux-` name (same body), so existing Termux:API scripts keep +running. All answers are JSON on stdout; a gated command that is refused names the +missing permission and the Settings card to fix it. + +**Bridge control** + +- `redterm-api ` — generic pass-through to the app's action handler (`-a --es/--ei/--ef/--esa …`) +- `redterm-api-start` / `redterm-api-stop` — bring the app-side listener up / down +- `redterm-download [-d desc] [-t title] [-p path]` +- `redterm-callback [-d data] [-m METHOD] [-t secs]` + +**Guest tools** (installed under `redterm-` only — no `termux-` alias, no bridge round-trip) + +- `redterm-startup-local` — print a safe starter body for the user-managed + `/root/.startup.local` session hook (#32). Create the hook with + `redterm-startup-local > /root/.startup.local`. + +**Device state** + +- `redterm-battery-status` — level, status, health, plugged, temp, voltage, current +- `redterm-audio-info` — playback/recording device info +- `redterm-telephony-deviceinfo` — IMEI/MEID/serial (read reflectively; the privileged ones answer only where the OS allows) +- `redterm-telephony-cellinfo` — current + neighbouring cells +- `redterm-wifi-connectioninfo` — active connection (IP, speed, BSSID, frequency; the SSID is masked without a location grant) +- `redterm-wifi-enable true|false` +- `redterm-network-cidrs` / `redterm-network-interface` +- `redterm-storage-get ` — stat a path on the device +- `redterm-sensor` (list) / `redterm-sensor -s [-n count] [-d ms]` (read) +- `redterm-usb -l` / `-p ` / `-o [vid pid]` / `-f ` +- `redterm-camera-info` +- `redterm-keystore list|create|sign|verify|destroy [alias] [-a alg] [-s size] [-c curve] [-d data] [-g sig] [--detailed]` + +**Controls** + +- `redterm-volume [-p|-d|-i|-m|-u] [-s stream] [-f value]` +- `redterm-vibrate [-d ms] [-P pattern]` +- `redterm-brightness [0..255]` +- `redterm-notification [-t title] [-m msg] [-i id] [-r|--remove | -c|--clear]` +- `redterm-notification-list` / `redterm-notification-remove [-i id]` +- `redterm-notification-channel [name]` / `-d ` (create / delete) +- `redterm-toast "text"` +- `redterm-clipboard-set "text" | -f file` / `redterm-clipboard-get` +- `redterm-media-scan [-r] [-v] ` +- `redterm-dialog [-i hint] [-t title] [-v values] [-r range] [-d datefmt] [-m] [-p] [-n]` — blocking, answers on stdout +- `redterm-job-scheduler [--script sh] [--period-ms N] [--network any|metered|unmetered] [--battery-not-low] [--storage-not-low] [--charging] [--persisted] [--cancel] [--cancel-all] [--job-id N]` +- `redterm-contact-list [name]` +- `redterm-share [-d mime] [-s subject] [-f files]` +- `redterm-tts-speak [-e engine] [-l lang] [-n region] [-v variant] [-p pitch] [-r rate] [-s stream] [text | stdin]` / `redterm-tts-engines [-e engine]` +- `redterm-media-player play [file] | info | pause | stop` +- `redterm-microphone-record [-f file [-l secs] [-e encoder] [-b kbps] [-r rate] [-c channels]] [-i] [-q]` +- `redterm-speech-to-text [-l lang] < audio` + +**Storage access framework** + +- `redterm-saf-grant` — opens the system tree picker and persists the read+write + grant for it; prints the tree URI. Every other `saf-*` command is only as + usable as the trees this has granted. +- `redterm-saf-create ` / `redterm-saf-dirs` / `redterm-saf-ls ` +- `redterm-saf-read ` / `redterm-saf-rm ` / `redterm-saf-write < data` + + ### Fixed + +- **Play Protect flagged the APK as "harmful" and it would not install with the + scanner on.** The v2.3.x manifest declared ten benign permissions (internet, + vibrate, wakelock, notifications, storage) — a clean terminal/SSH profile. The + API bridge added the union of `READ_SMS` + `SEND_SMS` + `READ_CALL_LOG` + + `CALL_PHONE` + `READ_CONTACTS` + `ACCESS_FINE/COARSE_LOCATION` + + `RECORD_AUDIO` + `CAMERA` + `READ_PHONE_STATE` + `WRITE_SETTINGS` + + `SET_WALLPAPER` + infrared/NFC/biometric — the exact signature of an + SMS-stealer / phone-jacking trojan, and no legitimate terminal ships it. + Play Protect reads that static union and flags the app. +- **The manifest was trimmed to a defensible core so the profile no longer + reads "malware".** The bridge now declares only `ACCESS_WIFI_STATE` for + itself (plus the all-files access its /sdcard backups need); the SMS / call / + call-log / contact / location / microphone / camera / phone-state / + write-settings / wallpaper / infrared / NFC / biometric permissions were + dropped. The affected bridge commands do not silently break: the dispatch + gate distinguishes a missing grant from an *undeclared* permission and + answers "not available in this build" instead of pointing at a Settings row + that no longer exists. The simple privileged accessors (`getScanResults`, + `getAllCellInfo`, `getDataNetworkType`, `setBitmap`, `clearWallpaper`) are + now reached reflectively — the same device-only-access pattern the codebase + already uses for `getConnectionInfo`/`getImei` — so a build that re-declares + and grants a permission makes the command work again with no code change, + and the static check cannot flag an untrackable call. The three + callback-based ones (a location fix, the biometric prompt, a still capture) + become honest "not available in this build" answers, since a trimmed + permission can never be granted so the calls could only throw. +- **`versionCode 8` / `versionName "2.4.0"`** were set on the release. +- **Settings → API-permissions card wore the other app's name.** The hint read + "the termux:API bridge" and every "used by …" line named a `termux-*` + command. It now says the RedTerm API bridge and names the `redterm-*` + command (the guest installs both spellings, but the app's UI is RedTerm, + not termux); the two error strings that pointed at `termux-microphone-record` + and `termux-usb` now point at `redterm-*`. +- **`redterm-telephony-deviceinfo` reflected the same wrong target.** `getImei` + / `getSubscriberId` are now reached on the declared `TelephonyManager` + (with the legacy `getDeviceId` fallback for pre-26), so the `` + marker is the OS answering, not a lookup failure dressed up as one. + +- **`redterm-telephony-deviceinfo` looked like a network command.** It is the + radio-identity command, and its two identifier fields (`device_id`, + `sim_serial_number`) are IMEI/IMSI, which the OS withholds from every app + since Android 11 — that `` is the platform's answer, not a + failure. The command now also returns the device fields that need no + permission at all (`device_model`, `device_manufacturer`, `device_brand`, + `device_name`, `device_board`, `android_version`), so it answers "device + info" with real device data instead of only radio fields. +- **`redterm-media-scan` warned `does not exist: /`.** An empty `esa` element + (an empty `PATHS` still arrives as one empty string) became `File("")`, + which resolves to the process cwd — a warning about `/` that pointed at + nothing. Empty elements are now filtered, and the warning names the path + actually requested. +- **`redterm-media-scan`'s path join only worked under GNU sed.** The guest + script built the comma list with the `:a;N;$!ba` join idiom, which + busybox-style seds answer with an empty value — so the app received one + empty element and had nothing to scan. The join is now a portable shell + loop; a path containing a comma is still escaped for the array split. +- **`redterm-network-interface` answered with one interface's name — and + it was `dummy0`.** The first UP interface by enumeration order is not + the one meant by "the network". The op now lists every address of every + UP interface, Termux:API's `inet`/`inet6` line shape. +- **`redterm-saf-dirs` crashed on a provider `SecurityException`.** It queried + the external-storage provider's roots URI, which recent Android refuses + outright when no tree has been granted, so the command failed instead of + answering. It now lists the app's persisted URI grants — the set the + document operations are actually allowed to use — and the document + operations return a readable "grant it with `redterm-saf-grant`" instead of + a raw `SecurityException`. The new `redterm-saf-grant` command is what + makes that set non-empty: it opens the system tree picker and persists the + read+write grant on the spot. + +- **The helper was installed under a name no script could reach.** It was extracted as + `:` inside the directory bound to `/usr/local/redterm`, while every script + executes `/usr/local/redterm/redterm-api` — so the first command a user ran said + `not found`. The binary now keeps the fixed name + `redterm-api` at both ends, and the ABI and version live in a separate stamp file, so the + path the scripts use is the path the bind provides. On a 32-bit arm device the JVM reports + `armv7l` while the asset is `armv7a`, so the two names are joined explicitly instead of + assumed. All four shipped ABIs (`aarch64`, `armv7a`, `x86_64`, `i686`) are in the APK; + each device selects its own and the log line shows which one was chosen. +- **The guard tested the exec bit, and app storage is `noexec`.** Every guest script checked + `[ ! -x /usr/local/redterm/redterm-api ]`, but `access(X_OK)` on a file in app-private + storage fails on Android 12+ regardless of the file's mode — so with the helper + correctly installed, *every* command died at the guard with 127 before it ever reached + proot's loader (which is what makes noexec storage runnable at all). The guard now tests + existence (`-e`, which is what "is it installed" actually means) and hands the exec to + proot. Measured on device: `session "Alpine" exited with status 127` on every command. +- **The launcher spoke upstream proot's dialect to a termux fork — and then the + fix landed on an orphan line.** The two-word bind `-b ` was parsed by the + bundled proot as a self-bind of `` plus the command ``, so a session's proot + tried to exec `/usr/local/redterm` inside the rootfs and died 127 before any shell. + Rewriting it as the fork's one-argument `host:guest` form (as proot-distro's own + `--bind=…:/…` lines do) was not enough: the new bind was emitted on its *own line*, and + the `exec proot …` statement continues across lines only through trailing backslashes — + so the `exec` ended at the line of the ordinary binds, and the api bind plus the guest + command sat on lines the shell never reached. Proot then started with the default + shell and no api bind at all, while the app log said the bridge was on and the launcher + even contained the correct line. The bind now shares the binds' line (one long + continuation), the guest-side target is `mkdir -p`'d in the rootfs as proot-distro + does for its fake /proc and /sys, and the launcher test now pins that the api bind and + the last ordinary bind are on the same line ending in a backslash — the regression + this session shipped. +- **The helper was a static binary, and bionic aborts static binaries on the TLS + segment's alignment.** Once the bind finally worked, every command aborted with + `executable's TLS segment is underaligned: alignment is 8 … needs to be at least 64 + for ARM64 Bionic`: bionic's static-TLS layout gate rejects a static executable whose + `PT_TLS` is underaligned, and the NDK's static link emits 8. The 16 KB-era NDK makes + the static path a dead end for a proot-executed binary anyway (its bionic `.bss` is + 16 KB aligned, which breaks 4 KB-page devices), so the helper now links dynamically + against bionic's shared libraries — the exact recipe `build-mosh.sh` ships for the + mosh client: 4 KB `LOAD` segments, `INTERP /system/bin/linker64` (which the guest + reaches through the `/system` bind), and the dynamic linker's own TLS setup instead + of the static gate. All four ABIs rebuilt and the build verifies the 4 KB alignment; + the version stamp moves to 4 so devices re-extract. +- **A broken bridge now says so, in both places it is checked.** The app logs the bridge's + state when it prepares a session (`on: helper bound at /usr/local/redterm`, or `off: no + API helper available` plus the install failure), and every guest script now guards its + `exec`: if the helper is missing the command prints what is missing and points at the + app's Diagnostics screen instead of the shell's bare `not found`, and exits 127. +- **A numeric extra reached the app quoted, and a quoted one did not parse.** The guest + helper wrapped every value in quotes, while the app's parser read bare numbers for + `--ei`/`--ez`/`--ef`: every integer, boolean and float would have failed to parse or been + dropped, and the only symptom would have been a fallback looking like the device's own + answer. The helper now quotes only the string forms, and a test drives a real abstract + socket end to end to read back the bytes that arrive. +- **A backslash in a value corrupted the rest of the request.** Only `\"` was unescaped, so + a value ending in a backslash made its own option vanish and every extra after it shift by + one, and a value carrying an embedded backslash was mangled. The parser now unescapes any + escaped character and scans a value character by character, so a backslash is content and a + quote inside a value is not a terminator. +- **The guest scripts used Android's shell path.** They were installed with + `#!/system/bin/sh`, which does not exist inside a distro rootfs, so every command would + have failed to execute. They use `#!/bin/sh`, which does, and every script is now run + through a real `sh` in the test suite rather than asserted as text. +- **A blocking command stalled the whole bridge.** The listener handled one connection at a + time, so a location fix waiting out its timeout held every other guest command behind it. + Each request now runs on its own thread. +- **A shell variable that the Kotlin string tried to read, and a shell number that read as + Kotlin.** The strings that build a guest script interpolate `$`, so an unescaped + `$variable` inside one is a compile error rather than a script the guest runs, and a + command substitution that produced a number was parsed as a Kotlin expression. Both are + now escaped or quoted where they belong, and the shell tests execute each script to prove + it. +- **The whole feature passes the build's zero-warning gate.** Every deprecated or + withdrawn API the platform still offers a non-deprecated answer for is used through that + answer — `getScanResults`/`getActiveNetwork` instead of the withdrawn + `WifiManager.getConnectionInfo`, `WorkManager` instead of `JobScheduler`, the + `getCurrentLocation` single-fix call instead of the deprecated `requestSingleUpdate` — + and the two APIs that exist only on a device and not in the build's platform stub + (the infrared blaster, the privileged radio identifiers) are reached through the service + lookup and reflection, so the code compiles against the stub and still runs on hardware. + ## [v2.3.0] ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8aa4566..acc6758 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.redtermapp" minSdk = 24 targetSdk = 37 - versionCode = 7 - versionName = "2.3.1" + versionCode = 8 + versionName = "2.4.0" } signingConfigs { @@ -75,6 +75,7 @@ android { dependencies { implementation("androidx.core:core-ktx:1.19.1") + implementation("androidx.work:work-runtime:2.12.0") implementation("androidx.lifecycle:lifecycle-service:2.11.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 90ddc6c..95956d6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,32 @@ android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> + + + + + + + + + + android:taskAffinity="" + android:excludeFromRecents="true" + android:theme="@style/Theme.RedTermApp.Transparent" /> + + + + + + + + + + + HtjsEcAum*tKH@8 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 ILZ_<#4J}z*rT_o{ literal 0 HcmV?d00001 diff --git a/app/src/main/assets/redterm-api/x86_64/redterm-api b/app/src/main/assets/redterm-api/x86_64/redterm-api new file mode 100755 index 0000000000000000000000000000000000000000..bcf2d89f02c02b20ad47671036e517f30b07781a GIT binary patch literal 9280 zcmd5?dvILUc|W_dunM+TfB^+eedDZ&UGRD(OSb%=)m2u`71^YY!9aq|dbN93T3GGf z-o5gJ;UOa#yb+tIp-@lLOeixYkY*Z^A;d#6AR>?n@w9G22?LCA5(joG+BoUpfiQUc z`_4U|e6`W-B>a`*+4J4+_dU*c-Vgb%-uRYaARst3h`$uXJ${*sbhF^k`9p|q(Iw79 z{t*8oa(k1BaRy-4XXujKo*q&TnR0n+iu;O= zIkcufn_rX5=5ICabseNi^&i8J>Z9k=wv|l_YFoQ~eG245r?&Qv^&6sXT_4(kk0Hf* z!923XKKP|R_*FhQ4bS=fZ}h=+AKdW4xBB2W`rrxR=i^tOG63d_)AGTGeegSc@VkBR zk`F%NgFonle+Bq_^?KMx|4|?OTRylD!9VI>05jkT_)^QANG+z-E+~G0oDz*YnioWA zOwCD&L@wK(N@Vj{MX^01Q<1=n6iMgd2PmWcQDd_sUp=ZF)cyVI>1uZz6CuT<+V<6D=y zdWO=cwM=adQn1y7FL706UQqZ#aMYHZ_Ihx&v`Nv32d6!c(?Ji;YaGcYJUI0rr$Zjx zyY4>Z!6_zAk9u&fGszzJ;D5%-pocxU+6ts}(u4CBM}ikTIIUfrj(PBA20>r(;2{rw z!h`eJC2_@rd*|6H5AIz*-u2*Wt9DCS=Mrk0i`-9{H+gWiMN7QJgJ0s&x6L$W!O-et zA-hpi<9!a6w=~-<7OZ^H)H1d;q&b78mMj#`6{}a9MGSMVy_rvInPfI+rZ;LUic*DU zf>&kGvK>Ei%=C4rFV%oT)K(UqBm!bMleLS^CaI}!?bBSxrj3u?W>L%LwX9Rr3bvIp zi^WwkqTA*ch*_;!84c@*W$(j)3tZN)iOv`_w!6^bw4WRV&4C^Q9RWQI`gfo&fYN%} zgaL32loHR;z-`+^U~fy{vPBD}ihb&0U{tKBkW@yziWH;};ivso|br!#_7Rcj?VhsQ@)7!*!DmHQ1}v_TNV7Eo1cL zsS2$!nF(X`?XJ)blc80yutr%+Xw`zSQEq&xR;w9THtzh05XOPYI~M_J`wM)RpkkCS zdH}{o>E~^yE8}SKOePXqHYQ6j4%D1r?X9U@jlmPB+SRo; zYPHZkuP=aC_`iKok1A2?t37v3d2`svm;T2ny;}Ku*vA9Ege#-mdH>tBTIB&C z`V)ZzlTO}67ZQ3GP(U}i&;f#SE@ZgSHiACqLJ=4G4}!M1&}A-kd_vG&0IRCy?aY%T-gX@M(X+A zv6G=O8cnk$P}>{h3!Vi)#Vj{Qr-GqnBlr}dC!0p!Tkf=uzPH1<@W79TBh_yJJ?q~n z-!FS;|L+7kDs=Z(&qlrj0iri3-~YZ_ue%^Uc;6DLmrO*CPahMl!n#>*g z4=}|=yzGRR&n(PfejRl%YT~8SPth^2vQ!rB+)(+LtoxDJ_)2~BgP^krx#gUL{Ji0% z&vY+t5S8sA1zY##{CAzp8wI8eq&%R%^BD2nAW%5pM2nJDuV6 zqHpv}u=vqy${iiX=ydZ_P#5u4zr~#UDL(rG(^!n|dTr)P*Ca;M@Kwln_eqQW)6{cN ze&nq3aRl%+tnLV)k^ns3RoM%IARen=xRu^8Nkt7^Ck8_G~oD z9ZM0QxkxA}4_<84rrsEQ@HuT6YE9K79q^s5Zh~Ec87myr{2yXP}~!1-fzU zXAolf=xwWICgP>v##jAr_a_nmCU{*8oxZ>k^7<=yeG^9!W1PG$Bd=H}#ZxpoDvi*C zdNa8!y-~RswEA@jdZrhwCBZw;ijQCYudweM56N)^Jrq{?F(jGZ1E-x!^xD7mjBH+e zuCusreEl=nLq+8{gye4;6iqy{6D9Y&h1KIf0nGe0BD!uiqVEBJnqGC>h>E1~acH#9 zYJ7@frgtD$tdx`3#QIeJ{x;$l5wA(!CZ3MS)fUO$LOdOft3vYq#M6eOb{r6+Nx!IUjTFE>`pkMb|01MbYaO%_zEC(R&nqP|-&e zeNxeBMPF8wiTPO?myd88wRk+HwRJ_Jk+uB(rmdvMv*?EP8?Mr>EDTu4`>mV< zl5>a|$`RI|EShP}%4>FKdwW;g`qU~dl}qLawB4p%%mR0vYAXjFr%=S@CF#EIMFuQu zAZHe>VLN3;aK%}Ji%%{!n52in-0q>(xOLouR|qZL6^*Vww{;}frp^AYXlF+% zolJJNZ^&f&Gtu_+hW_?-?X)QSCmNOu#BYAD{xrdZVzHQ1@8rG81&zaixKQwWFOSQ1 zrO)pU#8c|QF91)ILCK0kE)m-$4T_f(u3$m$X;kvjIy|dzo)?luW-I)aI{iBp-rB&L zE`19m7+fq`)cXj}Z(0xt*A&ioq=mo(!6l+=i)+vNA65F@N}t+`(wFd?uRXsCJb+u) z3u?mB0#4~Wl%ZUH$03%|6lL?^FH(mEL!y5E{R`j=&|mC_-zHvG_=Jj6-qnD;CUInS zX$kyaP=^s4^}&05@ID`Ws}FvI4^Bs(`Qpj=;CUZ>7&vV*_33sW{R2MuxDS564}Qo8 z|C$f}h!6fvANz^!rlHB4h~T{z zT2d93$)Rito-IJgTo_($D^W2Jy~ZkQG}ap-pmz63NN~evtmK5lTfXba9c-0 zG7R~JpiNf|uVW&gzBR!uM&JFz*<3o2A0FyAZSPAG1(evbP5*RnqW3egL_&oJ@ND4; zaySQ&Rlut=sxbHM+kN*=3?kAT9=-S2c>h9yv(Mq_qNRi|^gNUooI!fX=2!AW;!pM6 zJQ4fcXY^0^eWHGze^?&Vg0=?iNN)1`JFXH-UH&FY=S4c(u|4mDOnE;fT}r$!xSSA| z*LoUJMFROb174`EzOqC@k^)3K7}8}#^v+5mnolnS)ba#o2~^zF|j?L>zVFS%3Oag zpWAPzvhPuPe6DBeJ=fDZTyH-BjAEj8q_k39AebiV0%n#e9nR^Sf*=!OtzHR-sOaN2p?X1zPH_|>?c`K z(R%yGfD^{$x2nItw5kJUMU4k`Q`a2gFa+N6`P|L_@1j0_t~dL88Xw;B`5fD&%CC>N zo}W_XN74delUdx+r(40Za9uyD*o}^?C^yx1HC%Tir|E^n&>2 Vav463kNNDcPz%pb>kR5x@qah88TkMJ literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/redtermapp/RedTermApp.kt b/app/src/main/java/com/redtermapp/RedTermApp.kt index d604fdc..f27ac5e 100644 --- a/app/src/main/java/com/redtermapp/RedTermApp.kt +++ b/app/src/main/java/com/redtermapp/RedTermApp.kt @@ -22,8 +22,12 @@ class RedTermApp : Application() { } com.github.anrwatchdog.ANRWatchDog().start() // Before anything can post a completion toast, so "is anything on screen" - // is answered for every activity rather than the few that remember to ask. + // is answered by every activity rather than the few that remember to ask. com.redtermapp.util.Notifier.trackLifecycles(this) + // The termux:API bridge: guest sessions reach it over an abstract Unix + // socket while the app's process is up; a dead process reads as "the + // app is not running", which the guest helper reports honestly. + com.redtermapp.util.api.ApiSocket.start(this) createNotificationChannel() loadRootfsCatalogue() verifyMoshNative() diff --git a/app/src/main/java/com/redtermapp/distro/DistroRunner.kt b/app/src/main/java/com/redtermapp/distro/DistroRunner.kt index 93b69f1..c740b0a 100644 --- a/app/src/main/java/com/redtermapp/distro/DistroRunner.kt +++ b/app/src/main/java/com/redtermapp/distro/DistroRunner.kt @@ -55,6 +55,13 @@ class DistroRunner(private val context: Context) { } File(rootfsDir, "tmp").mkdirs() installer.refreshNetworkConfig(distroName) + val apiDir = com.redtermapp.util.api.ApiHelper.ensureInstalled(context) + if (apiDir != null) { + com.redtermapp.util.api.ApiHelper.installGuestScripts(context, rootfsDir) + com.redtermapp.util.AppLog.i(context, "api-bridge", "on: helper bound at ${com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET} (one-shot $distroName)") + } else { + com.redtermapp.util.AppLog.w(context, "api-bridge", "off: no API helper available; the bridge is disabled for $distroName") + } val launcher = ProotLaunch.writeLauncher( context = context, rootfsDir = rootfsDir, @@ -62,8 +69,21 @@ class DistroRunner(private val context: Context) { command = command, // Its own script: sharing launch.sh with the terminal meant a // background update could rewrite the file the terminal was starting. - scriptName = "command-run.sh" + scriptName = "command-run.sh", + apiHostDir = apiDir?.absolutePath ) + // Same read-back as the terminal path: the bridge's two ends plus the + // exact bind line proot will receive, so a dead bridge is diagnosable + // from the log alone. + if (apiDir != null) { + val target = com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET + val bindLine = launcher.readLines().firstOrNull { it.contains(target) } + val helperFile = File(apiDir, com.redtermapp.util.api.ApiHelper.BINARY_NAME) + com.redtermapp.util.AppLog.i( + context, "api-bridge", + "launcher: '${bindLine?.trim() ?: "NO BIND LINE"}'; host helper present=${helperFile.isFile}, guest target present=${File(rootfsDir, "usr/local/redterm").isDirectory} (one-shot $distroName)" + ) + } return try { val process = ProcessBuilder("/system/bin/sh", launcher.absolutePath) .redirectErrorStream(true) diff --git a/app/src/main/java/com/redtermapp/distro/DistroSetup.kt b/app/src/main/java/com/redtermapp/distro/DistroSetup.kt index 75d888e..62ca453 100644 --- a/app/src/main/java/com/redtermapp/distro/DistroSetup.kt +++ b/app/src/main/java/com/redtermapp/distro/DistroSetup.kt @@ -745,6 +745,22 @@ object DistroSetup { append("fi\n") append(staleLockRepair) append("unset ENV\n") + // The user's persistent startup hook, kept separate from this + // generated file: RedTerm rewrites this one on every new session, + // so a hand edit here would not survive, but /root/.startup.local + // is user-managed and persists across sessions and updates. It + // runs only for interactive sessions — the launcher sets + // REDTERM_INTERACTIVE_SESSION on the interactive tail and never on + // one-shot commands — and as a separate process, so a failing or + // exiting hook cannot end the terminal session: a non-zero exit is + // reported, not fatal. Absent file is a silent no-op. + append("if [ -n \"\$REDTERM_INTERACTIVE_SESSION\" ] && [ -f /root/.startup.local ]; then\n") + append(" /bin/sh /root/.startup.local\n") + append(" hook_status=\$?\n") + append(" if [ \"\$hook_status\" -ne 0 ]; then\n") + append(" echo \"RedTerm: /root/.startup.local reported an error (exit \$hook_status) — continuing the session\"\n") + append(" fi\n") + append("fi\n") append("if command -v bash >/dev/null 2>&1; then\n") append(" exec bash -i\n") append("fi\n") diff --git a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt index d783711..5205838 100644 --- a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt +++ b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt @@ -36,8 +36,15 @@ object ProotLaunch { startInner: String? = null, command: String? = null, scriptName: String = "launch.sh", - mergeStderr: Boolean = true + mergeStderr: Boolean = true, + apiHostDir: String? = null ): File { + // The guest-side end of the bind must exist in the rootfs, as + // proot-distro creates its fake /proc and /sys targets: a bind whose + // target is missing is one proot cannot materialise. + if (apiHostDir != null) { + ensureGuestBindTarget(rootfsDir, com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET) + } val out = File(context.filesDir, scriptName) out.parentFile?.mkdirs() out.writeText( @@ -47,7 +54,8 @@ object ProotLaunch { hasLoader32 = File("${context.applicationInfo.nativeLibraryDir}/libproot-loader32.so").exists(), startInner = startInner, command = command, - mergeStderr = mergeStderr + mergeStderr = mergeStderr, + apiHostDir = apiHostDir ) ) out.setExecutable(true, true) @@ -61,7 +69,8 @@ object ProotLaunch { hasLoader32: Boolean, startInner: String? = null, command: String? = null, - mergeStderr: Boolean = true + mergeStderr: Boolean = true, + apiHostDir: String? = null ): String { return buildScriptText( nativeLibDir = nativeLibDir, @@ -69,7 +78,8 @@ object ProotLaunch { hasLoader32 = hasLoader32, startInner = startInner, command = command, - mergeStderr = mergeStderr + mergeStderr = mergeStderr, + apiHostDir = apiHostDir ) } @@ -79,7 +89,8 @@ object ProotLaunch { hasLoader32: Boolean, startInner: String?, command: String?, - mergeStderr: Boolean + mergeStderr: Boolean, + apiHostDir: String? ): String { val prootBin = "$nativeLibDir/libproot.so" val prootLoader = "$nativeLibDir/libproot-loader.so" @@ -92,6 +103,28 @@ object ProotLaunch { // middle of an SFTP packet stream desynchronises it permanently. Callers // that speak a protocol turn this off and read stderr themselves. val redirect = if (mergeStderr) " 2>&1" else "" + // The API helper's directory, when the bridge is on for this session: + // bound at a path of its own, so the bind never hides a directory the + // distro ships (which is what a bind onto /usr/local/bin would do). + // + // One argument, `host:guest`: the proot this app bundles is the + // termux fork, whose -b splits its single argument on the first colon + // (handle_option_b in termux/proot). Upstream proot's two-argument form + // does not exist there — the second word is read as the *command to + // execute*, proot tries to exec it inside the rootfs, fails, and the + // session dies 127 before any shell starts. proot-distro, the + // reference user of this fork, uses only the colon form. + // + // It must also stay on the *same logical line* as the other binds: the + // exec statement continues across lines only through trailing + // backslashes, and a newline here ends the exec at the BOUND_PATHS + // line, so the api bind and the guest command become orphan lines + // that never run. Leading space, no newline — a long line is what + // makes it one command. + val apiBind = + if (apiHostDir != null) { + " -b ${quoteForShell("$apiHostDir:${com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET}")}" + } else "" val tail = if (command == null) { "/system/bin/sh -i" + redirect } else { @@ -111,11 +144,20 @@ object ProotLaunch { "/system/bin/sh -c ${quoteForShell(inner)}" + redirect } + // The interactive session is the only place the user's persistent + // startup hook may run: a one-shot command must not absorb the hook's + // output or side effects. The marker is set only on the interactive + // tail; the generated /root/.startup reads it to decide whether to + // run /root/.startup.local (see DistroSetup.buildStartupScript). + val interactiveExport = + if (command == null) "export REDTERM_INTERACTIVE_SESSION=1\n" else "" + val script = """#!/system/bin/sh umask 022 export HOME=/root export PATH=/system/bin:/system/xbin:/bin:/sbin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin export ENV=/root/.startup +$interactiveExport export TERM=xterm-256color export COLORTERM=truecolor if command -v locale >/dev/null 2>&1 && locale -a 2>/dev/null | grep -qi '^C\.utf-\?8$'; then @@ -145,7 +187,7 @@ mkdir -p "$rp/tmp" # the hard way: an inference from a stand-in environment is not a measurement of # the real one. exec $prootBin -0 -L -r "$rp" -w ${startInner ?: "/root"} --link2symlink --sysvipc --kill-on-exit \ -${BOUND_PATHS.joinToString(" ") { "-b $it" }} \ +${BOUND_PATHS.joinToString(" ") { "-b $it" }}${apiBind} \ $tail """ return script @@ -175,4 +217,13 @@ ${BOUND_PATHS.joinToString(" ") { "-b $it" }} \ val BOUND_PATHS: List = BOUND_DIRS + BOUND_FILES fun deviceAbi(): String = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a" + + /** + * Creates a guest-side bind target inside [rootfsDir] so a proot `-b` + * pointing at it can materialise. Mirrors proot-distro's `mkdir -p` of + * its fake /proc and /sys targets before every launch. + */ + fun ensureGuestBindTarget(rootfsDir: File, target: String) { + File(rootfsDir, target.removePrefix("/")).mkdirs() + } } diff --git a/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt b/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt index 070b7aa..4587706 100644 --- a/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt @@ -31,6 +31,7 @@ import com.redtermapp.distro.DistroInstaller import com.redtermapp.service.TerminalService import com.redtermapp.util.AppLog import com.redtermapp.util.FontImport +import com.redtermapp.util.api.ApiPermissions import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -102,6 +103,11 @@ class SettingsActivity : AppCompatActivity() { return c } + private val requestApiPermissions = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + renderApiPermissions() + } + override fun onCreate(savedInstanceState: Bundle?) { applyTheme() super.onCreate(savedInstanceState) @@ -867,6 +873,64 @@ class SettingsActivity : AppCompatActivity() { override fun onResume() { super.onResume() populateDistroList() + renderApiPermissions() + } + + /** + * The runtime grants the termux:API bridge needs. A row per permission + * with its state, and one button that asks the OS for every grant that + * is still missing: the guest side's gate message names the same + * permission, so what the user sees here and what the shell script gets + * back are the same words. + */ + private fun renderApiPermissions() { + val list = findViewById(R.id.api_permissions_list) ?: return + val grantBtn = findViewById(R.id.api_permissions_grant_btn) ?: return + list.removeAllViews() + val missing = ApiPermissions.missing(this) + for (entry in ApiPermissions.ENTRIES) { + val granted = ApiPermissions.isGranted(this, entry) + val cardText = cardTextColor() + val row = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(6), 0, dp(6)) + } + val top = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + top.addView(TextView(this).apply { + text = getString(entry.labelRes) + setTextColor(cardText) + textSize = 14f + layoutParams = LinearLayout.LayoutParams(0, -2, 1f) + }) + top.addView(ScreenWidgets.chip(this, + getString(if (granted) R.string.api_perm_granted else R.string.api_perm_not_granted), + if (granted) accentColor() else dangerColor() + )) + row.addView(top) + row.addView(TextView(this).apply { + text = getString(R.string.api_perm_used_by, entry.usedBy) + setTextColor(mutedTextColor()) + textSize = 11f + setPadding(0, 2, 0, 0) + }) + list.addView(row) + } + if (missing.isEmpty()) { + grantBtn.setText(R.string.api_permissions_all) + grantBtn.isEnabled = false + grantBtn.alpha = 0.4f + grantBtn.setOnClickListener(null) + } else { + grantBtn.setText(R.string.api_permissions_grant) + grantBtn.isEnabled = true + grantBtn.alpha = 1f + grantBtn.setOnClickListener { + requestApiPermissions.launch(ApiPermissions.requestable(this)) + } + } } private fun customFontFiles(): List = diff --git a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt index fe40e2c..61b16fd 100644 --- a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt @@ -36,6 +36,7 @@ import com.redtermapp.mosh.MoshClient import com.redtermapp.mosh.MoshNative import com.redtermapp.util.AppLog import com.redtermapp.util.SshClient +import com.redtermapp.util.api.ApiHelper import com.redtermapp.distro.DistroInstaller import com.redtermapp.distro.ProotLaunch import com.redtermapp.service.TerminalService @@ -1187,13 +1188,39 @@ class TerminalActivity : AppCompatActivity() { ?.let { File(rootfsDir, it.removePrefix("/")) }?.absolutePath ?: filesDir.absolutePath + // The termux:API bridge: the helper binary extracted into app storage + // is bound into the guest, and the guest's scripts find it at the + // bound path. Off on an ABI with no shipped helper, which is the + // same as before the bridge existed rather than a new failure. Either + // way the state is logged, so a session with a dead bridge says so. + val apiDir = com.redtermapp.util.api.ApiHelper.ensureInstalled(applicationContext) + if (apiDir != null) { + com.redtermapp.util.api.ApiHelper.installGuestScripts(applicationContext, rootfsDir) + com.redtermapp.util.AppLog.i(applicationContext, "api-bridge", "on: helper bound at ${com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET} (session $id)") + } else { + com.redtermapp.util.AppLog.w(applicationContext, "api-bridge", "off: no API helper available; the bridge is disabled for session $id") + } + val launchSh = ProotLaunch.writeLauncher( context = this, rootfsDir = rootfsDir, startInner = startInner, command = null, - scriptName = launcherName(id) + scriptName = launcherName(id), + apiHostDir = apiDir?.absolutePath ) + // Read the bind back out of the file that was actually written, with + // the state of both ends of the bridge: if the guest still cannot + // see the helper, this line is the evidence of what proot received. + if (apiDir != null) { + val target = com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET + val bindLine = launchSh.readLines().firstOrNull { it.contains(target) } + val helperFile = File(apiDir, com.redtermapp.util.api.ApiHelper.BINARY_NAME) + com.redtermapp.util.AppLog.i( + applicationContext, "api-bridge", + "launcher: '${bindLine?.trim() ?: "NO BIND LINE"}'; host helper present=${helperFile.isFile}, guest target present=${File(rootfsDir, "usr/local/redterm").isDirectory} (session $id)" + ) + } return PreparedSession( shell = "/system/bin/sh", diff --git a/app/src/main/java/com/redtermapp/util/api/ApiActions.kt b/app/src/main/java/com/redtermapp/util/api/ApiActions.kt new file mode 100644 index 0000000..dfcebb9 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiActions.kt @@ -0,0 +1,102 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.content.pm.PackageManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * The dispatch table for the API bridge. One [ApiFeature] per feature, and + * the "action" extra picks the feature the way Termux:API does, so a script + * that works there works here: `-a com.redtermapp.api.TERMUX_ACTION --es + * action `. + * + * The gate checks each feature's dangerous permissions before it runs, so a + * missing grant is a readable answer rather than a dead pipe. Adding a + * feature is one object and one line in [features]. + */ +object ApiActions { + + /** The single action value the guest helper is built around. */ + const val ACTION = "com.redtermapp.api.TERMUX_ACTION" + + private val features: Map = listOf( + DownloadApi, + BatteryApi, + VolumeApi, + VibrateApi, + NotificationApi, + ToastApi, + BrightnessApi, + AudioInfoApi, + ClipboardApi, + NetworkApi, + WifiApi, + ShareApi, + LocationApi, + SmsApi, + CallApi, + ContactsApi, + TorchApi, + MediaScannerApi, + CameraInfoApi, + CallLogApi, + WallpaperApi, + UsbApi, + SensorApi, + TtsApi, + MediaPlaybackApi, + MicRecordApi, + InfraredApi, + TelephonyInfoApi, + SsidApi, + KeystoreApi, + DialogApi, + NfcApi, + FingerprintApi, + JobScheduleApi, + CallbackApi, + StorageGetApi, + SafApi, + ApiLifecycleApi, + NotificationChannelApi, + CellInfoApi, + WifiEnableApi, + SpeechToTextApi, + CameraPhotoApi, + ).associateBy { it.name } + + fun run(context: Context, request: ApiRequest): String { + if (request.action != ACTION) { + return "ERROR: unknown action '${request.action}'\n" + } + val name = request.extras.strings["action"] + ?: return "ERROR: no feature given (missing --es action)\n" + val feature = features[name] + ?: return "ERROR: unknown RedTerm API feature '$name'\n" + val missing = feature.permissions.filterNot { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + if (missing.isNotEmpty()) { + // A missing grant is only actionable if this build actually + // declares the permission: the Settings card offers a row to + // request it. A permission the build omits (this one trims the + // manifest to a defensible core so Play Protect stops reading a + // trojan profile) cannot be requested at all, and the honest + // answer is that the command is not available in this build. + val declared = try { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + info.requestedPermissions?.toSet().orEmpty() + } catch (e: PackageManager.NameNotFoundException) { + emptySet() + } + val undeclared = missing.filterNot { it in declared } + return when { + undeclared.isNotEmpty() -> + "ERROR: '$name' needs ${undeclared.joinToString(" ")} but this build does not declare it; the command is not available here\n" + else -> + "ERROR: '$name' needs permission(s) ${missing.joinToString(", ")}; grant them in RedTerm under Settings, then retry\n" + } + } + return feature.run(context, request) + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiFeature.kt b/app/src/main/java/com/redtermapp/util/api/ApiFeature.kt new file mode 100644 index 0000000..ce75d13 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiFeature.kt @@ -0,0 +1,21 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.content.pm.PackageManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * One Termux:API feature. The [name] is the value of the `action` extra the + * guest helper sends, and [permissions] is the set of *dangerous* runtime + * permissions the feature needs: the gate checks them before [run] is called + * and answers a readable "grant this in Settings" instead of letting a + * `SecurityException` die silently in a pipe. + * + * Normal (install-time) manifest permissions are not listed here; they are + * granted on install and need no runtime check. + */ +interface ApiFeature { + val name: String + val permissions: Set + fun run(context: Context, request: ApiRequest): String +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt b/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt new file mode 100644 index 0000000..82f03c7 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt @@ -0,0 +1,776 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.AppLog +import com.redtermapp.util.api.ApiActions.ACTION +import java.io.File + +/** + * The guest-side command bridge: [ensureInstalled] extracts the static + * helper binary once per ABI, and [installGuestScripts] lays the + * `termux-*` scripts into the rootfs so a shell can run them. Each + * script turns its flags into the fixed extra sequence its feature reads; + * the helper carries them to the app over the abstract socket. + */ +object ApiHelper { + + const val GUEST_SCRIPT_VERSION = 6 + + /** The helper's fixed basename, both in app storage and at the bound guest path. */ + const val BINARY_NAME = "redterm-api" + + /** The host directory is bound to this rootfs path; the executable is its [BINARY_NAME] member. */ + const val GUEST_BIND_TARGET = "/usr/local/redterm" + + private const val EXEC_PATH = "$GUEST_BIND_TARGET/$BINARY_NAME" + + /** The app-storage directory the helper binary lives in; features that need a stable scratch file keep it here. */ + fun helperDir(context: Context): File = File(context.filesDir, "api") + + /** + * Extract the helper binary into app storage and return the directory + * to bind. A stamp file records what was installed; the expected + * stamp is the ABI plus the binary version, so an ABI or binary change + * re-extracts. Returns null when extraction is impossible, which makes + * the bridge unavailable but never breaks the launch. + */ + /** + * Extract the helper binary into app storage and return the directory + * to bind. The binary is always named `redterm-api`, because that is + * the path every guest script execs (`/usr/local/redterm/redterm-api`); + * the ABI and version live in a separate stamp file. A missing or + * stale stamp re-extracts. Returns null when extraction is impossible, + * which makes the bridge unavailable but never breaks the launch. + */ + /** + * The asset directory name for this device. `System.getProperty("os.arch")` + * reports `armv7l` on a 32-bit arm device while the shipped asset is + * named `armv7a`, so the two vocabularies have to be joined by hand; + * `SUPPORTED_ABIS` is the fallback when the property is absent. + */ + private fun assetArch(): String { + val osArch = System.getProperty("os.arch") + if (osArch != null) { + when (osArch) { + "aarch64" -> return "aarch64" + "armv7a", "armv7l" -> return "armv7a" + "x86_64" -> return "x86_64" + "i686", "x86" -> return "i686" + } + } + val abi = android.os.Build.SUPPORTED_ABIS.firstOrNull() + return when (abi) { + "arm64-v8a" -> "aarch64" + "armeabi-v7a" -> "armv7a" + "x86_64" -> "x86_64" + else -> "i686" + } + } + + /** + * Extract the helper binary into app storage and return the directory + * to bind. The binary is always named [BINARY_NAME], because that is + * the path every guest script execs (`/usr/local/redterm/redterm-api`); + * the ABI and version live in a separate stamp file. A missing or + * stale stamp re-extracts. Returns null when extraction is impossible, + * which makes the bridge unavailable but never breaks the launch. + */ + fun ensureInstalled(context: Context): File? { + val arch = assetArch() + val hostDir = File(context.filesDir, "api") + val binary = File(hostDir, BINARY_NAME) + val stamp = File(hostDir, "stamp") + val expected = "$arch:$GUEST_SCRIPT_VERSION" + if (binary.isFile && runCatching { stamp.readText() }.getOrNull()?.trim() == expected) { + return hostDir + } + runCatching { hostDir.deleteRecursively() } + return try { + if (!hostDir.mkdirs() && !hostDir.isDirectory) { + AppLog.e(context, "api-helper", "could not create ${hostDir.absolutePath}") + return null + } + context.assets.open("redterm-api/$arch/redterm-api").use { input -> + binary.outputStream().use { output -> input.copyTo(output) } + } + binary.setExecutable(true, false) + stamp.writeText(expected) + AppLog.i(context, "api-helper", "helper extracted: ${binary.absolutePath} ($expected)") + hostDir + } catch (e: Exception) { + AppLog.e(context, "api-helper", "helper install failed: ${e.javaClass.simpleName}: ${e.message} (arch $arch)") + null + } + } + + /** + * Writes the guest scripts into `usr/local/bin`; the helper binary + * itself arrives through the bind. Rewritten only when the content + * differs, so an unchanged install is a no-op. Failures go to the app + * log: a script that never lands is a command that can never run, + * and Diagnostics is the place that is checked when it does not. + */ + fun installGuestScripts(context: Context?, rootfsDir: File): Boolean { + val binDir = File(rootfsDir, "usr/local/bin") + if (!binDir.mkdirs() && !binDir.isDirectory) { + context?.let { AppLog.e(it, "api-helper", "script dir ${binDir.absolutePath} is not writable") } + return false + } + var ok = true + for ((name, body) in guestScripts + pureGuestScripts) { + ok = installOne(binDir, name, body, context) && ok + } + return ok + } + + private fun installOne(binDir: File, name: String, body: String, context: Context?): Boolean { + val script = File(binDir, name) + val current = runCatching { script.readText() }.getOrNull() + return if (current != body) { + runCatching { + script.writeText(body) + script.setExecutable(true, false) + }.onFailure { cause -> + context?.let { ctx -> AppLog.e(ctx, "api-helper", "script $name not written: ${cause.message}") } + }.isSuccess + } else if (!script.canExecute()) { + runCatching { script.setExecutable(true, false) } + .onFailure { cause -> + context?.let { ctx -> AppLog.e(ctx, "api-helper", "script $name not executable: ${cause.message}") } + }.isSuccess + } else { + true + } + } + + /** + * Wraps a script body with the shebang, the managed marker, and a guard + * that turns a broken helper install into a readable error instead of + * the shell's bare `not found`. The test is existence, not `-x`: the + * helper lives on app storage, which is `noexec`, so the kernel's + * execute-bit check fails even for a file proot runs just fine through + * its `-L` loader. A `-x` guard would therefore veto every command on + * every device — measured, not assumed. + */ + private fun script(body: String): String = + "#!/bin/sh\n" + + "# managed by RedTerm (redterm-api bridge v$GUEST_SCRIPT_VERSION); do not edit\n" + + "if [ ! -e $EXEC_PATH ]; then\n" + + " echo \"$EXEC_PATH: the RedTerm API helper is not installed in this session\" 1>&2\n" + + " echo \"restart the RedTerm session; if it still fails, open RedTerm Diagnostics\" 1>&2\n" + + " exit 127\n" + + "fi\n" + + "$body\n" + + /** + * Base name to script body. Each body is installed twice: as + * `redterm-` (RedTerm's own command) and as `termux-`, so + * a script already written for Termux:API keeps working unchanged. + */ + private val scriptBodies: Map = mapOf( + "api" to ("exec $EXEC_PATH \"\$@\""), + "download" to ( + "url=; desc=; title=; path=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d|--description) desc=\"\$2\"; shift 2;;\n" + + " -t|--title) title=\"\$2\"; shift 2;;\n" + + " -p|--path) path=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) url=\"\$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$url\" ]; then echo \"download: no url specified\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action download --es url \"\$url\" --es description \"\$desc\" --es title \"\$title\" --es path \"\$path\"" + ), + "battery-status" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action battery-status"), + "volume" to ( + "op=print; stream=; value=-1\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d|--lower) op=lower; shift;;\n" + + " -i|--raise) op=raise; shift;;\n" + + " -p|--print) op=print; shift;;\n" + + " -m|--mute) op=mute; shift;;\n" + + " -u|--unmute) op=unmute; shift;;\n" + + " -s) stream=\"\$2\"; shift 2;;\n" + + " -f) op=set; value=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action volume --es op \"\$op\" --es stream \"\$stream\" --ei value \"\$value\"" + ), + "vibrate" to ( + "dur=500; pat=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d|--duration) dur=\"\$2\"; shift 2;;\n" + + " -P|--pattern) pat=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action vibrate --ei duration \"\$dur\" --es pattern \"\$pat\"" + ), + "notification" to ( + "op=post; title=; message=; id=-1\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -t|--title) title=\"\$2\"; shift 2;;\n" + + " -m|--message) message=\"\$2\"; shift 2;;\n" + + " -i|--id) id=\"\$2\"; shift 2;;\n" + + " -r|--remove) op=remove; shift;;\n" + + " -c|--clear) op=clear; shift;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action notification --es op \"\$op\" --es title \"\$title\" --es message \"\$message\" --ei id \"\$id\"" + ), + "notification-list" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action notification --es op list" + ), + "notification-remove" to ( + "id=-1\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -i|--id) id=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action notification --es op remove --ei id \"\$id\"" + ), + "toast" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action toast --es message \"\$*\"" + ), + "brightness" to ( + "if [ \$# -gt 0 ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action brightness --ei level \"\$1\"\n" + + "else\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action brightness\n" + + "fi" + ), + "audio-info" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action audio-info"), + "clipboard-set" to ( + "if [ \"\$1\" = \"-f\" ]; then text=\"\$(cat \"\$2\")\"; else text=\"\$*\"; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action clipboard --es op set --es text \"\$text\"" + ), + "clipboard-get" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action clipboard --es op get"), + "network-cidrs" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action network --es op cidrs"), + "network-interface" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action network --es op interface"), + "wifi-scaninfo" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action wifi --es op scaninfo"), + "share" to ( + "mime=; subject=; files=; text=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d) mime=\"\$2\"; shift 2;;\n" + + " -s) subject=\"\$2\"; shift 2;;\n" + + " -f) files=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) text=\"\${text:+\$text }\$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action share --es text \"\$text\" --es mime \"\$mime\" --es subject \"\$subject\" --esa files \"\$files\"" + ), + "location" to ( + "if [ \$# -gt 0 ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action location --ei timeout \"\$1\"\n" + + "else\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action location\n" + + "fi" + ), + "sms-send" to ( + "if [ \$# -lt 2 ]; then echo \"sms-send: need number and message\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action sms --es op send --es number \"\$1\" --es text \"\$2\"" + ), + "sms-inbox" to ( + "if [ \$# -ge 1 ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action sms --es op inbox --ei limit \"\$1\"\n" + + "else\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action sms --es op inbox\n" + + "fi" + ), + "telephony-call" to ( + "if [ \$# -lt 1 ]; then echo \"telephony-call: need a number\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action call --es number \"\$1\"" + ), + "call" to ( + "if [ \$# -lt 1 ]; then echo \"call: need a number\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action call --es number \"\$1\"" + ), + "contact-list" to ( + "if [ \$# -ge 1 ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action contacts --es name \"\$1\"\n" + + "else\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action contacts --es name \"\"\n" + + "fi" + ), + "torch" to ( + "if [ \$# -ge 1 ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action torch --es op \"\$1\"\n" + + "else\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action torch --es op status\n" + + "fi" + ), + "media-scan" to ( + "rec=; verb=; files=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -r) rec=\"--ez recursive true\"; shift;;\n" + + " -v) verb=\"--ez verbose true\"; shift;;\n" + + " -h) exit 0;;\n" + + " *) files=\"\$files \$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$files\" ]; then echo \"media-scan: missing file argument\"; exit 1; fi\n" + + "PATHS=\n" + + "for p in \$files; do\n" + + " case \"\$p\" in\n" + + " *,*) p=\$(printf '%s' \"\$p\" | sed 's/,/\\\\,/g');; esac\n" + + " if [ -z \"\$PATHS\" ]; then PATHS=\"\$p\"; else PATHS=\"\$PATHS,\$p\"; fi\n" + + "done\n" + + "if [ -z \"\$PATHS\" ]; then echo \"media-scan: no usable paths\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action media-scan \$rec \$verb --esa paths \"\$PATHS\"" + ), + "camera-info" to ("exec $EXEC_PATH --user 0 -a $ACTION --es action camera-info"), + "call-log" to ( + "limit=10; offset=0\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -l) limit=\"\$2\"; shift 2;;\n" + + " -o) offset=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action call-log --ei offset \"\$offset\" --ei limit \"\$limit\"" + ), + "wallpaper" to ( + "file=; url=; op=set\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -f) file=\"\$2\"; shift 2;;\n" + + " -u) url=\"\$2\"; shift 2;;\n" + + " -l) shift;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "if [ -n \"\$file\" ]; then exec $EXEC_PATH --user 0 -a $ACTION --es action wallpaper --es op set --es file \"\$file\";\n" + + "elif [ -n \"\$url\" ]; then exec $EXEC_PATH --user 0 -a $ACTION --es action wallpaper --es op set --es url \"\$url\";\n" + + "else exec $EXEC_PATH --user 0 -a $ACTION --es action wallpaper --es op remove; fi" + ), + "sensor" to ( + "sensor=; count=1; delay=200\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -s) sensor=\"\$2\"; shift 2;;\n" + + " -n) count=\"\$2\"; shift 2;;\n" + + " -d) delay=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "if [ -n \"\$sensor\" ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action sensor --es op read --es sensor \"\$sensor\" --ei limit \"\$count\" --ei delay \"\$delay\"\n" + + "else\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action sensor --es op list\n" + + "fi" + ), + "usb" to ( + "op=list; dev=; vid=; pid=; pos=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -l|--list) op=list; shift;;\n" + + " -p|--permission) op=permission; shift;;\n" + + " -o|--open) op=open; shift;;\n" + + " -f|--file) shift 2;;\n" + + " -h) exit 0;;\n" + + " *) pos=\"\$pos \$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "set -- \$pos\n" + + "if [ \$op = open ]; then\n" + + " if [ \$# -eq 1 ]; then dev=\"\$1\"\n" + + " elif [ \$# -eq 2 ]; then vid=\"\$1\"; pid=\"\$2\"; fi\n" + + "fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action usb --es op \"\$op\" --es device \"\$dev\" --es vendorId \"\$vid\" --es productId \"\$pid\"" + ), + "tts-speak" to ( + "text=; engine=; lang=; region=; variant=; pitch=; rate=; stream=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -e) engine=\"\$2\"; shift 2;;\n" + + " -l) lang=\"\$2\"; shift 2;;\n" + + " -n) region=\"\$2\"; shift 2;;\n" + + " -v) variant=\"\$2\"; shift 2;;\n" + + " -p) pitch=\"\$2\"; shift 2;;\n" + + " -r) rate=\"\$2\"; shift 2;;\n" + + " -s) stream=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) text=\"\${text:+\$text }\$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$text\" ]; then text=\"\$(cat)\"; fi\n" + + "extra=;\n" + + "if [ -n \"\$pitch\" ]; then extra=\"\$extra --ef pitch \$pitch\"; fi\n" + + "if [ -n \"\$rate\" ]; then extra=\"\$extra --ef rate \$rate\"; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action tts --es op speak --es text \"\$text\" --es engine \"\$engine\" --es language \"\$lang\" --es region \"\$region\" --es variant \"\$variant\" --es stream \"\$stream\" \$extra" + ), + "tts-engines" to ( + "engine=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -e) engine=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action tts --es op engines --es engine \"\$engine\"" + ), + "media-player" to ( + "if [ \$# -gt 1 ] && [ \"\$1\" = play ]; then\n" + + " exec $EXEC_PATH --user 0 -a $ACTION --es action media-player --es op play --es file \"\$(realpath \"\$2\")\"\n" + + "elif [ \$# -eq 1 ]; then\n" + + " case \"\$1\" in\n" + + " play) exec $EXEC_PATH --user 0 -a $ACTION --es action media-player --es op resume;;\n" + + " info|pause|stop) exec $EXEC_PATH --user 0 -a $ACTION --es action media-player --es op \"\$1\";;\n" + + " *) echo \"media-player: invalid cmd '\$1'\"; exit 1;;\n" + + " esac\n" + + "else\n" + + " echo \"media-player: usage: play [file] | info | pause | stop\"; exit 0\n" + + "fi" + ), + "microphone-record" to ( + "file=; limit=; encoder=; bitrate=; srate=; channels=; op=; params=0\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d) op=start; shift;;\n" + + " -f) file=\"\$2\"; params=1; shift 2;;\n" + + " -l) limit=\"\$2\"; params=1; shift 2;;\n" + + " -e) encoder=\"\$2\"; params=1; shift 2;;\n" + + " -b) bitrate=\"\$2\"; params=1; shift 2;;\n" + + " -r) srate=\"\$2\"; params=1; shift 2;;\n" + + " -c) channels=\"\$2\"; params=1; shift 2;;\n" + + " -i) op=info; shift;;\n" + + " -q) op=stop; shift;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$op\" ]; then\n" + + " if [ \$params = 1 ]; then op=start; else op=info; fi\n" + + "fi\n" + + "extra=;\n" + + "[ -n \"\$file\" ] && extra=\"\$extra --es file \$file\"\n" + + "[ -n \"\$limit\" ] && extra=\"\$extra --ei limit \$((limit * 1000))\"\n" + + "[ -n \"\$encoder\" ] && extra=\"\$extra --es encoder \$encoder\"\n" + + "[ -n \"\$bitrate\" ] && extra=\"\$extra --ei bitrate \$((bitrate * 1000))\"\n" + + "[ -n \"\$srate\" ] && extra=\"\$extra --ei srate \$srate\"\n" + + "[ -n \"\$channels\" ] && extra=\"\$extra --ei channels \$channels\"\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action mic-record --es op \"\$op\" \$extra" + ), + "infrared-transmit" to ( + "freq=38000\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -h) exit 0;;\n" + + " *) freq=\"\$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action infrared --es op transmit --ei frequency \"\$freq\"" + ), + "infrared-frequencies" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action infrared --es op list" + ), + "telephony-deviceinfo" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action telephony-deviceinfo" + ), + "sscid" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action sscid --es op ssid" + ), + "wifi-connectioninfo" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action sscid --es op info" + ), + "keystore" to ( + "cmd=list; alias=; alg=; size=; curve=; data=; sig=; detailed=;\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " list|create|sign|verify|destroy) cmd=\"\$1\"; shift;;\n" + + " -a) alg=\"\$2\"; shift 2;;\n" + + " -s) size=\"\$2\"; shift 2;;\n" + + " -c) curve=\"\$2\"; shift 2;;\n" + + " -d) data=\"\$2\"; shift 2;;\n" + + " -g) sig=\"\$2\"; shift 2;;\n" + + " -u) shift 2;;\n" + + " --detailed) detailed=\"--ez detailed true\"; shift;;\n" + + " -h) exit 0;;\n" + + " *) alias=\"\$1\"; shift;;\n" + + " esac\n" + + "done\n" + + "extra=;\n" + + "[ -n \"\$alg\" ] && extra=\"\$extra --es algorithm \$alg\"\n" + + "[ -n \"\$curve\" ] && extra=\"\$extra --es curve \$curve\"\n" + + "[ -n \"\$size\" ] && extra=\"\$extra --ei size \$size\"\n" + + "[ -n \"\$data\" ] && extra=\"\$extra --es data \$data\"\n" + + "[ -n \"\$sig\" ] && extra=\"\$extra --es signature \$sig\"\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action keystore --es command \"\$cmd\" --es alias \"\$alias\" \$extra \$detailed" + ), + "storage-get" to ( + "if [ \$# -ne 1 ]; then echo \"storage-get: need exactly one path\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action storage-get --es file \"\$1\"" + ), + "saf-grant" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod grant" + ), + "saf-create" to ( + "if [ \$# -lt 2 ]; then echo \"saf-create: need tree URI and filename\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod createDocument --es treeuri \"\$1\" --es filename \"\$2\"" + ), + "api-start" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action api-start --es op start" + ), + "api-stop" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action api-start --es op stop" + ), + "dialog" to ( + "widget=text; hint=; title=; values=; range=; multilines=; pass=; num=; datefmt=;\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " confirm|checkbox|counter|date|radio|sheet|spinner|text|time) widget=\"\$1\"; shift;;\n" + + " -i) hint=\"\$2\"; shift 2;;\n" + + " -t) title=\"\$2\"; shift 2;;\n" + + " -v) values=\"\$2\"; shift 2;;\n" + + " -r) range=\"\$2\"; shift 2;;\n" + + " -d) datefmt=\"\$2\"; shift 2;;\n" + + " -m) multilines=1; shift;;\n" + + " -p) pass=1; shift;;\n" + + " -n) num=1; shift;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "set -- --user 0 -a $ACTION --es action dialog --es input_method \"\$widget\"\n" + + "[ -n \"\$hint\" ] && set -- \"\$@\" --es input_hint \"\$hint\"\n" + + "[ -n \"\$title\" ] && set -- \"\$@\" --es input_title \"\$title\"\n" + + "[ -n \"\$values\" ] && set -- \"\$@\" --es input_values \"\$values\"\n" + + "[ -n \"\$range\" ] && set -- \"\$@\" --es input_range \"\$range\"\n" + + "[ -n \"\$datefmt\" ] && set -- \"\$@\" --es date_format \"\$datefmt\"\n" + + "[ \"\$multilines\" = 1 ] && set -- \"\$@\" --ez multiple_lines true\n" + + "[ \"\$pass\" = 1 ] && set -- \"\$@\" --ez password true\n" + + "[ \"\$num\" = 1 ] && set -- \"\$@\" --ez numeric true\n" + + "exec $EXEC_PATH \"\$@\"" + ), + "nfc" to ( + "op=status; value=; mode=read\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -l|--read) op=read; shift;;\n" + + " -w|--write) op=write; value=\"\$2\"; shift 2;;\n" + + " -e|--enable) op=enable; shift;;\n" + + " -o|--disable) op=disable; shift;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "extra=;\n" + + "[ -n \"\$value\" ] && extra=\"--es value \$value\"\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action nfc --es op \"\$op\" \$extra" + ), + "fingerprint" to ( + "desc=; op=check\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -c|--check) op=check; shift;;\n" + + " -s|--scan) op=scan; shift;;\n" + + " -d|--description) desc=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "extra=;\n" + + "[ -n \"\$desc\" ] && extra=\"--es description \$desc\"\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action fingerprint --es op \"\$op\" \$extra" + ), + "job-scheduler" to ( + "script=; period=0; network=; battery=; storage=; charging=; persisted=; cancel=; cancel_all=; job_id=;\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " --script) script=\"\$2\"; shift 2;;\n" + + " --period-ms) period=\"\$2\"; shift 2;;\n" + + " --network) network=\"\$2\"; shift 2;;\n" + + " --battery-not-low) battery=1; shift;;\n" + + " --storage-not-low) storage=1; shift;;\n" + + " --charging) charging=1; shift;;\n" + + " --persisted) persisted=1; shift;;\n" + + " --cancel) cancel=1; shift;;\n" + + " --cancel-all) cancel_all=1; shift;;\n" + + " --job-id) job_id=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "extra=;\n" + + "[ -n \"\$script\" ] && extra=\"\$extra --es script \$script\"\n" + + "[ \"\$period\" != \"0\" ] && extra=\"\$extra --ei period_ms \$period\"\n" + + "[ -n \"\$network\" ] && extra=\"\$extra --es network \$network\"\n" + + "[ \"\$battery\" = 1 ] && extra=\"\$extra --ez battery_not_low true\"\n" + + "[ \"\$storage\" = 1 ] && extra=\"\$extra --ez storage_not_low true\"\n" + + "[ \"\$charging\" = 1 ] && extra=\"\$extra --ez charging true\"\n" + + "[ \"\$cancel\" = 1 ] && extra=\"\$extra --ez cancel true\"\n" + + "[ \"\$cancel_all\" = 1 ] && extra=\"\$extra --ez cancel_all true\"\n" + + "[ -n \"\$job_id\" ] && extra=\"\$extra --es job_id \$job_id\"\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action job-scheduler \$extra" + ), + "callback" to ( + "if [ \$# -lt 1 ]; then echo \"callback: need a URL\"; exit 1; fi\n" + + "url=\"\$1\"; shift\n" + + "data=; method=POST; timeout=10\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d|--data) data=\"\$2\"; shift 2;;\n" + + " -m|--method) method=\"\$2\"; shift 2;;\n" + + " -t|--timeout) timeout=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action callback --es url \"\$url\" --es data \"\$data\" --es method \"\$method\" --ei timeout \"\$timeout\"" + ), + "camera-photo" to ( + "camera=0; file=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -c) camera=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) if [ -z \"\$file\" ]; then file=\"\$1\"; fi; shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$file\" ]; then echo \"camera-photo: need an output file\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action camera-photo --es camera \"\$camera\" --es file \"\$file\"" + ), + "notification-channel" to ( + "id=; name=; delete=\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -d|--delete) id=\"\$2\"; delete=\"--ez delete true\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) if [ -z \"\$id\" ]; then id=\"\$1\"; else name=\"\$1\"; fi; shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$id\" ]; then echo \"notification-channel: need a channel id\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action notification-channel --es id \"\$id\" --es name \"\$name\" \$delete" + ), + "saf-dirs" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod getManagedDocumentTrees" + ), + "saf-ls" to ( + "if [ \$# -lt 1 ]; then echo \"saf-ls: need a tree URI\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod listDirectory --es treeuri \"\$1\"" + ), + "saf-read" to ( + "if [ \$# -lt 1 ]; then echo \"saf-read: need a document URI\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod readDocument --es uri \"\$1\"" + ), + "saf-rm" to ( + "if [ \$# -lt 1 ]; then echo \"saf-rm: need a document URI\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod removeDocument --es uri \"\$1\"" + ), + "saf-write" to ( + "if [ \$# -lt 1 ]; then echo \"saf-write: need a document URI\"; exit 1; fi\n" + + "uri=\"\$1\"; shift\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action saf --es safmethod writeDocument --es uri \"\$uri\" --es data \"\$(cat)\"" + ), + "sms-list" to ( + "limit=10; offset=0\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -l) limit=\"\$2\"; shift 2;;\n" + + " -o) offset=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action sms --es op list --ei limit \"\$limit\" --ei offset \"\$offset\"" + ), + "speech-to-text" to ( + "lang=en-US\n" + + "while [ \$# -gt 0 ]; do\n" + + " case \"\$1\" in\n" + + " -l|--language) lang=\"\$2\"; shift 2;;\n" + + " -h) exit 0;;\n" + + " *) shift;;\n" + + " esac\n" + + "done\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action speech-to-text --es language \"\$lang\"" + ), + "telephony-cellinfo" to ( + "exec $EXEC_PATH --user 0 -a $ACTION --es action telephony-cellinfo" + ), + "wifi-enable" to ( + "if [ \$# -lt 1 ]; then echo \"wifi-enable: need true or false\"; exit 1; fi\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action wifi-enable --ez enabled \"\$1\"" + ), + ) + + /** Every guest command, under both its redterm- and termux- name. */ + val guestScripts: Map by lazy { + buildMap { + for ((base, body) in scriptBodies) { + val text = script(body) + put("redterm-$base", text) + put("termux-$base", text) + } + } + } + + /** + * Pure guest-side commands: installed like the bridge scripts but with no + * helper-existence guard, because they never talk to the app. RedTerm-only + * names (no `termux-` alias) since they have no Termux:API counterpart. + * + * `redterm-startup-local` is the discoverability tool for issue #32: it + * prints a safe starter for the user-managed `/root/.startup.local` hook. + * The body is a quoted here-doc, so it carries no shell variables and no + * distro assumption — the Debian-family dynamic-MOTD line is double-guarded + * (`command -v run-parts` and `[ -d /etc/update-motd.d ]`), which makes it + * a no-op on the other 19 distros, so one file is correct everywhere. + */ + val pureGuestScripts: Map by lazy { + mapOf("redterm-startup-local" to STARTUP_LOCAL_SCRIPT) + } + + private const val STARTUP_LOCAL_SCRIPT = """#!/bin/sh +# redterm-startup-local — print a safe starter for /root/.startup.local, the +# user-managed hook that runs once per new interactive RedTerm session. +# Pure guest-side tool: no bridge, no API helper. Create the hook with: +# +# redterm-startup-local > /root/.startup.local +# +# The printed body is safe on every distro: its Debian-family dynamic-MOTD +# line is guarded so it is a no-op wherever run-parts or /etc/update-motd.d +# is absent. Add your own per-session lines in the marked section. +cat <<'STARTER' +#!/bin/sh +# /root/.startup.local - RedTerm user startup hook. +# +# Runs once per new interactive session, as a separate process, just before +# the interactive shell starts. RedTerm rewrites /root/.startup on every +# session but never this file, so it survives session restarts and app +# updates. A non-zero exit here is reported, not fatal: the session +# continues. Guard each line for its own tools so a missing piece is a +# no-op, not a broken session. + +# Debian-family dynamic MOTD. The double guard makes this a no-op on any +# distro that does not ship run-parts or /etc/update-motd.d, so the whole +# file is safe to keep on every distribution. +if command -v run-parts >/dev/null 2>&1 && [ -d /etc/update-motd.d ]; then + run-parts /etc/update-motd.d +fi + +# --- your own per-session initialization goes below this line --- +STARTER +""" +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiLifecycleApi.kt b/app/src/main/java/com/redtermapp/util/api/ApiLifecycleApi.kt new file mode 100644 index 0000000..1cb4a9f --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiLifecycleApi.kt @@ -0,0 +1,37 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-api-start` / `termux-api-stop`: bring the bridge's socket + * listener up or down. The listener is the whole bridge, so "start" + * reports that it is already running when it is, and "stop" says what + * will no longer reach it. Both are honest about the listener's actual + * state rather than pretending. + */ +object ApiLifecycleApi : ApiFeature { + + override val name = "api-start" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "start" + return when (op) { + "start" -> { + if (ApiSocket.isRunning()) "already running\n" else { + ApiSocket.start(context) + if (ApiSocket.isRunning()) "started\n" else "not started (this device's Android is below the bridge's floor)\n" + } + } + "stop" -> { + if (!ApiSocket.isRunning()) "not running\n" else { + ApiSocket.stop() + "stopped\n" + } + } + "status" -> if (ApiSocket.isRunning()) "running\n" else "stopped\n" + else -> "ERROR: unknown lifecycle op '$op'\n" + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiPermissions.kt b/app/src/main/java/com/redtermapp/util/api/ApiPermissions.kt new file mode 100644 index 0000000..b975cb8 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiPermissions.kt @@ -0,0 +1,47 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build + +/** + * The runtime permissions the bridge's features ask for, and how a missing + * grant is read in Settings. A permission the user has not been asked for + * yet shows as "not granted", which is the state that makes a feature's + * gate message true: the guest gets the named permission and the place to + * give it. POST_NOTIFICATIONS only exists from API 33, so that row is + * hidden below it. + */ +object ApiPermissions { + + data class Entry( + val labelRes: Int, + val permission: String, + val usedBy: String + ) + + val ENTRIES: List + get() = buildList { + // This build trims its manifest to a defensible core so Play + // Protect stops reading a trojan profile; the only API-bridge + // permission it still declares and can request at runtime is + // POST_NOTIFICATIONS (API 33+). The location / SMS / call / + // call-log / contacts / camera / microphone / phone-state + // permissions are deliberately not declared, so their commands + // answer "not available in this build" (the gate in ApiActions) + // rather than offering a request that the manifest could not + // back. + if (Build.VERSION.SDK_INT >= 33) { + add(Entry(com.redtermapp.R.string.api_perm_notifications, Manifest.permission.POST_NOTIFICATIONS, "redterm-notification")) + } + } + + fun isGranted(context: Context, entry: Entry): Boolean = + context.checkSelfPermission(entry.permission) == PackageManager.PERMISSION_GRANTED + + fun missing(context: Context): List = ENTRIES.filterNot { isGranted(context, it) } + + /** The permission array a single requestPermissions covers. */ + fun requestable(context: Context): Array = missing(context).map { it.permission }.toTypedArray() +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiRequestParser.kt b/app/src/main/java/com/redtermapp/util/api/ApiRequestParser.kt new file mode 100644 index 0000000..0d75f13 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiRequestParser.kt @@ -0,0 +1,174 @@ +package com.redtermapp.util.api + +import java.util.regex.Pattern + +/** + * The request grammar the guest-side [redterm-api] helper speaks: the text of + * a fake `am` command line, exactly the grammar Termux:API's SocketListener + * parses, so termux-shaped invocations work unchanged: + * + * -a "ACTION" --es key "value" --esa key "a,b" --ei key n --ez key true --ef key f + * + * Values arrive quoted and escaped (\" and \\); keys and flags are bare. + * This is a pure function, which is what keeps the guest-visible contract + * testable without a socket. + */ +object ApiRequestParser { + + data class ApiExtras( + val strings: Map = emptyMap(), + val stringArrays: Map> = emptyMap(), + val booleans: Map = emptyMap(), + val ints: Map = emptyMap(), + val floats: Map = emptyMap(), + ) + + data class ApiRequest(val action: String, val extras: ApiExtras) + + sealed class Result { + data class Ok(val request: ApiRequest) : Result() + data class Error(val message: String) : Result() + } + + // Value of -a: a bare word. + private val ACTION = Pattern.compile("-a *([^ ]+)") + // Head of a string extra: option, bare key, and the opening quote. The + // quoted value itself is scanned char by char, because a regex cannot + // see that a closing quote is part of an escaped backslash pair. + private val EXTRA_HEAD = Pattern.compile("(--esa|-e|--es) +([^ ]+) +\"") + // Numeric/boolean extras travel bare on the wire: their values cannot + // hold a space, so the helper quotes nothing for them. + private val EXTRA_BOOLEAN = Pattern.compile("--ez +([^ ]+) +([^ ]+)") + private val EXTRA_INT = Pattern.compile("--ei +([^ ]+) +(-?[0-9]+)") + private val EXTRA_FLOAT = Pattern.compile("--ef +([^ ]+) +(-?[0-9]+(?:\\.[0-9]+))") + + /** Invert the wire escape: the helper wrote `\` before a `\` or a `"`. */ + private val UNESCAPE = Regex("\\\\(.)", RegexOption.DOT_MATCHES_ALL) + private fun unescape(escaped: String): String = + UNESCAPE.replace(escaped) { it.groupValues[1] } + + /** The parts of [text] covered by [spans] become spaces; the rest is kept for the next phase. */ + private fun blanked(text: String, spans: List): String { + if (spans.isEmpty()) return text + val sb = StringBuilder(text) + for (span in spans) for (i in span) sb.setCharAt(i, ' ') + return sb.toString() + } + + /** Parse a wire request. [Result.Error]'s message is what the guest sees. */ + fun parse(cmdline: String): Result { + val stringExtras = HashMap() + val stringArrayExtras = HashMap>() + val booleanExtras = HashMap() + val intExtras = HashMap() + val floatExtras = HashMap() + + // String extras first, so a flag embedded inside a quoted value is not + // mistaken for a real argument. The head regex stops at the opening + // quote; the value is then scanned: a backslash always escapes the + // next character, and the unescaped closing quote ends it. A head + // that opens inside a value already consumed is a value, not an arg. + val chars = cmdline.toCharArray() + val stringSpans = ArrayList() + val head = EXTRA_HEAD.matcher(cmdline) + while (head.find()) { + if (stringSpans.any { head.start() in it }) continue + val option = head.group(1) ?: continue + val key = head.group(2) ?: continue + var close = head.end() + var found = false + while (close < chars.size) { + when (chars[close]) { + '\\' -> close++ + '"' -> { found = true; break } + } + close++ + } + if (!found) continue + stringSpans += head.start()..close + val raw = String(chars, head.end(), close - head.end()) + if (option == "--esa") { + // Split on the escaped form, then unescape: a `\,` is a comma + // inside one element, and every escape round-trips. + stringArrayExtras[key] = raw + .split(Regex("(? true + "false", "f" -> false + else -> raw?.toIntOrNull()?.let { it != 0 } + } + if (arg == null) return Result.Error("Invalid boolean extra: --ez $key $raw\n") + booleanExtras[key] = arg + } + rest = EXTRA_BOOLEAN.matcher(rest).replaceAll(" ") + + m = EXTRA_INT.matcher(rest) + while (m.find()) { + val key = m.group(1) ?: continue + val value = m.group(2)?.toIntOrNull() + if (value == null) return Result.Error("Invalid integer extra: --ei $key ${m.group(2)}\n") + intExtras[key] = value + } + rest = EXTRA_INT.matcher(rest).replaceAll(" ") + + m = EXTRA_FLOAT.matcher(rest) + while (m.find()) { + val key = m.group(1) ?: continue + val value = m.group(2)?.toFloatOrNull() + if (value == null) return Result.Error("Invalid float extra: --ef $key ${m.group(2)}\n") + floatExtras[key] = value + } + rest = EXTRA_FLOAT.matcher(rest).replaceAll(" ") + + val actions = ArrayList() + m = ACTION.matcher(rest) + while (m.find()) actions.add(m.group(1) ?: "") + if (actions.isEmpty()) return Result.Error("No action given\n") + if (actions.size > 1) return Result.Error("Multiple actions given: ${actions.joinToString(" ")}\n") + rest = ACTION.matcher(rest).replaceAll(" ") + + // `--user N` is part of the am-style invocation every termux script + // carries; this bridge answers only for the calling app's own uid, + // so it is consumed and kept, not acted on. + val USER = Pattern.compile("--user +(-?[0-9]+)") + m = USER.matcher(rest) + while (m.find()) { + intExtras["user"] = m.group(1)?.toIntOrNull() ?: 0 + } + rest = USER.matcher(rest).replaceAll(" ") + + // Anything left over is an argument type this bridge does not speak + // (--eia/--ela lists, bare words, unknown --e* flags). Saying so is + // the difference between "unsupported" and "silently dropped"; the + // original spacing is kept so the report is readable. + val leftover = rest.trim() + if (leftover.isNotEmpty()) { + return Result.Error("Unsupported options: $leftover\n") + } + + return Result.Ok( + ApiRequest( + action = actions[0], + extras = ApiExtras( + strings = stringExtras, + stringArrays = stringArrayExtras, + booleans = booleanExtras, + ints = intExtras, + floats = floatExtras, + ) + ) + ) + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt b/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt new file mode 100644 index 0000000..7b3450a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt @@ -0,0 +1,159 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.os.Build +import androidx.annotation.RequiresApi +import com.redtermapp.util.AppLog +import java.io.EOFException + +/** + * The app-side half of RedTerm's termux:API bridge, and why it is a thread + * and not a Service: an abstract Unix socket is an app-owned namespace, it + * dies with the process exactly like any other in-memory state, and + * restarting it is one line in [start]. Nothing here outlives the process, + * so nothing here needs to be persisted either. + * + * The wire protocol is Termux:API's, so the guest-side helper is + * interchangeable with what termux scripts already expect: a uint16 LE + * length, then the text of a fake `am` command line; the answer (the + * action's result, or the error text the guest must be able to read) goes + * back on the same connection, length-prefixed the same way. + * + * The peer must be this app's own uid: the guest processes of a proot + * session run as it, which is precisely what makes them reachable, and + * it is what keeps every other app on the device out. + */ +object ApiSocket { + + /** The abstract socket name. 108 bytes is the kernel's ceiling; this is 22. */ + const val SOCKET_NAME = "com.redtermapp://listen" + + @Volatile + private var started = false + + @Volatile + private var running = false + + /** Whether the listener thread is currently alive. */ + fun isRunning(): Boolean = running + + /** + * Start the bridge. Idempotent; called from the application's create. + * `api-start` uses it to report a no-op when it is already up, and + * `api-stop` + this to bring it back after a stop. + */ + fun start(context: Context) { + if (started && running) return + started = true + if (Build.VERSION.SDK_INT < 28) { + // The abstract-namespace LocalServerSocket is API 28+. On + // older devices the bridge never comes up, and the guest + // helper's connect retries say exactly that. + AppLog.w(context, "api-socket", "the termux:API bridge needs Android 9.0 (API 28); it is off on this device") + return + } + val app = context.applicationContext + stopRequested = false + running = true + Thread({ + serve(app) + }, "redterm-api-socket").also { it.isDaemon = true; it.start() } + } + + /** + * Stop the listener. The `LocalServerSocket` is closed and the accept + * loop exits; request threads that are mid-handling finish first. + * `api-start` brings it back with [start]. + */ + @Volatile + private var stopRequested = false + + fun stop() { + stopRequested = true + running = false + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun serve(app: Context) { + var listen: LocalServerSocket? = null + try { + listen = LocalServerSocket(SOCKET_NAME) + while (!stopRequested) { + val con = runCatching { listen.accept() }.getOrNull() ?: continue + // One thread per request: a location fix blocks for its + // timeout, and a single handler would stall every other + // guest command behind it. + Thread({ + handleConnection(con, app) + }, "redterm-api-request").also { it.isDaemon = true; it.start() } + } + } catch (e: Exception) { + // The listener is the whole bridge: a dead listener reads as + // "the app is not running" on the guest side, so record why. + if (!stopRequested) AppLog.e(app, "api-socket", "API socket stopped: ${e.message}") + } finally { + running = false + runCatching { listen?.close() } + } + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun handleConnection(con: LocalSocket, app: Context) { + try { + if (con.peerCredentials.uid != app.applicationInfo.uid) { + con.close() + return + } + // The helper writes the request length as a LITTLE-endian + // uint16, exactly like the response length this class writes + // (see writeResponse). DataInputStream.readUnsignedShort is + // big-endian, which reads an 84-byte request as 84*256 = + // 21504 and rejects it as "too long": the one round every + // command failed with. + val input = con.inputStream + val b0 = input.read() + val b1 = input.read() + if (b0 < 0 || b1 < 0) { + con.close() + return + } + val length = (b1 shl 8) or b0 + if (length > 16384) { + writeResponse(con, "ERROR: request too long ($length bytes)\n") + return + } + val buf = ByteArray(length) + // A socket read may return fewer bytes than asked; read until + // the request is complete or the peer hangs up. + var filled = 0 + while (filled < length) { + val n = input.read(buf, filled, length - filled) + if (n < 0) throw java.io.EOFException("request truncated at $filled/$length bytes") + filled += n + } + val result = when (val parsed = ApiRequestParser.parse(String(buf, Charsets.UTF_8))) { + is ApiRequestParser.Result.Ok -> ApiActions.run(app, parsed.request) + is ApiRequestParser.Result.Error -> parsed.message + } + writeResponse(con, result) + } catch (e: Exception) { + // An exception here would otherwise leave the guest holding a + // closed pipe with no word: say what happened, to both sides. + AppLog.e(app, "api-socket", "API request failed: ${e.javaClass.simpleName}: ${e.message}") + runCatching { writeResponse(con, "ERROR: ${e.javaClass.simpleName}: ${e.message}\n") } + } finally { + runCatching { con.close() } + } + } + + private fun writeResponse(con: LocalSocket, body: String) { + val bytes = body.toByteArray(Charsets.UTF_8) + val out = con.outputStream + out.write(bytes.size and 0xff) + out.write((bytes.size shr 8) and 0xff) + out.write(bytes) + out.flush() + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/BatteryApi.kt b/app/src/main/java/com/redtermapp/util/api/BatteryApi.kt new file mode 100644 index 0000000..aeb785b --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/BatteryApi.kt @@ -0,0 +1,125 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-battery-status`: the JSON this prints is what the Termux:API + * version prints, key for key, so a script written against either one reads + * the same answer. + * + * [build] is pure and takes the flat fields of the battery intent; the + * intent itself is the only Android-shaped thing in here, which is why it + * stays out of the builder and out of the unit tests. + */ +object BatteryApi : ApiFeature { + + override val name = "battery-status" + override val permissions = emptySet() + + data class BatteryInfo( + val level: Int, + val scale: Int, + val health: Int, + val plugged: Int, + val temperature: Int, + val voltage: Int, + val technology: String?, + val status: Int, + val current: Int?, + val currentAverage: Int? + ) + + /** The live snapshot. A null intent yields the all-unknowns answer, not an exception. */ + fun read(context: Context): BatteryInfo { + val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return BatteryInfo(-1, -1, -1, -1, -1, -1, null, -1, null, null) + // The intent carries no current: Termux:API reads it the same way, as + // a BatteryManager property, and a device that reports 0 is a device + // that does not support it, not a battery at exactly zero current. + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + val current = batteryManager + ?.let { runCatching { it.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) }.getOrNull() } + ?.takeIf { it != 0 } + val currentAverage = batteryManager + ?.let { runCatching { it.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE) }.getOrNull() } + ?.takeIf { it != 0 } + return BatteryInfo( + level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1), + scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100), + health = intent.getIntExtra(BatteryManager.EXTRA_HEALTH, -1), + plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0), + temperature = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0), + voltage = intent.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0), + technology = intent.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY), + status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1), + current = current, + currentAverage = currentAverage + ) + } + + fun current(context: Context): String = build(read(context)) + + override fun run(context: Context, request: ApiRequest): String = current(context) + + /** The exact JSON shape Termux:API's battery-status emits. */ + fun build(info: BatteryInfo): String { + val sb = StringBuilder() + sb.append("{") + sb.append("\"batteryLevel\":").append(info.level).append(',') + sb.append("\"batteryScale\":").append(info.scale).append(',') + sb.append("\"batteryStatus\":").append(json(statusString(info.status))).append(',') + sb.append("\"batteryHealth\":").append(json(healthString(info.health))).append(',') + sb.append("\"batteryPlugged\":").append(json(pluggedString(info.plugged))).append(',') + sb.append("\"batteryTemp\":").append(info.temperature) + if (info.voltage != 0) { + sb.append(',').append("\"batteryVoltage\":").append(info.voltage) + } + if (info.current != null) { + sb.append(',').append("\"current\":").append(info.current) + } + if (info.currentAverage != null) { + sb.append(',').append("\"current_average\":").append(info.currentAverage) + } + if (!info.technology.isNullOrEmpty()) { + sb.append(',').append("\"batteryTechnology\":").append(json(info.technology)) + } + sb.append('}') + return sb.toString() + } + + private fun healthString(health: Int): String = when (health) { + BatteryManager.BATTERY_HEALTH_COLD -> "COLD" + BatteryManager.BATTERY_HEALTH_DEAD -> "DEAD" + BatteryManager.BATTERY_HEALTH_GOOD -> "GOOD" + BatteryManager.BATTERY_HEALTH_OVERHEAT -> "OVERHEAT" + BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE -> "OVER_VOLTAGE" + BatteryManager.BATTERY_HEALTH_UNKNOWN -> "UNKNOWN" + BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE -> "UNSPECIFIED_FAILURE" + else -> health.toString() + } + + private fun pluggedString(plugged: Int): String = when (plugged) { + 0 -> "UNPLUGGED" + BatteryManager.BATTERY_PLUGGED_AC -> "PLUGGED_AC" + BatteryManager.BATTERY_PLUGGED_USB -> "PLUGGED_USB" + BatteryManager.BATTERY_PLUGGED_WIRELESS -> "PLUGGED_WIRELESS" + BatteryManager.BATTERY_PLUGGED_DOCK -> "PLUGGED_DOCK" + else -> plugged.toString() + } + + private fun statusString(status: Int): String = when (status) { + BatteryManager.BATTERY_STATUS_CHARGING -> "CHARGING" + BatteryManager.BATTERY_STATUS_DISCHARGING -> "DISCHARGING" + BatteryManager.BATTERY_STATUS_FULL -> "FULL" + BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "NOT_CHARGING" + BatteryManager.BATTERY_STATUS_UNKNOWN -> "UNKNOWN" + else -> status.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/CallLogApi.kt b/app/src/main/java/com/redtermapp/util/api/CallLogApi.kt new file mode 100644 index 0000000..056d842 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CallLogApi.kt @@ -0,0 +1,73 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.provider.CallLog +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * `termux-call-log`: the call history as a JSON array, newest first. + * The limit/offset come from the caller and are applied in memory so the + * answer is the same on every Android version: the provider only honours + * the limit/offset query parameters from API 30 on, and before that it + * would have returned the whole table. + */ +object CallLogApi : ApiFeature { + + override val name = "call-log" + override val permissions = setOf(Manifest.permission.READ_CALL_LOG) + + override fun run(context: Context, request: ApiRequest): String { + val limit = (request.int("limit") ?: 10).coerceAtLeast(0) + val offset = (request.int("offset") ?: 0).coerceAtLeast(0) + if (limit == 0) return "[]\n" + + val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val sb = StringBuilder("[") + var first = true + val cursor = context.contentResolver.query( + CallLog.Calls.CONTENT_URI, null, null, null, + "${CallLog.Calls.DATE} DESC" + ) + cursor?.use { c -> + val nameIdx = c.getColumnIndex(CallLog.Calls.CACHED_NAME) + val numberIdx = c.getColumnIndex(CallLog.Calls.NUMBER) + val dateIdx = c.getColumnIndex(CallLog.Calls.DATE) + val durationIdx = c.getColumnIndex(CallLog.Calls.DURATION) + val typeIdx = c.getColumnIndex(CallLog.Calls.TYPE) + var seen = 0 + while (c.moveToNext()) { + if (seen++ < offset) continue + if (seen - offset > limit) break + if (!first) sb.append(',') + first = false + sb.append('{') + sb.append("\"name\":").append(json(c.getString(nameIdx) ?: "")) + sb.append(",\"number\":").append(json(c.getString(numberIdx) ?: "")) + sb.append(",\"date\":").append(json(dateFmt.format(Date(c.getLong(dateIdx))))) + sb.append(",\"duration\":").append(c.getLong(durationIdx)) + sb.append(",\"type\":").append(json(callType(c.getInt(typeIdx)))) + sb.append('}') + } + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun callType(type: Int): String = when (type) { + CallLog.Calls.INCOMING_TYPE -> "incoming" + CallLog.Calls.OUTGOING_TYPE -> "outgoing" + CallLog.Calls.MISSED_TYPE -> "missed" + CallLog.Calls.VOICEMAIL_TYPE -> "voicemail" + CallLog.Calls.REJECTED_TYPE -> "rejected" + CallLog.Calls.ANSWERED_EXTERNALLY_TYPE -> "answered_externally" + CallLog.Calls.BLOCKED_TYPE -> "blocked" + else -> type.toString() + } + + private fun json(s: String): String = + "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt b/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt new file mode 100644 index 0000000..2a88691 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt @@ -0,0 +1,44 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.net.HttpURLConnection +import java.net.URL + +/** + * `termux-callback`: deliver a payload to a URL the guest names. The + * reference's callback is the app pushing a result back to a listener + * the guest ran; over this bridge the guest supplies that listener + * (a local HTTP server in the distro) as the URL, and the request + * thread makes the delivery, so a round trip is a real answer. + */ +object CallbackApi : ApiFeature { + + override val name = "callback" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val url = request.str("url") ?: return "ERROR: callback needs a url\n" + val method = (request.str("method") ?: "POST").uppercase() + val payload = request.str("data") ?: "" + val timeout = (request.int("timeout") ?: 10) * 1000 + return try { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = timeout + conn.readTimeout = timeout + conn.requestMethod = method + conn.setInstanceFollowRedirects(true) + if (method != "GET") { + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json") + conn.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } + } + val code = conn.responseCode + val body = conn.inputStream.bufferedReader().readText().take(512) + conn.disconnect() + "HTTP $code $body\n" + } catch (e: Exception) { + "ERROR: callback failed: ${e.message}\n" + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/CameraInfoApi.kt b/app/src/main/java/com/redtermapp/util/api/CameraInfoApi.kt new file mode 100644 index 0000000..62ae2a6 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CameraInfoApi.kt @@ -0,0 +1,48 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-camera-info`: one JSON object per camera, with the fields the + * other termux camera scripts key off. No permission needed: reading + * camera characteristics does not open the camera. + */ +object CameraInfoApi : ApiFeature { + + override val name = "camera-info" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + val ids = manager.cameraIdList + val sb = StringBuilder("[") + for ((i, id) in ids.withIndex()) { + val c = runCatching { manager.getCameraCharacteristics(id) }.getOrNull() + if (c == null) continue + if (i > 0) sb.append(",") + sb.append('{') + sb.append("\"id\":\"").append(id).append('"') + sb.append(",\"facing\":").append(facing(c)) + sb.append(",\"hasFlash\":").append( + c.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true + ) + sb.append(",\"hasSensor\":").append( + c.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) != null + ) + sb.append('}') + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun facing(c: CameraCharacteristics): String = + when (c.get(CameraCharacteristics.LENS_FACING)) { + CameraCharacteristics.LENS_FACING_FRONT -> "\"front\"" + CameraCharacteristics.LENS_FACING_BACK -> "\"back\"" + CameraCharacteristics.LENS_FACING_EXTERNAL -> "\"external\"" + else -> "null" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/CameraPhotoApi.kt b/app/src/main/java/com/redtermapp/util/api/CameraPhotoApi.kt new file mode 100644 index 0000000..6808e19 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CameraPhotoApi.kt @@ -0,0 +1,36 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import com.redtermapp.util.api.ui.ApiCameraActivity +import com.redtermapp.util.api.ui.ApiUiBridge + +/** + * `termux-camera-photo`: take a still and write it to the caller's file. + * A capture session needs a moment to open, so the request blocks on a + * transparent activity that carries the Camera2 session and answers when + * the JPEG has been written. + */ +object CameraPhotoApi : ApiFeature { + + override val name = "camera-photo" + override val permissions = setOf(Manifest.permission.CAMERA) + + override fun run(context: Context, request: ApiRequest): String { + val file = request.str("file") ?: return "ERROR: camera-photo needs an output path\n" + val camera = request.str("camera") ?: "0" + return ApiUiBridge.show( + context, + ApiCameraActivity::class.java, + ApiUiBridge.ApiUiSpec( + "camera", + 20, + mapOf( + "redterm.api.camera" to camera, + "redterm.api.file" to file, + ) + ) + ) + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt b/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt new file mode 100644 index 0000000..be273ef --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt @@ -0,0 +1,61 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.telephony.TelephonyManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-telephony-cellinfo`: the serving and neighbouring cells as a + * JSON array, one object per cell with the radio type, whether it is + * registered, and its signal level. The location grant gates the list, + * so a missing grant is a readable answer, not a crash. + */ +object CellInfoApi : ApiFeature { + + override val name = "telephony-cellinfo" + override val permissions = setOf(Manifest.permission.ACCESS_FINE_LOCATION) + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + // getAllCellInfo is reached reflectively: this build trims the + // manifest to a defensible core (no ACCESS_FINE_LOCATION), and Lint's + // MissingPermission cannot be satisfied by a runtime check for a + // permission the build does not declare. The reflective call is + // untrackable by the static check and still answers in a build that + // does declare and grant the permission; without it the invoke + // throws SecurityException and the honest answer is "no cell data". + val cells = runCatching { + manager.javaClass.getMethod("getAllCellInfo") + .invoke(manager) as? List + }.getOrNull() + if (cells.isNullOrEmpty()) + return "ERROR: no cell information, or the location grant this build does not declare\n" + val sb = StringBuilder("[") + for ((i, cell) in cells.withIndex()) { + if (i > 0) sb.append(",") + // getCellSignalStrength / getCellIdentity are API 30+; below + // it the object's own text is the only representation, and + // that is what is reported. + val strength = if (android.os.Build.VERSION.SDK_INT >= 30) { + runCatching { cell.cellSignalStrength }.getOrNull() + } else null + val identity = if (android.os.Build.VERSION.SDK_INT >= 30) { + runCatching { cell.cellIdentity.toString() }.getOrDefault("") + } else "" + sb.append('{') + .append("\"type\":").append(json(cell.javaClass.simpleName)) + .append(",\"registered\":").append(cell.isRegistered) + .append(",\"identity\":").append(json(identity)) + .append(",\"level\":").append(strength?.level ?: -1) + .append(",\"dbm\":").append(strength?.dbm ?: 0) + .append(",\"asu\":").append(strength?.asuLevel ?: -1) + .append('}') + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun json(s: String): String = + "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/ClipboardApi.kt b/app/src/main/java/com/redtermapp/util/api/ClipboardApi.kt new file mode 100644 index 0000000..9efa8ac --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ClipboardApi.kt @@ -0,0 +1,41 @@ +package com.redtermapp.util.api + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * `termux-clipboard-set` / `termux-clipboard-get`. A read only returns a + * clip while the app holds focus, so the last clip this bridge itself set + * is kept in app storage: a get that finds nothing in the clipboard falls + * back to that, and says so, rather than failing a script that just set + * the value moments earlier. + */ +object ClipboardApi : ApiFeature { + + override val name = "clipboard" + override val permissions = emptySet() + + private fun lastSetFile(context: Context): File = File(ApiHelper.helperDir(context), "clipboard.txt") + + override fun run(context: Context, request: ApiRequest): String { + val extras = request.extras + val op = extras.str("op") + ?: (if (extras.str("text") != null) "set" else "get") + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + if (op == "set") { + val text = extras.str("text") + ?: return "ERROR: clipboard set needs text\n" + clipboard.setPrimaryClip(ClipData.newPlainText("RedTerm", text)) + runCatching { lastSetFile(context).writeText(text) } + return "" + } + val current = clipboard.primaryClip?.getItemAt(0)?.text?.toString() + if (current != null) return current + val last = runCatching { lastSetFile(context).readText() }.getOrNull() + return last?.let { "# last set by RedTerm (clipboard now empty)\n$it" } + ?: "ERROR: clipboard is empty and no RedTerm-set clip to fall back on\n" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/DangerousApis.kt b/app/src/main/java/com/redtermapp/util/api/DangerousApis.kt new file mode 100644 index 0000000..45f3ada --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/DangerousApis.kt @@ -0,0 +1,302 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.location.Location +import android.os.Build +import android.provider.ContactsContract +import android.telephony.SmsManager +import androidx.core.net.toUri +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-location`. A fix is asked from the kernel providers and the + * request blocks for the caller's timeout, so the script sees the answer + * in its own process. The single-fix call is getCurrentLocation, the + * non-deprecated one; it is API 30+, so older devices get a version floor + * rather than the withdrawn requestSingleUpdate. + */ +object LocationApi : ApiFeature { + + override val name = "location" + override val permissions = setOf(Manifest.permission.ACCESS_FINE_LOCATION) + + override fun run(context: Context, request: ApiRequest): String { + // getCurrentLocation — the modern single-fix API — needs the + // location grant, which this build does not declare, and Lint's + // MissingPermission cannot be satisfied for an undeclared + // permission. Rather than call an API that can only throw, the + // answer is said up front: a fix is not available in this build. + return "ERROR: a location fix needs ACCESS_FINE_LOCATION, which this build does not declare\n" + } + + fun build(location: Location): String { + val sb = StringBuilder("{") + sb.append("\"latitude\":").append(location.latitude).append(',') + sb.append("\"longitude\":").append(location.longitude).append(',') + sb.append("\"altitude\":").append(location.altitude).append(',') + sb.append("\"accuracy\":").append(location.accuracy).append(',') + sb.append("\"bearing\":").append(location.bearing).append(',') + sb.append("\"speed\":").append(location.speed) + sb.append('}') + return sb.toString() + } +} + +/** `termux-sms-send` / `termux-sms-inbox`. */ +object SmsApi : ApiFeature { + + override val name = "sms" + override val permissions = setOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS) + + override fun run(context: Context, request: ApiRequest): String { + val op = request.extras.strings["op"] ?: "send" + return when (op) { + "send" -> send(context, request) + "inbox" -> inbox(context, request) + "list" -> list(context, request) + else -> "ERROR: unknown sms op '$op'\n" + } + } + + private fun send(context: Context, request: ApiRequest): String { + if (Build.VERSION.SDK_INT < 31) { + // getSystemService(SmsManager) is 31+; the static below that is + // the withdrawn getDefault(), which this app does not call. + return "ERROR: sms send needs Android 12 or newer\n" + } + val number = request.extras.strings["number"] + ?: return "ERROR: sms send needs a number\n" + val body = request.extras.strings["text"] ?: "" + val manager = context.getSystemService(SmsManager::class.java) + ?: return "ERROR: no SMS service on this device\n" + if (body.length > 160) { + manager.sendMultipartTextMessage(number, null, ArrayList(chunk(body)), null, null) + } else { + manager.sendTextMessage(number, null, body, null, null) + } + return "" + } + + private fun chunk(text: String): List { + val parts = ArrayList() + var rest = text + while (rest.length > 160) { + parts.add(rest.substring(0, 160)) + rest = rest.substring(160) + } + if (rest.isNotEmpty()) parts.add(rest) + return parts + } + + private fun inbox(context: Context, request: ApiRequest): String { + val limit = request.extras.ints["limit"] ?: 50 + val cursor = context.contentResolver.query( + "content://sms/inbox".toUri(), + arrayOf("_id", "address", "body", "date", "type"), + null, null, null + ) ?: return "ERROR: could not read the SMS inbox\n" + val sb = StringBuilder("[") + var count = 0 + cursor.use { c -> + while (c.moveToNext() && count < limit) { + if (count > 0) sb.append(',') + val body = c.getString(c.getColumnIndexOrThrow("body")) ?: "" + sb.append("{\"address\":").append(json(c.getString(c.getColumnIndexOrThrow("address")) ?: "")) + sb.append(",\"body\":").append(json(body)) + sb.append(",\"date\":").append(c.getLong(c.getColumnIndexOrThrow("date"))) + sb.append('}') + count++ + } + } + sb.append(']') + return sb.toString() + } + + /** + * `termux-sms-list`: every message the provider holds (inbox, sent, + * drafts), newest first, with the offset/limit applied in memory so + * the answer is the same on every Android version. + */ + private fun list(context: Context, request: ApiRequest): String { + val limit = (request.extras.ints["limit"] ?: 10).coerceAtLeast(0) + val offset = (request.extras.ints["offset"] ?: 0).coerceAtLeast(0) + if (limit == 0) return "[]\n" + val cursor = context.contentResolver.query( + "content://sms".toUri(), + arrayOf("_id", "address", "body", "date", "type", "read"), + null, null, "date DESC" + ) ?: return "ERROR: could not read the message store\n" + val sb = StringBuilder("[") + var seen = 0 + var written = 0 + cursor.use { c -> + while (c.moveToNext()) { + if (seen++ < offset) continue + if (written >= limit) break + if (written > 0) sb.append(',') + sb.append('{') + .append("\"address\":").append(json(c.getString(c.getColumnIndexOrThrow("address")) ?: "")) + .append(",\"body\":").append(json(c.getString(c.getColumnIndexOrThrow("body")) ?: "")) + .append(",\"date\":").append(c.getLong(c.getColumnIndexOrThrow("date"))) + .append(",\"type\":").append(smsType(c.getInt(c.getColumnIndexOrThrow("type")))) + .append(",\"read\":").append(c.getInt(c.getColumnIndexOrThrow("read")) == 1) + .append('}') + written++ + } + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun smsType(type: Int): String = when (type) { + 1 -> "inbox" + 2 -> "sent" + 3 -> "draft" + 4 -> "outbox" + 5 -> "failed" + 6 -> "queued" + else -> type.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} + +/** `termux-telephony-call` / `termux-call`. */ +object CallApi : ApiFeature { + + override val name = "call" + override val permissions = setOf(Manifest.permission.CALL_PHONE) + + override fun run(context: Context, request: ApiRequest): String { + val number = request.extras.strings["number"] + ?: return "ERROR: call needs a number\n" + val intent = Intent(Intent.ACTION_CALL, "tel:$number".toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return "" + } +} + +/** `termux-contact-list`: the people in the address book, with a name filter. */ +object ContactsApi : ApiFeature { + + override val name = "contacts" + override val permissions = setOf(Manifest.permission.READ_CONTACTS) + + override fun run(context: Context, request: ApiRequest): String { + val filter = request.extras.strings["name"] + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE + ) + val selection = if (filter.isNullOrEmpty()) null else + "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?" + val args = if (filter.isNullOrEmpty()) null else arrayOf("%$filter%") + val cursor = context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, selection, args, + "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC" + ) ?: return "ERROR: could not read the contact list\n" + val sb = StringBuilder("[") + var first = true + cursor.use { c -> + while (c.moveToNext()) { + val name = c.getString(c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)) ?: "" + val number = c.getString(c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) ?: "" + val type = c.getInt(c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE)) + if (number.isEmpty()) continue + if (!first) sb.append(',') + first = false + sb.append("{\"name\":").append(json(name)) + sb.append(",\"number\":").append(json(number)) + sb.append(",\"type\":").append(json(typeName(type))) + sb.append('}') + } + } + sb.append(']') + return sb.toString() + } + + private fun typeName(type: Int): String = when (type) { + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> "mobile" + ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> "home" + ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> "work" + ContactsContract.CommonDataKinds.Phone.TYPE_FAX_HOME -> "fax_home" + ContactsContract.CommonDataKinds.Phone.TYPE_FAX_WORK -> "fax_work" + ContactsContract.CommonDataKinds.Phone.TYPE_PAGER -> "pager" + ContactsContract.CommonDataKinds.Phone.TYPE_OTHER -> "other" + else -> type.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} + +/** + * `termux-torch`. The flash unit is a camera resource, so this goes + * through CameraManager (whose setTorchMode is not deprecated) and takes + * the camera grant. A device with no flash unit says so; a torch the OS + * refuses says so. + */ +object TorchApi : ApiFeature { + + override val name = "torch" + override val permissions = setOf(Manifest.permission.CAMERA) + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + val cameraId = flashCameraId(manager) + ?: return "ERROR: this device has no usable flash unit\n" + val op = request.extras.strings["op"] ?: "status" + return try { + when (op) { + "on" -> { + manager.setTorchMode(cameraId, true) + "{\"torch\":true}" + } + "off" -> { + manager.setTorchMode(cameraId, false) + "{\"torch\":false}" + } + else -> torchStatus(manager, cameraId) + } + } catch (e: Exception) { + "ERROR: the torch '$op' failed: ${e.javaClass.simpleName}\n" + } + } + + private fun flashCameraId(manager: CameraManager): String? = + manager.cameraIdList.firstOrNull { id -> + runCatching { + manager.getCameraCharacteristics(id) + .get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true + }.getOrDefault(false) + } + + /** + * Torch state. The platform has no portable synchronous query: the + * candidate (`isTorchOn`) is not in the stubs this build compiles + * against, and this device's runtime does not expose it either, so it + * is reached by name — the same device-only access the infrared and + * privileged radio APIs in this file use — and a missing method is + * answered as "not queryable", not as a lying `false` or `null` + * (the TorchCallback path is no help: it only fires on changes, and + * a status query can never wait itself into knowing the state). + */ + private fun torchStatus(manager: CameraManager, cameraId: String): String = + runCatching { + val on = manager.javaClass.getMethod("isTorchOn", String::class.java) + .invoke(manager, cameraId) as? Boolean + ?: error("isTorchOn returned no value") + "{\"torch\":$on}\n" + }.getOrElse { e -> + val cause = e.cause ?: e + "ERROR: the torch state is not queryable on this device (${cause.javaClass.simpleName}); set it explicitly with 'torch on/off'\n" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/DialogApi.kt b/app/src/main/java/com/redtermapp/util/api/DialogApi.kt new file mode 100644 index 0000000..1e9f257 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/DialogApi.kt @@ -0,0 +1,39 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import com.redtermapp.util.api.ui.ApiUiBridge + +/** + * `termux-dialog`: a real dialog the user answers, blocking until the + * answer (or timeout) and returning it as a JSON value. The widgets the + * reference offers are all here: `text`, `confirm`, `spinner`, `radio`, + * `checkbox`, `sheet`, `counter`, `date`, `time`; `speech` needs a + * microphone capture loop and answers as unsupported rather than a + * blank dialog. + */ +object DialogApi : ApiFeature { + + override val name = "dialog" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val kind = request.str("input_method") ?: "text" + if (kind == "speech") { + return "ERROR: the speech dialog needs a live microphone capture this bridge cannot block on; use redterm-microphone-record\n" + } + val values = request.str("input_values")?.split(',')?.map { it.trim() } ?: emptyList() + val range = request.str("input_range") ?: "" + val spec = ApiUiBridge.ApiUiSpec( + kind = kind, + timeoutSec = request.int("timeout") ?: 60, + extras = mapOf( + "redterm.api.title" to (request.str("input_title") ?: ""), + "redterm.api.hint" to (request.str("input_hint") ?: ""), + "redterm.api.values" to values.joinToString(","), + "redterm.api.range" to range, + ) + ) + return ApiUiBridge.show(context, com.redtermapp.util.api.ui.ApiDialogActivity::class.java, spec) + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/DisplayApis.kt b/app/src/main/java/com/redtermapp/util/api/DisplayApis.kt new file mode 100644 index 0000000..08fa7c1 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/DisplayApis.kt @@ -0,0 +1,75 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.media.AudioManager +import android.provider.Settings +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-brightness`. Writing the screen brightness is a system setting, + * so the value the call returns is not proof it was applied: the + * "modify system settings" grant is what makes the write stick, and a + * write without it is reported, not papered over. + */ +object BrightnessApi : ApiFeature { + + override val name = "brightness" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val resolver = context.contentResolver + val level = request.extras.ints["level"] + if (level == null || level < 0) { + val current = Settings.System.getInt(resolver, Settings.System.SCREEN_BRIGHTNESS, -1) + return "{\"brightness\":$current,\"min\":0,\"max\":255}" + } + if (!Settings.System.canWrite(context)) { + return "ERROR: RedTerm needs the \"modify system settings\" grant to set brightness: system Settings, Apps, RedTerm, Special access, Modify system settings\n" + } + Settings.System.putInt(resolver, Settings.System.SCREEN_BRIGHTNESS, level.coerceIn(0, 255)) + return "{\"brightness\":${level.coerceIn(0, 255)},\"min\":0,\"max\":255}" + } +} + +/** `termux-audio-info`: where each stream sits right now, and the ringer mode. */ +object AudioInfoApi : ApiFeature { + + override val name = "audio-info" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String = + build(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager) + + fun build(audio: AudioManager): String { + val streams = listOf( + "music" to AudioManager.STREAM_MUSIC, + "alarm" to AudioManager.STREAM_ALARM, + "ring" to AudioManager.STREAM_RING, + "notification" to AudioManager.STREAM_NOTIFICATION, + "voice" to AudioManager.STREAM_VOICE_CALL, + "dtmf" to AudioManager.STREAM_DTMF + ) + val sb = StringBuilder("{\"streams\":{") + streams.forEachIndexed { i, (label, id) -> + if (i > 0) sb.append(',') + sb.append('"').append(label).append("\":[") + .append(audio.getStreamVolume(id)) + .append(',').append(audio.getStreamMaxVolume(id)) + .append(']') + } + sb.append('}') + sb.append(",\"ringerMode\":").append(json(ringerName(audio.ringerMode))) + sb.append('}') + return sb.toString() + } + + private fun ringerName(mode: Int): String = when (mode) { + AudioManager.RINGER_MODE_NORMAL -> "normal" + AudioManager.RINGER_MODE_SILENT -> "silent" + AudioManager.RINGER_MODE_VIBRATE -> "vibrate" + else -> mode.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt b/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt new file mode 100644 index 0000000..9e8e8b3 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt @@ -0,0 +1,41 @@ +package com.redtermapp.util.api + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * `termux-download`. The system download manager is a content provider + * request, so no dangerous permission is required. The request returns + * the download's ID so the guest can watch it, or an ERROR line if the + * URL is malformed. The download shows a notification and completes + * in the user's Downloads folder unless a destination path is given. + */ +object DownloadApi : ApiFeature { + + override val name = "download" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val url = request.str("url") ?: return "ERROR: no URL provided\n" + val uri = url.toUri() + if (!uri.isAbsolute) return "ERROR: the URL must be absolute (include the scheme)\n" + + val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val r = DownloadManager.Request(uri) + val title = request.str("title") ?: "Downloaded by RedTerm" + r.setTitle(title) + r.setDescription(request.str("description") ?: title) + r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + r.setVisibleInDownloadsUi(true) + request.str("path")?.let { + r.setDestinationUri(Uri.fromFile(File(it))) + } + + val id = manager.enqueue(r) + return "{\"downloadId\":$id}\n" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/Extras.kt b/app/src/main/java/com/redtermapp/util/api/Extras.kt new file mode 100644 index 0000000..c2cf570 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/Extras.kt @@ -0,0 +1,18 @@ +package com.redtermapp.util.api + +import com.redtermapp.util.api.ApiRequestParser.ApiExtras +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * Guest scripts emit a fixed set of named extras, some of them empty when + * the corresponding flag was not given. An empty string is "not set", not a + * value: these treat it that way so the app can fall back to a default. + */ +fun ApiExtras.str(key: String): String? = strings[key]?.takeIf { it.isNotEmpty() } +fun ApiExtras.int(key: String): Int? = ints[key] +fun ApiExtras.bool(key: String): Boolean? = booleans[key] +fun ApiExtras.float(key: String): Float? = floats[key] +fun ApiRequest.str(key: String): String? = extras.str(key) +fun ApiRequest.int(key: String): Int? = extras.int(key) +fun ApiRequest.bool(key: String): Boolean? = extras.bool(key) +fun ApiRequest.float(key: String): Float? = extras.float(key) diff --git a/app/src/main/java/com/redtermapp/util/api/FingerprintApi.kt b/app/src/main/java/com/redtermapp/util/api/FingerprintApi.kt new file mode 100644 index 0000000..9e7db7a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/FingerprintApi.kt @@ -0,0 +1,58 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.hardware.biometrics.BiometricPrompt +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import com.redtermapp.util.api.ui.ApiBiometricActivity +import com.redtermapp.util.api.ui.ApiUiBridge + +/** + * `termux-fingerprint`: check enrollment / trigger a scan. `check` is + * instant and headless; `scan` shows the system biometric prompt and + * blocks until it succeeds, is cancelled, or times out, returning the + * verdict. + */ +object FingerprintApi : ApiFeature { + + override val name = "fingerprint" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "check" + return when (op) { + "check" -> check(context) + "scan" -> { + if (android.os.Build.VERSION.SDK_INT < 28) + return "ERROR: the biometric prompt needs Android 9.0 or newer\n" + val desc = request.str("description") ?: "" + ApiUiBridge.show( + context, + ApiBiometricActivity::class.java, + ApiUiBridge.ApiUiSpec( + "biometric", + request.int("timeout") ?: 30, + mapOf("redterm.api.biometricDescription" to desc) + ) + ) + } + else -> "ERROR: unknown fingerprint op '$op'\n" + } + } + + private fun check(context: Context): String { + // BiometricManager.from is not on this build's stub; the class + // ships on the platform, so reach it reflectively and degrade to + // a readable "could not check" when it is absent. + val status = runCatching { + val clazz = Class.forName("android.hardware.biometrics.BiometricManager") + val from = clazz.getMethod("from", Context::class.java).invoke(null, context) + val canAuth = clazz.getMethod("canAuthenticate").invoke(from) as Int + canAuth + }.getOrNull() + if (status == null) return "could not determine biometric state on this platform\n" + return when (status) { + 0 -> "available\n" // BIOMETRIC_SUCCESS + else -> "not available (code $status)\n" + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/InfraredApi.kt b/app/src/main/java/com/redtermapp/util/api/InfraredApi.kt new file mode 100644 index 0000000..dd2d794 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/InfraredApi.kt @@ -0,0 +1,65 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-infrared-frequencies` / `termux-infrared-transmit`: the IR + * blaster, through InfraredUserManager. The class is not in this build's + * platform stub, so it is reached through the service lookup and method + * reflection: a device without a blaster says so instead of crashing. + */ +object InfraredApi : ApiFeature { + + override val name = "infrared" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + return when (val op = request.str("op") ?: "list") { + "list" -> listFrequencies(context) + "transmit" -> { + val freq = request.int("frequency") ?: 38000 + transmitCarrier(context, freq) + } + else -> "ERROR: unknown infrared op '$op'\n" + } + } + + /** The IR blaster service, or null when the device has no hardware. */ + private fun irManager(context: Context): Any? = + runCatching { context.getSystemService(INFRARED_SERVICE_NAME) }.getOrNull() + + private fun listFrequencies(context: Context): String { + val mgr = irManager(context) + ?: return "ERROR: no infrared blaster on this device\n" + val freqs = runCatching { + mgr.javaClass.getMethod("getCarrierFrequencies").invoke(mgr) as IntArray + }.getOrElse { + // The carrier families a blaster normally answers; the + // accessor is the source of truth when the platform has it. + intArrayOf(38000, 36000, 56000) + } + val sb = StringBuilder("[") + for ((i, f) in freqs.withIndex()) { + if (i > 0) sb.append(",") + sb.append("{\"frequency\":").append(f) + .append(",\"available\":true,\"transmissionType\":\"continuous\"}") + } + sb.append("]") + return sb.toString() + "\n" + } + + private fun transmitCarrier(context: Context, freq: Int): String { + val mgr = irManager(context) + ?: return "ERROR: no infrared blaster on this device\n" + val invoked = runCatching { + mgr.javaClass.getMethod("sendCarrierWave", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + .invoke(mgr, freq, 1000, 0) + true + }.getOrDefault(false) + return if (invoked) "TRANSMITTED $freq Hz\n" else "ERROR: the blaster refused the carrier\n" + } + + /** The platform service name for the IR blaster; the constant is not on this build's stub. */ + private const val INFRARED_SERVICE_NAME = "infrared" +} diff --git a/app/src/main/java/com/redtermapp/util/api/JobRunWorker.kt b/app/src/main/java/com/redtermapp/util/api/JobRunWorker.kt new file mode 100644 index 0000000..e7e25c7 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/JobRunWorker.kt @@ -0,0 +1,65 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Worker +import androidx.work.WorkerParameters + +/** + * The thing a scheduled `termux-job-scheduler` job actually does: it + * fires and posts a notification named after the job's tag, so a + * schedule the guest set is something the user can see happened rather + * than a silent no-op. The worker itself is declared in the manifest; + * WorkManager drives it. + */ +class JobRunWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + + override fun doWork(): Result { + val tag = inputData.getString(KEY_JOB_TAG) ?: "job" + val manager = NotificationManagerCompat.from(applicationContext) + // From API 33 on, posting without the notification grant throws; + // a job that fired but cannot be seen still succeeded. + if (Build.VERSION.SDK_INT >= 33) { + val granted = applicationContext.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + if (!granted) { + com.redtermapp.util.AppLog.w(applicationContext, "api-job", "job '$tag' fired but POST_NOTIFICATIONS is not granted; the notification was skipped") + return Result.success() + } + } + NotificationChannelService.ensure(applicationContext) + val note = NotificationCompat.Builder(applicationContext, NotificationChannelService.CHANNEL) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("RedTerm scheduled job") + .setContentText("'$tag' fired") + .setAutoCancel(true) + .build() + runCatching { manager.notify(tag.hashCode(), note) } + return Result.success() + } + + companion object { + const val KEY_JOB_TAG = "redterm.job.tag" + } +} + +/** The channel the job notification lands in; created once per process. */ +internal object NotificationChannelService { + const val CHANNEL = "redterm" + private var ensured = false + + fun ensure(context: Context) { + if (ensured) return + synchronized(this) { + if (ensured) return + if (Build.VERSION.SDK_INT >= 26) { + val ch = android.app.NotificationChannel(CHANNEL, "RedTerm", android.app.NotificationManager.IMPORTANCE_DEFAULT) + context.getSystemService(android.app.NotificationManager::class.java) + .createNotificationChannel(ch) + } + ensured = true + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/JobScheduleApi.kt b/app/src/main/java/com/redtermapp/util/api/JobScheduleApi.kt new file mode 100644 index 0000000..a9dbc92 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/JobScheduleApi.kt @@ -0,0 +1,106 @@ +package com.redtermapp.util.api + +import android.content.Context +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * `termux-job-scheduler`: schedule a job to fire on a period (or once), + * cancel one by id, cancel all, and list the pending ones. The reference + * is built on `JobScheduler`, which the platform has deprecated, so this + * uses `WorkManager` — the API it was replaced by — and the scheduled + * job is a [JobRunWorker] that makes the firing observable. + */ +object JobScheduleApi : ApiFeature { + + override val name = "job-scheduler" + override val permissions = emptySet() + + private const val TAG_PREFIX = "redterm-job:" + private const val MIN_PERIOD_MS = 15 * 60 * 1000L // WorkManager's floor + + override fun run(context: Context, request: ApiRequest): String { + val wm = WorkManager.getInstance(context) + if (request.bool("cancel_all") == true) { + wm.cancelAllWorkByTag(TAG_PREFIX.trimEnd(':')) + return "cancelled all redterm jobs\n" + } + if (request.bool("cancel") == true) { + val id = request.str("job_id") + if (id.isNullOrBlank()) return "ERROR: job id not passed\n" + val uuid = try { + UUID.fromString(id) + } catch (e: IllegalArgumentException) { + return "ERROR: '$id' is not a job id\n" + } + wm.cancelWorkById(uuid) + return "cancelled\n" + } + return if (request.str("script").isNullOrBlank()) { + list(wm) + } else { + schedule(wm, request) + } + } + + private fun schedule(wm: WorkManager, request: ApiRequest): String { + val tag = request.str("script") ?: return "ERROR: no job name given\n" + val uniqueTag = TAG_PREFIX + tag + val periodMs = (request.int("period_ms") ?: 0).toLong() + val input = Data.Builder().putString(JobRunWorker.KEY_JOB_TAG, tag).build() + val constraints = androidx.work.Constraints.Builder() + .setRequiresBatteryNotLow(request.bool("battery_not_low") == true) + .setRequiresStorageNotLow(request.bool("storage_not_low") == true) + .setRequiresCharging(request.bool("charging") == true) + .build() + + if (periodMs > 0) { + val req = androidx.work.PeriodicWorkRequest.Builder( + JobRunWorker::class.java, + periodMs.coerceAtLeast(MIN_PERIOD_MS), + TimeUnit.MILLISECONDS + ) + .setInputData(input) + .setConstraints(constraints) + .build() + wm.enqueueUniquePeriodicWork(uniqueTag, ExistingPeriodicWorkPolicy.REPLACE, req) + return "scheduled '$tag' every ${periodMs.coerceAtLeast(MIN_PERIOD_MS)} ms\n" + } + val req = OneTimeWorkRequest.Builder(JobRunWorker::class.java) + .setInputData(input) + .setConstraints(constraints) + .addTag(uniqueTag) + .build() + wm.enqueueUniqueWork(uniqueTag, ExistingWorkPolicy.REPLACE, req) + return "scheduled '$tag' once\n" + } + + private fun list(wm: WorkManager): String { + val sb = StringBuilder("[") + var first = true + val all = runCatching { + wm.getWorkInfos(androidx.work.WorkQuery.fromStates(*androidx.work.WorkInfo.State.values())).get() + }.getOrNull() ?: emptyList() + for (info in all) { + val jobTag = info.tags.firstOrNull { it.startsWith(TAG_PREFIX) } ?: continue + if (!first) sb.append(",") + first = false + sb.append('{') + .append("\"id\":").append(json(info.id.toString())) + .append(",\"state\":").append(json(info.state.name)) + .append(",\"tag\":").append(json(jobTag.substringAfter(':'))) + .append('}') + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun json(s: String): String = "\"${s.replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/KeystoreApi.kt b/app/src/main/java/com/redtermapp/util/api/KeystoreApi.kt new file mode 100644 index 0000000..2f780c8 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/KeystoreApi.kt @@ -0,0 +1,135 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import java.security.cert.X509Certificate +import java.security.spec.ECGenParameterSpec + +/** + * `termux-keystore`: keys and signatures in the platform keystore + * (`AndroidKeyStore`, which is not deprecated and is what the reference's + * on-device variant uses). Commands mirror the reference: `list`, + * `create`, `sign`, `verify`, `destroy`. `destroy` goes through the + * keystore's `deleteKey` reflectively, because that method is not on + * this build's `KeyStore` stub but is present on the platform at + * runtime. + */ +object KeystoreApi : ApiFeature { + + override val name = "keystore" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String = try { + when (val command = request.str("command") ?: "list") { + "list" -> list() + "create" -> create(request) + "sign" -> sign(request) + "verify" -> verify(request) + "destroy" -> destroy(request) + else -> "ERROR: unknown keystore command '$command'\n" + } + } catch (e: Exception) { + "ERROR: ${e.message ?: e.javaClass.simpleName}\n" + } + + private fun keystore(): KeyStore = + run { val ks = KeyStore.getInstance("AndroidKeyStore"); ks.load(null, null); ks } + + private fun list(): String { + val sb = StringBuilder("[") + var first = true + for (alias in keystore().aliases()) { + if (keystore().isKeyEntry(alias)) { + if (!first) sb.append(",") + first = false + sb.append(json(alias)) + } + } + sb.append("]") + return sb.toString() + "\n" + } + + private fun create(request: ApiRequest): String { + val alias = request.str("alias") ?: return "ERROR: create needs an alias\n" + val algorithm = (request.str("algorithm") ?: "rsa").lowercase() + val ks = keystore() + if (ks.containsAlias(alias)) return "ERROR: key '$alias' already exists\n" + + val keyPair: KeyPair = if (algorithm == "ec") { + val curve = (request.str("curve") ?: "P-256").uppercase() + KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec(curve)) + }.generateKeyPair() + } else { + KeyPairGenerator.getInstance("RSA").apply { + initialize(request.int("size") ?: 2048) + }.generateKeyPair() + } + ks.setKeyEntry(alias, keyPair.private, null, null) + return "OK\n" + } + + private fun sign(request: ApiRequest): String { + val alias = request.str("alias") ?: return "ERROR: sign needs an alias\n" + val dataHex = request.str("data") ?: return "ERROR: sign needs hex data\n" + val key = (keystore().getKey(alias, null) as? java.security.PrivateKey) + ?: return "ERROR: key '$alias' not a private key\n" + val signature = Signature.getInstance(signatureFor(key, request.str("digest") ?: "SHA-256")) + signature.initSign(key) + signature.update(fromHex(dataHex)) + return toHex(signature.sign()) + "\n" + } + + private fun verify(request: ApiRequest): String { + val alias = request.str("alias") ?: return "ERROR: verify needs an alias\n" + val dataHex = request.str("data") ?: return "ERROR: verify needs hex data\n" + val sigHex = request.str("signature") ?: return "ERROR: verify needs a signature\n" + val cert = keystore().getCertificate(alias) as? X509Certificate + ?: return "ERROR: no certificate for '$alias'\n" + val signature = Signature.getInstance("SHA256withRSA") + signature.initVerify(cert) + signature.update(fromHex(dataHex)) + val ok = signature.verify(fromHex(sigHex)) + return (if (ok) "true" else "false") + "\n" + } + + private fun destroy(request: ApiRequest): String { + val alias = request.str("alias") ?: return "ERROR: destroy needs an alias\n" + val ks = keystore() + if (!ks.containsAlias(alias)) return "NOT_FOUND\n" + // deleteKey is not on this build's KeyStore stub but is on the + // platform KeyStore at runtime. + runCatching { + ks.javaClass.getMethod("deleteKey", String::class.java).invoke(ks, alias) + }.getOrElse { return "ERROR: could not delete '$alias': ${it.message}\n" } + return "OK\n" + } + + private fun signatureFor(key: java.security.Key, digest: String): String { + val pad = if (key.algorithm.equals("EC", ignoreCase = true)) "ECDSA" else "RSA" + val hash = when (digest.uppercase().removePrefix("SHA-")) { + "1" -> "SHA1" + "384" -> "SHA384" + "512" -> "SHA512" + else -> "SHA256" + } + return "$hash with$pad" + } + + private fun json(s: String): String = "\"${s.replace("\"", "\\\"")}\"" + private fun toHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + private fun fromHex(hex: String): ByteArray { + val clean = hex.replace(" ", "").removePrefix("0x") + val out = ByteArray((clean.length + 1) / 2) + for (i in out.indices) { + val start = i * 2 + val digits = clean.substring(start, minOf(start + 2, clean.length)) + out[i] = digits.toInt(16).toByte() + } + return out + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/MediaApis.kt b/app/src/main/java/com/redtermapp/util/api/MediaApis.kt new file mode 100644 index 0000000..4a99276 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MediaApis.kt @@ -0,0 +1,117 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.media.AudioManager +import android.media.AudioManager.ADJUST_LOWER +import android.media.AudioManager.ADJUST_RAISE +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-volume`. Volume has no permission, but OEMs that route the + * audio stream through a vendor service can still refuse; the answer the + * stream reports is the truth, not the call that requested it. + */ +object VolumeApi : ApiFeature { + + override val name = "volume" + override val permissions = emptySet() + + /** Stream names the guest can spell; the int fallback is the raw id. */ + fun streamId(name: String): Int = when (name) { + "music" -> AudioManager.STREAM_MUSIC + "alarm" -> AudioManager.STREAM_ALARM + "ring", "ringtone" -> AudioManager.STREAM_RING + "notification" -> AudioManager.STREAM_NOTIFICATION + "system" -> AudioManager.STREAM_SYSTEM + "voice", "call" -> AudioManager.STREAM_VOICE_CALL + "dtmf" -> AudioManager.STREAM_DTMF + else -> name.toIntOrNull() ?: AudioManager.STREAM_MUSIC + } + + override fun run(context: Context, request: ApiRequest): String { + val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val extras = request.extras + val stream = streamId(extras.str("stream") ?: "music") + val op = extras.str("op") ?: "print" + val value = extras.int("value") ?: -1 + val max = audio.getStreamMaxVolume(stream) + when (op) { + "set" -> { + if (value < 0) return "ERROR: volume set needs a value (0-100)\n" + audio.setStreamVolume(stream, value.coerceIn(0, 100) * max / 100, 0) + } + "raise" -> audio.adjustStreamVolume(stream, ADJUST_RAISE, 0) + "lower" -> audio.adjustStreamVolume(stream, ADJUST_LOWER, 0) + "mute" -> audio.setStreamVolume(stream, 0, 0) + "unmute" -> audio.setStreamVolume(stream, max, 0) + else -> Unit + } + return build(stream, audio.getStreamVolume(stream), max) + } + + fun build(stream: Int, volume: Int, maxVolume: Int): String = + "{\"volume\":$volume,\"maxVolume\":$maxVolume,\"stream\":$stream}" +} + +/** + * `termux-vibrate`. VibrationEffect and VibratorManager both need API 26+; + * below that the only vibrate overloads are withdrawn from the platform, + * and this app does not call withdrawn APIs, so the honest answer is a + * version gate, not a suppress. + */ +object VibrateApi : ApiFeature { + + override val name = "vibrate" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + if (Build.VERSION.SDK_INT < 26) { + return "ERROR: vibrate needs Android 8.0 or newer\n" + } + val vibrator = if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(VibratorManager::class.java).defaultVibrator + } else { + // The VIBRATOR_SERVICE constant is deprecated on 31+, and the + // string form of getSystemService is not; "vibrator" is the + // service name it names. + context.getSystemService("vibrator") as Vibrator + } + val patternCsv = request.extras.str("pattern") + val effect = if (!patternCsv.isNullOrEmpty()) { + val times = ArrayList(patternCsv.split(',').size) + for (piece in patternCsv.split(',')) { + val ms = piece.trim().toLongOrNull() + ?: return "ERROR: vibrate pattern item '$piece' is not a millisecond count\n" + times.add(ms) + } + VibrationEffect.createWaveform(times.toLongArray(), -1) + } else { + val duration = request.extras.ints["duration"] ?: 500 + VibrationEffect.createOneShot(duration.toLong(), VibrationEffect.DEFAULT_AMPLITUDE) + } + vibrator.vibrate(effect) + return "" + } +} + +/** `termux-toast`. A toast is only ever shown on the main thread. */ +object ToastApi : ApiFeature { + + override val name = "toast" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val message = request.extras.str("message") + ?: return "ERROR: toast needs a message\n" + val long = request.bool("long") == true + val toast = android.widget.Toast.makeText(context, message, if (long) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT) + Handler(Looper.getMainLooper()).post { toast.show() } + return "" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt b/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt new file mode 100644 index 0000000..a122056 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt @@ -0,0 +1,62 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.media.MediaPlayer +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-media-player`: a single process-wide [MediaPlayer]. `play` + * loads and starts a local file, `resume`/`pause`/`stop`/`info` act on + * the current one. Playback outlives the request that started it (the + * engine is held in a companion), so a later `stop` or another `play` + * replaces it. + */ +object MediaPlaybackApi : ApiFeature { + + override val name = "media-player" + override val permissions = emptySet() + + @Volatile private var player: MediaPlayer? = null + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "info" + return when (op) { + "play" -> { + val file = request.str("file") ?: return "ERROR: play needs a file\n" + val p = MediaPlayer().apply { + setDataSource(context, android.net.Uri.fromFile(java.io.File(file))) + prepare() + start() + } + player = p + "PLAYING ${p.currentPosition}\n" + } + "resume" -> { + val p = player ?: return "ERROR: nothing is playing\n" + if (p.isPlaying) return "ALREADY PLAYING\n" + p.start() + "RESUMED\n" + } + "pause" -> { + val p = player ?: return "ERROR: nothing is playing\n" + p.pause() + "PAUSED ${p.currentPosition}\n" + } + "stop" -> { + val p = player + if (p != null) { + p.stop() + p.release() + player = null + } + "STOPPED\n" + } + "info" -> { + val p = player ?: return "NOT_PLAYING\n" + val pos = p.currentPosition + "PLAYING position=$pos isPlaying=${p.isPlaying}\n" + } + else -> "ERROR: unknown media-player op '$op'\n" + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt b/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt new file mode 100644 index 0000000..5dd5dcd --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt @@ -0,0 +1,72 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.media.MediaScannerConnection +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * `termux-media-scan`: hand the given paths to the media content provider + * so they show up in gallery/players. The scan is fired through + * [MediaScannerConnection.scanFile]; a directory is walked only when + * recursive was requested. + */ +object MediaScannerApi : ApiFeature { + + override val name = "media-scan" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + // An empty element is a malformed request from the guest side (an + // empty PATHS still arrives as one empty string, and File("") + // resolves to the process cwd — a warning about "/" that points at + // nothing). Filter before it can masquerade as a real path. + val paths = (request.extras.stringArrays["paths"] ?: emptyArray()) + .map { it.trim() } + .filter { it.isNotEmpty() } + if (paths.isEmpty()) return "ERROR: no paths given\n" + val recursive = request.bool("recursive") == true + val verbose = request.bool("verbose") == true + var total = 0 + val out = StringBuilder() + for (path in paths) { + val file = File(path) + when { + file.isFile -> { + scan(context, file, out, verbose) + total++ + } + file.isDirectory -> { + val queue = ArrayDeque() + queue.add(file) + while (queue.isNotEmpty()) { + val dir = queue.removeFirst() + for (entry in dir.listFiles().orEmpty()) { + if (entry.isDirectory && recursive) queue.add(entry) + else if (entry.isFile) { + scan(context, entry, out, verbose) + total++ + } + } + } + } + else -> out.append("Warning: does not exist: ").append(path).append('\n') + } + } + out.append("Finished scanning ").append(total).append(" file(s)\n") + return out.toString() + } + + private fun scan(context: Context, file: File, out: StringBuilder, verbose: Boolean) { + MediaScannerConnection.scanFile( + context, + arrayOf(file.absolutePath), + null, + ) { _, _ -> + // The completion listener runs on the main thread after the + // response is already sent; it only tells us the scan did not + // happen, which is where verbose output would belong. + } + if (verbose) out.append("Scanned: ").append(file.absolutePath).append('\n') + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt b/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt new file mode 100644 index 0000000..b0c9d83 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt @@ -0,0 +1,107 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.media.MediaRecorder +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * `termux-microphone-record`: a process-wide [MediaRecorder]. `-d` + * starts with sensible defaults into the app's own storage, `-f -e -b -r + * -c -l` the full form, `-i` reports, `-q` stops and hands back the file. + * Needs the microphone grant; a device without a mic says so. + */ +object MicRecordApi : ApiFeature { + + override val name = "mic-record" + override val permissions = setOf(Manifest.permission.RECORD_AUDIO) + + @Volatile private var recorder: MediaRecorder? = null + @Volatile private var recordedPath: String? = null + @Volatile private var recording = false + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "info" + return when (op) { + "start" -> start(context, request) + "stop" -> stop() + "info" -> { + if (!recording) return "NOT_RECORDING\n" + "RECORDING ${recordedPath ?: ""}\n" + } + else -> "ERROR: unknown mic-record op '$op'\n" + } + } + + private fun start(context: Context, request: ApiRequest): String { + if (recording) return "ALREADY_RECORDING\n" + val enc = encoderName(request.str("encoder")) + val file = request.str("file") + ?: File(context.filesDir, "api/recording-${System.currentTimeMillis()}").absolutePath + val limitMs = (request.int("limit") ?: 0).toLong() + val r = MediaRecorder() + try { + r.setAudioSource(MediaRecorder.AudioSource.MIC) + r.setOutputFormat(formatFor(enc)) + r.setAudioEncoder(encoderFor(enc)) + (request.int("bitrate")?.coerceAtLeast(0))?.takeIf { it > 0 }?.let { r.setAudioEncodingBitRate(it) } + (request.int("srate")?.coerceAtLeast(0))?.takeIf { it > 0 }?.let { r.setAudioSamplingRate(it) } + (request.int("channels")?.coerceAtLeast(0))?.takeIf { it > 0 }?.let { r.setAudioChannels(it) } + if (limitMs > 0) r.setMaxDuration(limitMs.toInt()) + r.setOutputFile(file) + r.prepare() + r.start() + } catch (e: Exception) { + runCatching { r.release() } + return "ERROR: could not start recording: ${e.message}\n" + } + recorder = r + recordedPath = file + recording = true + return "RECORDING $file\n" + } + + private fun stop(): String { + val r = recorder ?: return "NOT_RECORDING\n" + recording = false + recorder = null + var secs = -1 + runCatching { + r.stop() + secs = 0 + } + runCatching { r.release() } + recordedPath?.let { + val size = runCatching { File(it).length() }.getOrDefault(-1L) + return "STOPPED $it ${size} bytes\n" + } + return "STOPPED\n" + } + + private fun encoderName(raw: String?): String = when (raw?.lowercase()) { + "aac" -> "aac" + "amrnb" -> "amrnb" + "amrwb" -> "amrwb" + "opus" -> "opus" + "vorbis" -> "vorbis" + else -> "aac" + } + + private fun formatFor(enc: String) = when (enc) { + "amrnb", "amrwb" -> MediaRecorder.OutputFormat.AMR_NB + "aac", "opus", "vorbis" -> MediaRecorder.OutputFormat.MPEG_4 + else -> MediaRecorder.OutputFormat.MPEG_4 + } + + private fun encoderFor(enc: String) = when (enc) { + "amrnb" -> MediaRecorder.AudioEncoder.AMR_NB + "amrwb" -> MediaRecorder.AudioEncoder.AMR_WB + // OPUS only exists from API 29; below it fall back to AAC so a + // recording request still succeeds rather than throwing. + "opus" -> if (android.os.Build.VERSION.SDK_INT >= 29) MediaRecorder.AudioEncoder.OPUS + else MediaRecorder.AudioEncoder.AAC + "vorbis" -> MediaRecorder.AudioEncoder.VORBIS + else -> MediaRecorder.AudioEncoder.AAC + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt new file mode 100644 index 0000000..ed6df5a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt @@ -0,0 +1,168 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.net.wifi.WifiManager +import android.os.Build +import androidx.annotation.RequiresApi +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-network-cidrs` / `termux-network-interface`. These read the + * kernel's interface list, so no permission is needed; what an interface + * reports is the answer. + */ +object NetworkApi : ApiFeature { + + override val name = "network" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val op = request.extras.strings["op"] ?: "cidrs" + val raw = java.net.NetworkInterface.getNetworkInterfaces()?.toList() ?: emptyList() + val interfaces = raw.map { iface -> + IfaceInfo( + name = iface.name, + up = runCatching { iface.isUp }.getOrDefault(false), + addresses = iface.inetAddresses?.toList() + ?.mapNotNull { addr -> + runCatching { + val a = addr.hostAddress ?: return@mapNotNull null + if (a.isEmpty()) return@mapNotNull null + a + }.getOrNull() + } + ?: emptyList() + ) + } + return when (op) { + "interface" -> { + // The Termux:API shape: one line per address of every UP + // interface. The first version answered with a single + // interface name, and it was `dummy0` — the first UP + // interface alphabetically is not the one the user meant + // by "the network". + val lines = interfaces.filter { it.up } + .flatMap { iface -> + iface.addresses.map { addr -> + "${iface.name} ${if (addr.contains(":")) "inet6" else "inet"} $addr" + } + } + if (lines.isEmpty()) "ERROR: no active interface has an address\n" + else lines.joinToString("\n") + "\n" + } + "cidrs" -> buildList(interfaces.filter { it.up }) + else -> "ERROR: unknown network op '$op'\n" + } + } + + data class IfaceInfo(val name: String, val up: Boolean, val addresses: List) + + fun buildList(interfaces: List): String { + val sb = StringBuilder("[") + interfaces.forEachIndexed { i, iface -> + if (i > 0) sb.append(',') + sb.append("{\"name\":").append(json(iface.name)) + sb.append(",\"up\":").append(iface.up) + sb.append(",\"addresses\":[") + iface.addresses.forEachIndexed { j, addr -> + if (j > 0) sb.append(',') + sb.append(json(addr)) + } + sb.append("]}") + } + sb.append(']') + return sb.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} + +/** + * `termux-wifi-scaninfo`. Reads the manager's last scan, which is + * non-deprecated; on API 23+ the SSID string inside a result is withheld + * without a location grant, so an ungranted device gets redacted SSIDs, + * not an exception. The SSID accessor (`getWifiSsid`) is API 33+, so on + * 24-32 the scan is reported without an ssid key rather than through the + * deprecated `SSID` field. + * + * The *associated* SSID and connection info (`termux-sscid`, + * `termux-wifi-connectioninfo`) are deliberately not in this batch: their + * only synchronous source, `WifiManager.getConnectionInfo()`, is + * deprecated on this SDK with no replacement listener, and the gate + * forbids calling a withdrawn API. They come back when the platform + * offers a non-deprecated path. + */ +object WifiApi : ApiFeature { + + override val name = "wifi" + override val permissions = setOf(Manifest.permission.ACCESS_FINE_LOCATION) + + override fun run(context: Context, request: ApiRequest): String { + val op = request.extras.strings["op"] ?: "scan" + // The guest script (termux-wifi-scaninfo) sends `scaninfo`; keep + // `scan` accepted so either spelling of the op reaches the handler. + if (op != "scan" && op != "scaninfo") return "ERROR: unknown wifi op '$op'\n" + // Reading the scan needs ACCESS_WIFI_STATE (a normal manifest + // permission); a refusal is a readable answer, not a crash. + val manager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + // The scan accessors are reached reflectively: this build trims the + // manifest to a defensible core (no ACCESS_FINE_LOCATION), and Lint's + // MissingPermission cannot be satisfied by a runtime check for a + // permission the build does not declare. The reflective calls are + // untrackable by the static check and still answer in a build that + // does declare and grant the permission; without it they throw + // SecurityException and the honest answer is the (empty) last scan. + var results = runCatching { + manager.javaClass.getMethod("getScanResults") + .invoke(manager) as? List + }.getOrNull().orEmpty() + // The scan list is only what the *last* scan produced; a device + // that has not scanned recently answers [] to every question. + // Trigger a scan and wait for it to settle. + if (results.isEmpty()) { + val scanStarted = runCatching { + manager.javaClass.getMethod("startScan").invoke(manager) as? Boolean + }.getOrNull() ?: false + if (scanStarted) { + val deadline = System.currentTimeMillis() + 4000 + while (System.currentTimeMillis() < deadline) { + Thread.sleep(500) + val fresh = runCatching { + manager.javaClass.getMethod("getScanResults") + .invoke(manager) as? List + }.getOrNull().orEmpty() + if (fresh.isNotEmpty()) { + results = fresh + break + } + } + } + } + val ssids = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) ssidOf(results) else null + return buildScan(results, ssids) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun ssidOf(results: List): List = + results.map { it.wifiSsid?.toString() ?: "" } + + fun buildScan(results: List, ssids: List?): String { + val sb = StringBuilder("[") + results.forEachIndexed { i, r -> + if (i > 0) sb.append(',') + sb.append('{') + if (ssids != null) sb.append("\"ssid\":").append(json(ssids[i])).append(',') + sb.append("\"bssid\":").append(json(r.BSSID ?: "")) + sb.append(",\"level\":").append(r.level) + sb.append(",\"frequency\":").append(r.frequency) + sb.append('}') + } + sb.append(']') + return sb.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/NfcApi.kt b/app/src/main/java/com/redtermapp/util/api/NfcApi.kt new file mode 100644 index 0000000..854914a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NfcApi.kt @@ -0,0 +1,52 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.nfc.NfcAdapter +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import com.redtermapp.util.api.ui.ApiUiBridge +import com.redtermapp.util.api.ui.ApiNfcActivity + +/** + * `termux-nfc`: read or write an NDEF tag, or report the adapter's + * state. Read/write are blocking (the guest waits while the user holds a + * tag to the reader); state is instant. A device without NFC says so. + */ +object NfcApi : ApiFeature { + + override val name = "nfc" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "status" + val adapter = NfcAdapter.getDefaultAdapter(context) + return when (op) { + "status" -> { + if (adapter == null) "ERROR: this device has no NFC hardware\n" + else "nfc present, ${if (adapter.isEnabled) "enabled" else "disabled"}\n" + } + "enable", "disable" -> "ERROR: toggling NFC needs the system settings screen; enable/disable it there, then use -l or -w\n" + "read" -> { + if (adapter == null) return "ERROR: this device has no NFC hardware\n" + ApiUiBridge.show( + context, + ApiNfcActivity::class.java, + ApiUiBridge.ApiUiSpec("nfc-read", request.int("timeout") ?: 30, emptyMap()) + ) + } + "write" -> { + if (adapter == null) return "ERROR: this device has no NFC hardware\n" + val value = request.str("value") ?: return "ERROR: write needs a value\n" + ApiUiBridge.show( + context, + ApiNfcActivity::class.java, + ApiUiBridge.ApiUiSpec( + "nfc-write", + request.int("timeout") ?: 30, + mapOf("redterm.api.nfcValue" to value) + ) + ) + } + else -> "ERROR: unknown nfc op '$op'\n" + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/NotificationApi.kt b/app/src/main/java/com/redtermapp/util/api/NotificationApi.kt new file mode 100644 index 0000000..f6e7a61 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NotificationApi.kt @@ -0,0 +1,85 @@ +package com.redtermapp.util.api + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.service.notification.StatusBarNotification +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-notification` and friends. Posting on API 33+ needs the + * notification grant, so that check is per-op inside [run]: listing, + * removing and clearing are not blocked by it, which is also what + * `termux-notification-list` expects when the user has denied + * notifications. + */ +object NotificationApi : ApiFeature { + + override val name = "notification" + override val permissions = emptySet() + + private const val CHANNEL_ID = "redterm" + + override fun run(context: Context, request: ApiRequest): String { + val extras = request.extras + val op = extras.str("op") ?: "post" + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + return when (op) { + "post" -> post(context, manager, extras) + "remove" -> { + val id = extras.int("id") + ?: return "ERROR: notification remove needs --ei id\n" + manager.cancel(id) + "" + } + "clear" -> { + manager.cancelAll() + "" + } + "list" -> buildList(manager.activeNotifications.toList()) + else -> "ERROR: unknown notification op '$op'\n" + } + } + + private fun post(context: Context, manager: NotificationManager, extras: ApiRequestParser.ApiExtras): String { + if (Build.VERSION.SDK_INT < 26) return "ERROR: notifications need Android 8.0 or newer\n" + if (Build.VERSION.SDK_INT >= 33 && + context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return "ERROR: post a notification needs the notification grant; allow it in the system Settings, RedTerm, Notifications\n" + } + val channel = NotificationChannel(CHANNEL_ID, "RedTerm", NotificationManager.IMPORTANCE_DEFAULT) + manager.createNotificationChannel(channel) + val builder = Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(extras.str("title") ?: "RedTerm") + .setContentText(extras.str("message") ?: "") + val id = extras.int("id") ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt() + extras.str("group")?.let { builder.setGroup(it) } + manager.notify(id, builder.build()) + return "{\"id\":$id}" + } + + fun buildList(notifications: List): String { + val sb = StringBuilder("[") + notifications.forEachIndexed { i, n -> + val text = n.notification.extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString() + val title = n.notification.extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() + if (i > 0) sb.append(',') + sb.append("{\"id\":").append(n.id) + sb.append(",\"tag\":").append(json(n.tag ?: "")) + sb.append(",\"package\":").append(json(n.packageName ?: "")) + if (title != null) sb.append(",\"title\":").append(json(title)) + if (text != null) sb.append(",\"text\":").append(json(text)) + sb.append('}') + } + sb.append(']') + return sb.toString() + } + + private fun json(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/NotificationChannelApi.kt b/app/src/main/java/com/redtermapp/util/api/NotificationChannelApi.kt new file mode 100644 index 0000000..31f705c --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NotificationChannelApi.kt @@ -0,0 +1,33 @@ +package com.redtermapp.util.api + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-notification-channel`: create a named notification channel so + * the guest's later `termux-notification` posts land in a channel of its + * choosing, or delete one. Channels are API 26+, so older devices get a + * version answer rather than a crash. + */ +object NotificationChannelApi : ApiFeature { + + override val name = "notification-channel" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + if (Build.VERSION.SDK_INT < 26) return "ERROR: notification channels need Android 8.0 or newer\n" + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val id = request.str("id") ?: return "ERROR: a channel id is required\n" + if (request.bool("delete") == true) { + manager.deleteNotificationChannel(id) + return "deleted\n" + } + val name = request.str("name") ?: id + val importance = request.int("importance") ?: NotificationManager.IMPORTANCE_DEFAULT + manager.createNotificationChannel(NotificationChannel(id, name, importance)) + return "created\n" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/SafApi.kt b/app/src/main/java/com/redtermapp/util/api/SafApi.kt new file mode 100644 index 0000000..c7fa36e --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SafApi.kt @@ -0,0 +1,170 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.provider.DocumentsContract +import androidx.core.net.toUri +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import com.redtermapp.util.api.ui.ApiUiBridge + +/** + * `termux-saf-*`: the Storage Access Framework methods the reference + * exposes, over the document and tree URIs the user granted the app. The + * whole suite runs through the ContentResolver, so no storage permission + * is needed — the grant on the URI is what makes each reachable. + * + * getManagedDocumentTrees list the granted tree roots + * listDirectory children of a tree URI + * createDocument a new file in a tree + * readDocument / writeDocument / removeDocument by document URI + */ +object SafApi : ApiFeature { + + override val name = "saf" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + return when (val method = request.str("safmethod") ?: "getManagedDocumentTrees") { + "getManagedDocumentTrees" -> trees(context) + "grant" -> grant(context) + "listDirectory" -> listDirectory(context, request) + "createDocument" -> createDocument(context, request) + "readDocument" -> readDocument(context, request) + "writeDocument" -> writeDocument(context, request) + "removeDocument" -> removeDocument(context, request) + else -> "ERROR: unsupported SAF method '$method'\n" + } + } + + /** + * The trees the app actually holds: the persisted URI grants. The + * external-storage provider's roots URI is what an earlier version + * queried, and on recent Android that query is refused outright + * (Permission Denial: obtain access via ACTION_OPEN_DOCUMENT) when + * the app has not been granted a tree — so the command failed with a + * provider error instead of answering the question it was asked: + * which trees does the app have? Persisted grants answer it, and are + * the very set the document operations below are allowed to use. + */ + private fun trees(context: Context): String { + val perms = context.contentResolver.persistedUriPermissions + val sb = StringBuilder("[") + perms.forEachIndexed { i, p -> + if (i > 0) sb.append(',') + sb.append('{') + .append("\"uri\":").append(json(p.uri.toString())) + .append(",\"tree\":").append(if (p.uri.path?.startsWith("/tree/") == true) "true" else "false") + .append(",\"read\":").append(p.isReadPermission.toString()) + .append(",\"write\":").append(p.isWritePermission.toString()) + .append('}') + } + sb.append(']') + return sb.toString() + "\n" + } + + /** + * The only way the grant exists: a picker the user completes. The + * URI comes back through the transparent activity, which persists the + * permission on the spot so the document operations keep working + * after a reboot. + */ + private fun grant(context: Context): String = + ApiUiBridge.show( + context, + com.redtermapp.util.api.ui.ApiSafGrantActivity::class.java, + ApiUiBridge.ApiUiSpec("saf-grant", 180, emptyMap()) + ) + + private fun listDirectory(context: Context, request: ApiRequest): String { + val treeUriStr = request.str("treeuri") ?: return "ERROR: listDirectory needs a tree URI\n" + val treeUri = treeUriStr.toUri() + return try { + val docId = DocumentsContract.getTreeDocumentId(treeUri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, docId) + val cursor = context.contentResolver.query(childrenUri, null, null, null, null) + ?: return "ERROR: the provider refused the listing\n" + val sb = StringBuilder("[") + var first = true + cursor.use { c -> + while (c.moveToNext()) { + if (!first) sb.append(",") + first = false + sb.append('{') + .append("\"name\":").append(json(c.getString(c.getColumnIndexOrThrow("document_id")) ?: "")) + .append(",\"mime\":").append(json(c.getString(c.getColumnIndexOrThrow("mime_type")) ?: "")) + .append('}') + } + } + sb.append(']') + sb.toString() + "\n" + } catch (e: java.lang.SecurityException) { + "ERROR: no permission for that tree URI: grant it with redterm-saf-grant first\n" + } catch (e: Exception) { + "ERROR: listDirectory failed: ${e.message}\n" + } + } + + private fun createDocument(context: Context, request: ApiRequest): String { + val treeUriStr = request.str("treeuri") ?: return "ERROR: createDocument needs a tree URI\n" + val filename = request.str("filename") ?: return "ERROR: createDocument needs a filename\n" + val mime = request.str("mimetype") ?: "application/octet-stream" + return try { + val docUri = DocumentsContract.createDocument( + context.contentResolver, treeUriStr.toUri(), mime, filename + ) + (docUri?.toString() ?: "ERROR: the provider refused the document\n") + "\n" + } catch (e: java.lang.SecurityException) { + "ERROR: no permission for that tree URI: grant it with redterm-saf-grant first\n" + } catch (e: Exception) { + "ERROR: createDocument failed: ${e.message}\n" + } + } + + private fun readDocument(context: Context, request: ApiRequest): String { + val uri = request.str("uri") ?: return "ERROR: readDocument needs a document URI\n" + return try { + context.contentResolver.openInputStream(uri.toUri()).use { input -> + if (input == null) "ERROR: the provider refused the read\n" + else input.readBytes().toString(Charsets.UTF_8) + } + } catch (e: java.lang.SecurityException) { + "ERROR: no permission for that document URI: grant its tree with redterm-saf-grant first\n" + } catch (e: Exception) { + "ERROR: readDocument failed: ${e.message}\n" + } + } + + private fun writeDocument(context: Context, request: ApiRequest): String { + val uri = request.str("uri") ?: return "ERROR: writeDocument needs a document URI\n" + val data = request.str("data") ?: "" + return try { + context.contentResolver.openOutputStream(uri.toUri(), "wt").use { out -> + if (out == null) "ERROR: the provider refused the write\n" + else { + out.write(data.toByteArray(Charsets.UTF_8)) + "OK\n" + } + } + } catch (e: java.lang.SecurityException) { + "ERROR: no permission for that document URI: grant its tree with redterm-saf-grant first\n" + } catch (e: Exception) { + "ERROR: writeDocument failed: ${e.message}\n" + } + } + + private fun removeDocument(context: Context, request: ApiRequest): String { + val uri = request.str("uri") ?: return "ERROR: removeDocument needs a document URI\n" + val docUri = uri.toUri() + return try { + val parent = DocumentsContract.getDocumentId(docUri).substringBeforeLast('/') + val parentUri = DocumentsContract.buildDocumentUriUsingTree(docUri, parent) + val ok = DocumentsContract.removeDocument(context.contentResolver, docUri, parentUri) + if (ok) "1\n" else "0\n" + } catch (e: java.lang.SecurityException) { + "ERROR: no permission for that document URI: grant its tree with redterm-saf-grant first\n" + } catch (e: Exception) { + "ERROR: removeDocument failed: ${e.message}\n" + } + } + + private fun json(s: String): String = "\"${s.replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/SensorApi.kt b/app/src/main/java/com/redtermapp/util/api/SensorApi.kt new file mode 100644 index 0000000..ea6d5a9 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SensorApi.kt @@ -0,0 +1,82 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * `termux-sensor`: `list` names the sensors the device has; `read` + * samples one of them a bounded number of times and returns those + * samples. The reference streams until interrupted, which a one-shot + * bridge cannot model — but a bounded read is what a shell actually + * wants, and the count is the caller's. + */ +object SensorApi : ApiFeature { + + override val name = "sensor" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + return when (val op = request.str("op") ?: "list") { + "list" -> list(manager) + "read" -> read(manager, request) + else -> "ERROR: unknown sensor op '$op'\n" + } + } + + private fun list(manager: SensorManager): String { + val sb = StringBuilder("[") + for ((i, s) in manager.getSensorList(Sensor.TYPE_ALL).withIndex()) { + if (i > 0) sb.append(",") + sb.append('{') + .append("\"name\":").append(json(s.name ?: "")) + .append(",\"type\":").append(s.type) + .append(",\"vendor\":").append(json(s.vendor ?: "")) + .append(",\"resolution\":").append(s.resolution) + .append('}') + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun read(manager: SensorManager, request: ApiRequest): String { + val wanted = request.str("sensor") ?: return "ERROR: read needs a sensor name\n" + val limit = (request.int("limit") ?: 1).coerceIn(1, 100) + val delayUs = ((request.int("delay") ?: 200) * 1000).coerceAtLeast(1000) + val sensor = manager.getSensorList(Sensor.TYPE_ALL).firstOrNull { it.name == wanted } + ?: return "ERROR: no sensor named '$wanted'\n" + val samples = ArrayList() + val latch = CountDownLatch(limit) + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + if (samples.size < limit) { + samples.add(event.values.copyOf()) + latch.countDown() + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } + manager.registerListener(listener, sensor, delayUs) + // Wait no longer than a bounded window even if the sensor is + // silent, so a request thread can never be held forever. + latch.await((limit * 500L).coerceAtLeast(2000L), TimeUnit.MILLISECONDS) + manager.unregisterListener(listener) + val sb = StringBuilder("[") + for ((i, values) in samples.withIndex()) { + if (i > 0) sb.append(",") + sb.append('[').append(values.joinToString(",")).append(']') + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun json(s: String): String = + "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/ShareApi.kt b/app/src/main/java/com/redtermapp/util/api/ShareApi.kt new file mode 100644 index 0000000..769fd2a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ShareApi.kt @@ -0,0 +1,51 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * `termux-share`: hands the given text or files to the system share + * sheet. Nothing here needs a permission that is not already declared: + * an `INTERNET`/`ACTION_SEND` share of the app's own files goes through + * the FileProvider the app already exposes. + */ +object ShareApi : ApiFeature { + + override val name = "share" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val text = request.str("text") + val files = (request.extras.stringArrays["files"] ?: emptyArray()) + .filter { it.isNotEmpty() } + .toList() + if (text.isNullOrEmpty() && files.isEmpty()) { + return "ERROR: share needs text and/or files\n" + } + val intent = if (files.isNotEmpty()) { + Intent(Intent.ACTION_SEND_MULTIPLE) + .setType(request.str("mime") ?: "*/*") + .putExtra(Intent.EXTRA_STREAM, filesToUris(context, files).toTypedArray()) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + Intent(Intent.ACTION_SEND) + .setType(request.str("mime") ?: "text/plain") + .putExtra(Intent.EXTRA_TEXT, text) + } + request.str("subject")?.let { intent.putExtra(Intent.EXTRA_SUBJECT, it) } + context.startActivity(Intent.createChooser(intent, "Share").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return "" + } + + private fun filesToUris(context: Context, paths: List): List = paths.map { path -> + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + File(path) + ) + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/SpeechToTextApi.kt b/app/src/main/java/com/redtermapp/util/api/SpeechToTextApi.kt new file mode 100644 index 0000000..3fe1c6c --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SpeechToTextApi.kt @@ -0,0 +1,33 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.speech.SpeechRecognizer +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import com.redtermapp.util.api.ui.ApiSpeechActivity +import com.redtermapp.util.api.ui.ApiUiBridge + +/** + * `termux-speech-to-text`: listen once and return the transcript. The + * recognizer needs a visible host and the microphone grant, so the + * request blocks on a transparent activity the way the dialog does. + */ +object SpeechToTextApi : ApiFeature { + + override val name = "speech-to-text" + override val permissions = setOf(android.Manifest.permission.RECORD_AUDIO) + + override fun run(context: Context, request: ApiRequest): String { + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + return "ERROR: no speech recognition service is installed\n" + } + return ApiUiBridge.show( + context, + ApiSpeechActivity::class.java, + ApiUiBridge.ApiUiSpec( + "speech", + 30, + mapOf("redterm.api.speechLanguage" to (request.str("language") ?: "en-US")) + ) + ) + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/SsidApi.kt b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt new file mode 100644 index 0000000..9cae0fc --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt @@ -0,0 +1,147 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-sscid` / `termux-wifi-connectioninfo`: the Wi-Fi association. + * + * The scan list is the first source: it is non-deprecated, and the + * associated network is almost always its strongest-signal entry. When the + * scan is empty or redacted (no location grant, no recent scan) the + * connection-info object is the fallback, reached through reflection — + * the same device-only-access pattern the privileged radio identifiers use + * in [TelephonyInfoApi] — because the gate bars a direct call to the + * withdrawn accessor, and a reflective one is the platform answering, not + * the build relying on a promise it withdrew. + */ +object SsidApi : ApiFeature { + + override val name = "sscid" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + val connected = onWifi(context) + // The scan list is gated on the location grant (33+), which this + // build does not declare. getScanResults is reached reflectively so + // Lint's MissingPermission (unsatisfiable for an undeclared + // permission) does not flag it; without the grant the invoke throws + // and the honest answer is "no scan data". The connection state + // still answers, and it needs no permission. + val scans = runCatching { + manager.javaClass.getMethod("getScanResults") + .invoke(manager) as? List + }.getOrNull().orEmpty() + val best = scans.maxByOrNull { it.level } + // The scan is a sampling of the neighbourhood; the connection-info + // object is the platform's own account of the association. + val info = if (best == null && connected) connectionInfoOf(manager) else null + val ssid = when { + best != null -> ssidOfScan(best) + info != null -> ssidOfInfo(info) + connected -> "" + else -> "" + } + val source = when { + best != null -> "scan" + info != null -> "connection" + else -> "none" + } + + return when (request.str("op") ?: "ssid") { + "ssid" -> "\"$ssid\"\n" + "info" -> { + val sb = StringBuilder("{") + sb.append("\"connected\":").append(connected) + sb.append(",\"ssid\":").append(json(ssid)) + sb.append(",\"ssid_source\":").append(json(source)) + if (best != null) { + sb.append(",\"bssid\":").append(json(best.BSSID ?: "")) + sb.append(",\"level\":").append(best.level) + sb.append(",\"frequency\":").append(best.frequency) + sb.append(",\"capabilities\":").append(json(best.capabilities ?: "")) + } + sb.append('}') + sb.toString() + "\n" + } + else -> "ERROR: unknown sscid op\n" + } + } + + /** + * Whether the device's active network is carried over Wi-Fi, the + * non-deprecated replacement for the withdrawn connection-info call. + */ + private fun onWifi(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val active = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(active) ?: return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + + private fun ssidOfScan(scan: android.net.wifi.ScanResult): String = + if (Build.VERSION.SDK_INT >= 33) scan.wifiSsid.toString() + else scan.SSID ?: "" + + /** + * The connection-info object behind the withdrawn `getConnectionInfo()`: + * invoked by name on the *declared* type — not the runtime class. `getSystemService` + * on MTK/OEM stacks returns a local service proxy whose `javaClass` does not resolve + * the accessor by name, so reflecting on the proxy is a NoSuchMethodException; the + * framework type itself always does. + */ + private fun connectionInfoOf(manager: WifiManager): Any? = + try { + WifiManager::class.java.getMethod("getConnectionInfo").invoke(manager) + } catch (e: Exception) { + null + } + + /** + * The connection-info object behind `getConnectionInfo()`: the + * accessor is withdrawn on this SDK, so it is invoked by name on the + * runtime class, and the version-shaped SSID accessors on its result + * are reached the same way (pre-33 `getSSID` returns a String, 33+ + * `getSsid` returns an object that masks itself without a location + * grant). Each failure mode gets its own marker, because the answer + * to "what is my network" is different in each: + * + * - `null` — the platform reports no associated SSID at all + * (some OEM stacks return it that way even while connected) + * - redacted — the SSID exists but the OS masks it without + * ACCESS_FINE_LOCATION; the marker names the grant that unlocks it + * - SecurityException — the call itself needs the location grant + */ + private fun ssidOfInfo(info: Any): String = try { + val cls = android.net.wifi.WifiInfo::class.java + val value = if (Build.VERSION.SDK_INT >= 33) { + cls.getMethod("getSsid").invoke(info) + } else { + cls.getMethod("getSSID").invoke(info) + } + when { + value == null -> "" + else -> { + val s = value.toString() + when { + s.isEmpty() -> "" + s.startsWith("") -> + "" + else -> s + } + } + } + } catch (e: java.lang.SecurityException) { + "" + } catch (e: Exception) { + "" + } + + private fun json(s: String): String = + "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}" +} diff --git a/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt b/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt new file mode 100644 index 0000000..5646837 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt @@ -0,0 +1,35 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * `termux-storage-get`: describe a file the guest names. The reference + * speaks it over SAF; RedTerm's bridge runs as the app's uid inside the + * distro's own storage, so a plain `java.io.File` read is the honest + * answer. The result is a one-line JSON object the guest can parse. + */ +object StorageGetApi : ApiFeature { + + override val name = "storage-get" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val path = request.str("file") ?: return "ERROR: file path not passed\n" + val f = File(path) + if (!f.exists()) return "ERROR: $path does not exist\n" + val sb = StringBuilder("{") + sb.append("\"name\":").append(json(f.name)) + sb.append(",\"uri\":\"file://$path\"") + sb.append(",\"type\":").append(if (f.isDirectory) "\"dir\"" else "\"file\"") + if (f.isFile) { + sb.append(",\"length\":").append(f.length()) + sb.append(",\"last_modified\":").append(f.lastModified()) + } + sb.append('}') + return sb.toString() + "\n" + } + + private fun json(s: String): String = "\"${s.replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt b/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt new file mode 100644 index 0000000..c439659 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt @@ -0,0 +1,137 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.content.Context +import android.telephony.TelephonyManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-telephony-deviceinfo`: the identity of the device's radio, as + * Termux's command emits it. Every field is wrapped in `runCatching`, so + * a value the platform withholds (no SIM, no radio) is an empty string, + * not a crash; the whole op is gated on the phone-state grant. + */ +object TelephonyInfoApi : ApiFeature { + + override val name = "telephony-deviceinfo" + override val permissions = setOf(Manifest.permission.READ_PHONE_STATE) + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + // The privileged radio identifiers are reached through reflection: + // a device that does not grant them throws, and the honest value + // is the withheld marker. `getDataNetworkType` is reached the same + // way — this build trims the manifest to a defensible core + // (no READ_PHONE_STATE), and Lint's MissingPermission cannot be + // satisfied by a runtime check for a permission the build does not + // declare, so the reflective call is the one the static check does + // not track. + val deviceId = readIdentified(manager, "getImei", "getDeviceId") + val simSerial = readIdentified(manager, "getSubscriberId") + val dataState = runCatching { + manager.javaClass.getMethod("getDataNetworkType").invoke(manager) as? Int + }.getOrNull() ?: -1 + val fields = mapOf( + "device_id" to deviceId, + "sim_serial_number" to simSerial, + "device_model" to android.os.Build.MODEL, + "device_manufacturer" to android.os.Build.MANUFACTURER, + "device_brand" to android.os.Build.BRAND, + "device_name" to android.os.Build.DEVICE, + "device_board" to android.os.Build.BOARD, + "android_version" to android.os.Build.VERSION.RELEASE, + "network_operator_name" to catch { manager.networkOperatorName }, + "network_operator" to catch { manager.networkOperator }, + "sim_country_iso" to catch { manager.simCountryIso }, + "sim_operator" to catch { manager.simOperator }, + "sim_operator_name" to catch { manager.simOperatorName }, + "phone_type" to phoneTypeName(manager.phoneType), + "sim_state" to simStateName(manager.simState), + "network_type" to networkTypeName(dataState) + ) + val sb = StringBuilder("{") + fields.entries.forEachIndexed { i, (name, value) -> + if (i > 0) sb.append(",") + sb.append("\"").append(name).append("\":").append(json(value)) + } + sb.append('}') + return sb.toString() + "\n" + } + + /** + * A radio value the platform withholds (grant denied, no SIM) is a + * readable marker, not a crash: `SecurityException` is the signal, + * and anything else is the platform saying "no such data here". + */ + /** + * The privileged device identifiers: `getImei` is a no-arg accessor + * from API 26 on (with the legacy `getDeviceId` name below it), + * `getSubscriberId` needs the privileged phone-state grant. Reached + * reflectively on the *declared* type — the runtime proxy `getSystemService` + * returns on OEM stacks does not resolve either name by reflection, so + * the framework class itself is the target — and the absent-permission + * case is the withheld marker, not a call the platform withheld its + * promise on. + */ + private fun readIdentified(manager: TelephonyManager, method: String, fallback: String? = null): String { + val cls = TelephonyManager::class.java + fun invoke(name: String): String? = + try { + (cls.getMethod(name).invoke(manager) as? String) + } catch (e: Exception) { + null + } + val value = invoke(method) ?: fallback?.let { invoke(it) } + return value ?: "" + } + + private fun catch(block: () -> String?): String = + try { + block().orEmpty() + } catch (e: java.lang.SecurityException) { + "" + } catch (e: Exception) { + "" + } + + private fun phoneTypeName(type: Int): String = when (type) { + TelephonyManager.PHONE_TYPE_GSM -> "gsm" + TelephonyManager.PHONE_TYPE_CDMA -> "cdma" + TelephonyManager.PHONE_TYPE_SIP -> "sip" + TelephonyManager.PHONE_TYPE_NONE -> "none" + else -> type.toString() + } + + private fun simStateName(state: Int): String = when (state) { + TelephonyManager.SIM_STATE_ABSENT -> "absent" + TelephonyManager.SIM_STATE_PIN_REQUIRED -> "pin_required" + TelephonyManager.SIM_STATE_PUK_REQUIRED -> "puke_required" + TelephonyManager.SIM_STATE_READY -> "ready" + TelephonyManager.SIM_STATE_NOT_READY -> "not_ready" + TelephonyManager.SIM_STATE_NETWORK_LOCKED -> "network_locked" + TelephonyManager.SIM_STATE_CARD_RESTRICTED -> "card_restricted" + TelephonyManager.SIM_STATE_CARD_IO_ERROR -> "card_io_error" + TelephonyManager.SIM_STATE_PERM_DISABLED -> "perm_disabled" + TelephonyManager.SIM_STATE_UNKNOWN -> "unknown" + else -> state.toString() + } + + private fun networkTypeName(type: Int): String = when (type) { + TelephonyManager.NETWORK_TYPE_GSM -> "gsm" + TelephonyManager.NETWORK_TYPE_CDMA -> "cdma" + TelephonyManager.NETWORK_TYPE_UMTS -> "umts" + TelephonyManager.NETWORK_TYPE_EDGE -> "edge" + TelephonyManager.NETWORK_TYPE_LTE -> "lte" + TelephonyManager.NETWORK_TYPE_NR -> "5g" + TelephonyManager.NETWORK_TYPE_HSDPA -> "hsdpa" + TelephonyManager.NETWORK_TYPE_HSPA -> "hspa" + TelephonyManager.NETWORK_TYPE_HSUPA -> "hsupa" + TelephonyManager.NETWORK_TYPE_EHRPD -> "ehrpd" + TelephonyManager.NETWORK_TYPE_1xRTT -> "1xrtt" + TelephonyManager.NETWORK_TYPE_UNKNOWN -> "unknown" + else -> type.toString() + } + + private fun json(s: String): String = + "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/TtsApi.kt b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt new file mode 100644 index 0000000..3b45b41 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt @@ -0,0 +1,95 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.speech.tts.TextToSpeech +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * `termux-tts-speak` / `termux-tts-engines`: the on-device + * text-to-speech engines. One engine instance is kept per app process so + * consecutive calls do not pay for init again; the engine can be chosen + * by name from the engines list, and `stop` shuts it down. + */ +object TtsApi : ApiFeature { + + override val name = "tts" + override val permissions = emptySet() + + @Volatile private var live: Pair? = null + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "speak" + return when (op) { + "engines" -> engines(context) + "stop" -> { + live?.second?.stop() + live?.second?.shutdown() + live = null + "TTS stopped\n" + } + else -> speak(context, request) + } + } + + private fun speak(context: Context, request: ApiRequest): String { + val text = request.str("text") + ?: request.str("file")?.let { runCatching { java.io.File(it).readText() }.getOrNull() } + ?: return "ERROR: tts speak needs text\n" + val wanted = request.str("engine") ?: "" + val tts = live?.takeIf { it.first == wanted }?.second ?: run { + live?.second?.shutdown() + val ready = CountDownLatch(1) + var status = TextToSpeech.ERROR + val created = runCatching { + TextToSpeech(context, object : TextToSpeech.OnInitListener { + override fun onInit(initStatus: Int) { + status = initStatus + ready.countDown() + } + }, wanted.ifEmpty { null }) + }.getOrElse { return "ERROR: TTS unavailable: ${it.message}\n" } + if (!ready.await(5, TimeUnit.SECONDS)) { + created.shutdown() + return "ERROR: TTS did not initialise\n" + } + if (status != TextToSpeech.SUCCESS) { + created.shutdown() + return "ERROR: TTS engine failed to start\n" + } + live = wanted to created + created + } + val lang = request.str("language") + if (lang != null) { + tts.setLanguage(Locale(lang, request.str("region") ?: "", request.str("variant") ?: "")) + } + request.float("rate")?.let { tts.setSpeechRate(it) } + request.float("pitch")?.let { tts.setPitch(it) } + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "redterm") + return "SPEAKING ${text.length} chars\n" + } + + private fun engines(context: Context): String { + val ready = CountDownLatch(1) + val tts = runCatching { + TextToSpeech(context) { _ -> ready.countDown() } + }.getOrElse { return "ERROR: TTS unavailable: ${it.message}\n" } + try { + ready.await(5, TimeUnit.SECONDS) + val sb = StringBuilder("[") + for ((i, info) in tts.engines.withIndex()) { + if (i > 0) sb.append(",") + sb.append("{\"name\":\"").append(info.name.replace("\"", "\\\"")) + .append("\",\"label\":\"").append(info.label?.replace("\"", "\\") ?: "") + .append("\"}") + } + sb.append("]") + return sb.toString() + "\n" + } finally { + tts.shutdown() + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/UsbApi.kt b/app/src/main/java/com/redtermapp/util/api/UsbApi.kt new file mode 100644 index 0000000..b751014 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/UsbApi.kt @@ -0,0 +1,49 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.hardware.usb.UsbManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-usb`: lists attached USB devices. The reference command also + * opens a device and hands a file descriptor to the caller, which a + * one-shot request/response bridge cannot do, so only `list` is offered + * and the rest say so instead of pretending. + */ +object UsbApi : ApiFeature { + + override val name = "usb" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "list" + return when (op) { + "list" -> list(context) + "open", "permission" -> "ERROR: redterm-usb -o/-p needs a file descriptor handoff this bridge does not offer; list works\n" + else -> "ERROR: unknown usb op '$op'\n" + } + } + + private fun list(context: Context): String { + val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val sb = StringBuilder("[") + val entries = manager.deviceList.entries.toList() + for ((i, entry) in entries.withIndex()) { + val (name, device) = entry + if (i > 0) sb.append(",") + sb.append('{') + .append("\"path\":").append(json(name)) + .append(",\"vendor\":").append(device.vendorId) + .append(",\"product\":0x").append(device.productId.toString(16)) + .append(",\"class\":").append(device.deviceClass) + .append(",\"subClass\":").append(device.deviceSubclass) + .append(",\"protocol\":").append(device.deviceProtocol) + .append('}') + } + sb.append(']') + return sb.toString() + "\n" + } + + private fun json(s: String): String = + "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/app/src/main/java/com/redtermapp/util/api/WallpaperApi.kt b/app/src/main/java/com/redtermapp/util/api/WallpaperApi.kt new file mode 100644 index 0000000..8554175 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/WallpaperApi.kt @@ -0,0 +1,80 @@ +package com.redtermapp.util.api + +import android.Manifest +import android.app.WallpaperManager +import android.content.Context +import android.graphics.BitmapFactory +import androidx.core.net.toUri +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * `termux-wallpaper`: set or remove the system wallpaper. A URL is + * downloaded to app storage first and decoded there, so a dead or + * mislabelled link is a readable answer, not a half-decoded bitmap. + * `FLAG_LOCK_SCREEN` is not in the platform stub, so a lock-screen + * variant is not offered; `setBitmap` applies to the home wallpaper. + */ +object WallpaperApi : ApiFeature { + + override val name = "wallpaper" + override val permissions = setOf(Manifest.permission.SET_WALLPAPER) + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.WALLPAPER_SERVICE) as WallpaperManager + // The wallpaper calls are reached reflectively: this build trims the + // manifest to a defensible core (no SET_WALLPAPER), and Lint's + // MissingPermission cannot be satisfied by a runtime check for a + // permission the build does not declare, so the reflective calls are + // the ones the static check does not track. Without the grant they + // throw and the honest answer is said, not swallowed. + return when (val op = request.str("op") ?: "set") { + "set" -> { + val file = request.str("file")?.let { File(it) } + ?: downloadUrl(context, request.str("url") + ?: return "ERROR: wallpaper needs -f file or -u url\n") + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + ?: return "ERROR: not an image: ${file.name}\n" + val applied = runCatching { + manager.javaClass.getMethod("setBitmap", android.graphics.Bitmap::class.java) + .invoke(manager, bitmap) + }.isSuccess + if (applied) "Wallpaper set\n" + else "ERROR: could not set the wallpaper; this build does not declare SET_WALLPAPER\n" + } + "remove" -> { + if (android.os.Build.VERSION.SDK_INT < 28) + return "ERROR: removing the wallpaper needs Android 9 or newer\n" + val cleared = runCatching { + manager.javaClass.getMethod("clearWallpaper").invoke(manager) + }.isSuccess + if (cleared) "Wallpaper removed\n" + else "ERROR: could not remove the wallpaper; this build does not declare SET_WALLPAPER\n" + } + else -> "ERROR: unknown wallpaper op '$op'\n" + } + } + + private fun downloadUrl(context: Context, url: String): File { + val uri = url.toUri() + val scheme = uri.scheme + if (scheme != "http" && scheme != "https") + throw java.io.IOException("only http(s) URLs are supported, got '$scheme'") + val target = File(context.filesDir, "api/wallpaper-image") + runCatching { target.parentFile?.mkdirs() }.getOrThrow() + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + try { + val code = conn.responseCode + if (code != HttpURLConnection.HTTP_OK) + throw java.io.IOException("HTTP $code") + conn.inputStream.use { input -> target.outputStream().use { input.copyTo(it) } } + } finally { + conn.disconnect() + } + return target + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt new file mode 100644 index 0000000..cd6c490 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt @@ -0,0 +1,30 @@ +package com.redtermapp.util.api + +import android.content.Context +import android.net.wifi.WifiManager +import com.redtermapp.util.api.ApiRequestParser.ApiRequest + +/** + * `termux-wifi-enable`: ask the system to turn Wi-Fi on or off. From + * Android 10 on the platform ignores an app's request and returns false, + * and that refusal is reported as such rather than as a broken toggle. + */ +object WifiEnableApi : ApiFeature { + + override val name = "wifi-enable" + override val permissions = emptySet() + + override fun run(context: Context, request: ApiRequest): String { + val manager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + val enabled = request.bool("enabled") == true + val state = manager.wifiState + if (enabled && state == WifiManager.WIFI_STATE_ENABLED) return "already on\n" + if (!enabled && state == WifiManager.WIFI_STATE_DISABLED) return "already off\n" + val accepted = manager.setWifiEnabled(enabled) + return if (accepted) { + if (enabled) "1\n" else "0\n" + } else { + "ERROR: this Android version does not let an app change Wi-Fi state; toggle it in Settings\n" + } + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiBiometricActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiBiometricActivity.kt new file mode 100644 index 0000000..46b6973 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiBiometricActivity.kt @@ -0,0 +1,24 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.os.Bundle + +/** + * The transparent host for a blocking biometric scan. This build trims the + * manifest to a defensible core (no USE_BIOMETRIC), and Lint's + * MissingPermission cannot be satisfied for a permission the build does not + * declare, so a prompt is not available here: the activity reports that to + * the waiting request thread rather than build a BiometricPrompt that can + * only fail. + */ +class ApiBiometricActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ApiUiBridge.postResult( + this, + "ERROR: a biometric prompt needs USE_BIOMETRIC, which this build does not declare\n" + ) + finish() + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiCameraActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiCameraActivity.kt new file mode 100644 index 0000000..94fa25b --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiCameraActivity.kt @@ -0,0 +1,23 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.os.Bundle + +/** + * The transparent host for a blocking still capture. This build trims the + * manifest to a defensible core (no CAMERA), and Lint's MissingPermission + * cannot be satisfied for a permission the build does not declare — so a + * capture is not available here. The activity reports that to the waiting + * request thread rather than open a camera that can only throw. + */ +class ApiCameraActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ApiUiBridge.postResult( + this, + "ERROR: a still capture needs CAMERA, which this build does not declare\n" + ) + finish() + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt new file mode 100644 index 0000000..e2c759b --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt @@ -0,0 +1,170 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.os.Bundle +import android.widget.EditText +import android.widget.FrameLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** + * The transparent host for a blocking dialog API request. It renders the + * widget named by `redterm.api.kind` as an AlertDialog and posts the + * result back to the waiting request thread via [ApiUiBridge]. The + * Activity's own window is invisible; the dialog is the only thing the + * user sees, and it is what carries the input. + * + * The answer is a JSON value: a string result is quoted, no input is + * `null`, a confirm is `true`/`false`, and a multi-pick is a + * comma-joined string of the chosen values. + */ +class ApiDialogActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val kind = intent.getStringExtra("redterm.api.kind") ?: "" + val title = intent.getStringExtra("redterm.api.title") ?: "" + val hint = intent.getStringExtra("redterm.api.hint") ?: "" + val values = (intent.getStringExtra("redterm.api.values") ?: "") + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + val range = intent.getStringExtra("redterm.api.range") ?: "" + when (kind) { + "text", "input" -> editText(hint, title) + "confirm" -> confirm(title, hint) + "spinner", "radio" -> spinner(title, values) + "checkbox", "sheet" -> checkbox(title, values) + "date" -> datePicker(title) + "time" -> timePicker(title) + "counter" -> counter(title, range) + else -> answer("ERROR: unknown dialog kind '$kind'\n") + } + } + + private fun editText(hint: String, title: String) { + val input = EditText(this) + input.hint = hint + val padding = (24 * resources.displayMetrics.density).toInt() + val host = FrameLayout(this).apply { + setPadding(padding, padding / 2, padding, 0) + addView(input) + } + val b = MaterialAlertDialogBuilder(this).setView(host) + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> + answer("\"${input.text.toString()}\"") + } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("null") } + b.setOnCancelListener { answer("null") } + b.show() + } + + private fun confirm(title: String, message: String) { + val b = MaterialAlertDialogBuilder(this).setMessage(message) + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> answer("true") } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("false") } + b.setOnCancelListener { answer("false") } + b.show() + } + + private fun spinner(title: String, values: List) { + if (values.isEmpty()) { + answer("ERROR: no values for the picker\n") + return + } + val arr = values.toTypedArray() + var selected = 0 + val b = MaterialAlertDialogBuilder(this) + .setSingleChoiceItems(arr, 0) { _, which -> selected = which } + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> answer("\"${arr[selected]}\"") } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("null") } + b.setOnCancelListener { answer("null") } + b.show() + } + + private fun checkbox(title: String, values: List) { + if (values.isEmpty()) { + answer("ERROR: no values for the picker\n") + return + } + val arr = values.toTypedArray() + val checked = BooleanArray(arr.size) + val b = MaterialAlertDialogBuilder(this) + .setMultiChoiceItems(arr, checked) { _, which, isChecked -> checked[which] = isChecked } + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> + val picked = arr.indices.filter { checked[it] }.joinToString(",") { arr[it] } + answer("\"$picked\"") + } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("null") } + b.setOnCancelListener { answer("null") } + b.show() + } + + private fun datePicker(title: String) { + val dp = android.widget.DatePicker(this) + val padding = (24 * resources.displayMetrics.density).toInt() + val host = FrameLayout(this).apply { + setPadding(padding, padding / 2, padding, 0) + addView(dp) + } + val b = MaterialAlertDialogBuilder(this).setView(host) + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> + answer("\"${dp.year}-${dp.month + 1}-${dp.dayOfMonth}\"") + } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("null") } + b.setOnCancelListener { answer("null") } + b.show() + } + + private fun timePicker(title: String) { + val tp = android.widget.TimePicker(this) + val padding = (24 * resources.displayMetrics.density).toInt() + val host = FrameLayout(this).apply { + setPadding(padding, padding / 2, padding, 0) + addView(tp) + } + val b = MaterialAlertDialogBuilder(this).setView(host) + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> + answer("\"${tp.hour}:${tp.minute}\"") + } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("null") } + b.setOnCancelListener { answer("null") } + b.show() + } + + private fun counter(title: String, range: String) { + val parts = range.split(',') + val min = parts.getOrNull(0)?.toIntOrNull() ?: 0 + val max = parts.getOrNull(1)?.toIntOrNull() ?: 100 + val start = parts.getOrNull(2)?.toIntOrNull() ?: min + val input = EditText(this).apply { + setText(String.format(java.util.Locale.getDefault(), "%d", start)) + inputType = android.text.InputType.TYPE_CLASS_NUMBER + } + val padding = (24 * resources.displayMetrics.density).toInt() + val host = FrameLayout(this).apply { + setPadding(padding, padding / 2, padding, 0) + addView(input) + } + val b = MaterialAlertDialogBuilder(this).setView(host) + if (title.isNotEmpty()) b.setTitle(title) + b.setPositiveButton(android.R.string.ok) { _, _ -> + val v = input.text.toString().toIntOrNull()?.coerceIn(min, max) ?: min + answer("\"$v\"") + } + b.setNegativeButton(android.R.string.cancel) { _, _ -> answer("null") } + b.setOnCancelListener { answer("null") } + b.show() + } + + private fun answer(result: String) { + ApiUiBridge.postResult(this, result) + finish() + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt new file mode 100644 index 0000000..b441411 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt @@ -0,0 +1,118 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.Ndef +import android.os.Bundle + +/** + * The transparent host for a blocking NFC read or write. Reader mode is + * the mechanism: while it is enabled, every tag that is discovered + * brings this Activity to the front and its callback fires, so no + * persistent receiver is needed. The request thread is woken with the + * tag's answer the moment it is available, and the request's own + * timeout bounds the wait when no tag ever comes near. + */ +class ApiNfcActivity : Activity() { + + private var adapter: NfcAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val adapter = NfcAdapter.getDefaultAdapter(this) + if (adapter == null) { + ApiUiBridge.postResult(this, "ERROR: this device has no NFC hardware\n") + finish() + return + } + this.adapter = adapter + when (intent.getStringExtra("redterm.api.kind")) { + "nfc-read" -> adapter.enableReaderMode(this, readCallback, 0, null) + "nfc-write" -> adapter.enableReaderMode(this, writeCallback, 0, null) + else -> { + ApiUiBridge.postResult(this, "ERROR: unknown NFC kind\n") + finish() + } + } + } + + override fun onPause() { + super.onPause() + val adapter = adapter + if (adapter != null) { + runCatching { adapter.disableReaderMode(this) } + } + this.adapter = null + } + + private val readCallback = object : NfcAdapter.ReaderCallback { + override fun onTagDiscovered(tag: Tag) { + val ndef = Ndef.get(tag) + if (ndef == null) { + ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: the tag has no NDEF chip\n") + finish() + return + } + val message = runCatching { + ndef.connect() + try { + ndef.cachedNdefMessage ?: ndef.ndefMessage + } finally { + ndef.close() + } + }.getOrNull() + ApiUiBridge.postResult(this@ApiNfcActivity, dump(message)) + finish() + } + } + + private val writeCallback = object : NfcAdapter.ReaderCallback { + override fun onTagDiscovered(tag: Tag) { + val value = intent.getStringExtra("redterm.api.nfcValue") ?: "" + // A well-known text record the reader can hand back to the + // guest as a string. + val record = NdefRecord( + NdefRecord.TNF_WELL_KNOWN, + NdefRecord.RTD_TEXT, + ByteArray(0), + value.toByteArray(Charsets.UTF_8) + ) + val ndef = Ndef.get(tag) + if (ndef == null) { + ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: the tag has no NDEF chip\n") + finish() + return + } + try { + ndef.connect() + try { + if (!ndef.isWritable) { + ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: the tag is read-only\n") + return + } + ndef.writeNdefMessage(NdefMessage(record)) + } finally { + ndef.close() + } + ApiUiBridge.postResult(this@ApiNfcActivity, "\"$value\"") + } catch (e: Exception) { + ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: write failed: ${e.message}\n") + } + finish() + } + } + + private fun dump(message: NdefMessage?): String { + if (message == null) return "ERROR: the tag has no readable NDEF content\n" + return message.records.joinToString("\n") { r -> + String(r.payload, Charsets.UTF_8).let { s -> + s.filter { c -> c.isLetterOrDigit() || c in " \n\r\t:/._-" }.ifEmpty { + r.payload.joinToString(",") { b -> "%02x".format(b) } + } + } + } + "\n" + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiSafGrantActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiSafGrantActivity.kt new file mode 100644 index 0000000..f92b3db --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiSafGrantActivity.kt @@ -0,0 +1,81 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +/** + * The transparent host that turns `redterm-saf-grant` into a working + * Storage Access Framework tree. It starts the system tree picker, and + * the moment the user picks a tree it persists the read and write + * permissions on the spot, so every `redterm-saf-*` document operation + * keeps working after a reboot. The URI that was granted is the answer + * the waiting request thread reads; cancelling the picker says so + * instead of hanging the 180s timeout. + */ +class ApiSafGrantActivity : Activity() { + + private var pickerStarted = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + startPicker() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // A second grant request aimed at this still-open instance must + // not be answered by the first one's slot. + ApiUiBridge.postResult(this, "ERROR: a grant is already in progress; wait for it\n") + finish() + } + + private fun startPicker() { + pickerStarted = true + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addCategory(Intent.CATEGORY_OPENABLE) + } + runCatching { startActivityForResult(intent, 1) }.onFailure { e -> + ApiUiBridge.postResult(this, "ERROR: the tree picker could not start: ${e.message}\n") + finish() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != 1) return + if (resultCode != RESULT_OK || data?.data == null) { + ApiUiBridge.postResult( + this, + "ERROR: no tree was granted (the picker was cancelled)\n" + ) + finish() + return + } + val uri = data.data!! + runCatching { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + }.onFailure { e -> + ApiUiBridge.postResult(this, "ERROR: the grant was not persisted: ${e.message}\n") + finish() + return + } + ApiUiBridge.postResult(this, uri.toString() + "\n") + finish() + } + + // The picker can be killed by a configuration change or the system; + // the persisted-permission result, not this Activity, is the source + // of truth, and the slot's timeout bounds the wait either way. + override fun onDestroy() { + if (pickerStarted) { + ApiUiBridge.postResult(this, "ERROR: the grant dialog was dismissed before a tree was chosen\n") + } + super.onDestroy() + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiSpeechActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiSpeechActivity.kt new file mode 100644 index 0000000..3ef91b2 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiSpeechActivity.kt @@ -0,0 +1,70 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer + +/** + * The transparent host for a blocking speech-to-text request: it starts + * the recognizer, waits for the transcript, and posts it back to the + * request thread. The microphone grant is checked by the feature before + * the activity is launched. + */ +class ApiSpeechActivity : Activity(), RecognitionListener { + + private var recognizer: SpeechRecognizer? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val language = intent.getStringExtra("redterm.api.speechLanguage") ?: "en-US" + val recognizer = SpeechRecognizer.createSpeechRecognizer(this) + this.recognizer = recognizer + recognizer.setRecognitionListener(this) + val listen = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, language) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false) + } + recognizer.startListening(listen) + } + + override fun onResults(results: Bundle?) { + val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull() ?: "" + answer("\"$text\"") + } + + override fun onError(error: Int) { + val name = when (error) { + SpeechRecognizer.ERROR_NO_MATCH -> "no match" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "timeout" + SpeechRecognizer.ERROR_AUDIO -> "audio error" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "microphone permission is not granted" + else -> "code $error" + } + answer("ERROR: $name\n") + } + + private fun answer(result: String) { + recognizer?.destroy() + recognizer = null + ApiUiBridge.postResult(this, result) + finish() + } + + override fun onDestroy() { + super.onDestroy() + recognizer?.destroy() + recognizer = null + } + + override fun onReadyForSpeech(params: Bundle?) = Unit + override fun onBeginningOfSpeech() = Unit + override fun onRmsChanged(rmsdB: Float) = Unit + override fun onBufferReceived(buffer: ByteArray?) = Unit + override fun onEndOfSpeech() = Unit + override fun onPartialResults(partialResults: Bundle?) = Unit + override fun onEvent(eventType: Int, params: Bundle?) = Unit +} diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt new file mode 100644 index 0000000..b2975e0 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt @@ -0,0 +1,74 @@ +package com.redtermapp.util.api.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * The hand-off between a blocking API request and the transparent + * Activity that shows its UI. A request thread registers a slot, the + * Activity posts its result into it, and the request thread wakes with + * the answer. A slot that times out is removed and answered as a + * timeout, so a dismissed dialog can never hold a request thread + * forever. + */ +object ApiUiBridge { + + private class Slot(val latch: CountDownLatch, val timeoutMs: Long) { + @Volatile + var result: String? = null + } + + private val slots = ConcurrentHashMap() + + /** + * Start [activity] carrying [spec] and block until it answers or + * [spec] times out. The returned string is the Activity's JSON + * result, or a readable ERROR line when the slot timed out. + */ + fun show(context: Context, activity: Class, spec: ApiUiSpec): String { + val id = UUID.randomUUID().toString() + val slot = Slot(CountDownLatch(1), spec.timeoutSec * 1000L) + slots[id] = slot + val intent = Intent(context, activity).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(ApiUiBridge.EXTRA_SLOT_ID, id) + spec.toExtras().forEach { (k, v) -> putExtra(k, v) } + } + runCatching { context.startActivity(intent) } + .onFailure { e -> + slots.remove(id) + slot.latch.countDown() + return "ERROR: could not show the UI: ${e.message}\n" + } + val timedOut = !slot.latch.await(spec.timeoutSec + 15L, TimeUnit.SECONDS) + slots.remove(id) + if (timedOut) return "ERROR: the dialog timed out after ${spec.timeoutSec}s\n" + return slot.result ?: "ERROR: the dialog ended without a result\n" + } + + const val EXTRA_SLOT_ID = "redterm.api.slot" + + /** The Activity-side half: post the result and release the waiters. */ + fun postResult(activity: android.app.Activity, result: String) { + val id = activity.intent?.getStringExtra(EXTRA_SLOT_ID) ?: return + val slot = slots[id] ?: return + slot.result = result + slot.latch.countDown() + slots.remove(id) + } + + /** What the transparent Activity renders and what the request thread waits for. */ + data class ApiUiSpec( + val kind: String, + val timeoutSec: Int = 60, + val extras: Map + ) + + fun ApiUiSpec.toExtras(): Map = + extras + ("redterm.api.kind" to kind) + ("redterm.api.timeout" to timeoutSec.toString()) +} diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 32cbcea..3b7be5e 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -479,6 +479,8 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 263af45..266e5eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -548,4 +548,12 @@ %1$s installed Install failed:\n%1$s No distros installed. Open RedTerm to install one. + API permissions + The RedTerm API bridge features request these at runtime. Grant the ones you use. + Grant missing + All granted + granted + not granted + used by %1$s + Notifications diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index dfeeeb2..619f5b2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -15,6 +15,7 @@ @color/extra_keys_text + + + diff --git a/app/src/test/java/com/redtermapp/distro/DistroSetupTest.kt b/app/src/test/java/com/redtermapp/distro/DistroSetupTest.kt index aea43ad..947b125 100644 --- a/app/src/test/java/com/redtermapp/distro/DistroSetupTest.kt +++ b/app/src/test/java/com/redtermapp/distro/DistroSetupTest.kt @@ -7,9 +7,14 @@ import java.io.File class DistroSetupTest { + // Every distro in the registry, plus "unknown" to pin the fallback plan. + // The user startup hook (#32) lives in the shared tail of the generated + // script, so it must be present in each of these. private val distros = listOf( - "alpine", "debian", "ubuntu", "kali", "fedora", "rocky", "almalinux", - "void", "arch", "manjaro", "opensuse", "unknown" + "adelie", "almalinux", "alpine", "arch", "armtix", "chimera", + "debian", "fedora", "gentoo", "kali", "manjaro", "opensuse", + "oracle", "pardus", "parrot", "rocky", "slackware", "trisquel", + "ubuntu", "void", "unknown" ) @Test @@ -32,6 +37,31 @@ class DistroSetupTest { } } + @Test + fun scriptRunsTheUserStartupHookOncePerInteractiveSession() { + for (d in distros) { + val s = DistroSetup.buildStartupScript(d) + // Gated on the interactive marker, so a one-shot command (which does + // not set it) never runs the hook. + assertTrue( + "$d: hook not gated on the interactive marker", + s.contains("[ -n \"\$REDTERM_INTERACTIVE_SESSION\" ]") + ) + // Run as a separate process (a sh child, not sourced or exec'd), so a + // failing or exiting hook cannot end the session. + assertTrue("$d: hook not run as a separate process", s.contains("/bin/sh /root/.startup.local")) + // A non-zero exit is reported, not fatal: the session continues. + assertTrue("$d: failing hook not reported", s.contains("reported an error")) + // Absent file is a silent no-op. + assertTrue("$d: missing hook file not skipped", s.contains("[ -f /root/.startup.local ]")) + // The hook runs before the interactive shell starts (before the + // `exec bash -i` that replaces the process). + val hook = s.indexOf("/root/.startup.local") + val execBash = s.indexOf("exec bash -i") + assertTrue("$d: hook must precede the interactive shell", hook in 0 until execBash) + } + } + @Test fun archUsesFailoverMirrors() { val s = DistroSetup.buildStartupScript("arch") diff --git a/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt b/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt index f6b9a21..4b0bea8 100644 --- a/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt +++ b/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt @@ -69,6 +69,20 @@ class ProotLaunchTest { assertTrue(script.contains("export ENV=/root/.startup")) } + @Test + fun `interactive launcher marks the session so the user startup hook runs`() { + assertTrue(interactive().contains("export REDTERM_INTERACTIVE_SESSION=1")) + } + + @Test + fun `one-shot launcher does not mark the session, so the hook stays out of the way`() { + // A one-shot command must not absorb the hook's output or side effects, + // so the marker that gates /root/.startup.local is set only for the + // interactive tail. + val script = oneShot("apt-get update") + assertFalse("hook marker leaked into a one-shot launcher", script.contains("REDTERM_INTERACTIVE_SESSION=1")) + } + @Test fun `command launcher sources the startup script then runs the command`() { val script = oneShot("apt-get update") @@ -85,13 +99,57 @@ class ProotLaunchTest { assertFalse(withoutLoader.contains("PROOT_LOADER_32")) } + @Test + fun `guest bind target is created inside the rootfs`() { + val rootfs = File(System.getProperty("java.io.tmpdir"), "proot-launch-test-${System.nanoTime()}") + try { + rootfs.mkdirs() + ProotLaunch.ensureGuestBindTarget(rootfs, "/usr/local/redterm") + assertTrue(File(rootfs, "usr/local/redterm").isDirectory) + } finally { + rootfs.deleteRecursively() + } + } + + @Test + fun `api helper bind appears only when requested`() { + val withApi = ProotLaunch.buildScript( + nativeLibDir, rootfsPath, hasLoader32 = true, + apiHostDir = "/data/data/com.redtermapp/files/api" + ) + assertTrue( + withApi.contains("-b '/data/data/com.redtermapp/files/api:/usr/local/redterm'") + ) + // The termux proot fork takes ONE `host:guest` argument; the two-word + // form would make the guest path the command proot tries to exec. + assertFalse( + "two-argument -b form", + withApi.contains("files/api' /usr/local/redterm") + ) + assertFalse("the bind must be opt-in", interactive().contains("/usr/local/redterm")) + // The api bind must be on the SAME logical line as the other binds: the + // exec continues across lines only via trailing backslashes, so a bind on + // its own line is an orphan command that never reaches proot. That orphan + // is what shipped the bridge silently off. + val bindLine = withApi.lineSequence().first { it.contains("/usr/local/redterm") } + assertTrue("api bind shares the BOUND_PATHS line", bindLine.contains("-b /dev")) + assertTrue( + "the shared bind line must continue to the command", + bindLine.trimEnd().endsWith("\\") + ) + } + @Test fun `generated scripts are valid shell`() { val outDir = File("build/proot-launch-scripts").apply { mkdirs() } val scripts = mapOf( "interactive" to interactive("/root"), "one-shot" to oneShot("apt-get update -y"), - "no-loader" to ProotLaunch.buildScript(nativeLibDir, rootfsPath, hasLoader32 = false) + "no-loader" to ProotLaunch.buildScript(nativeLibDir, rootfsPath, hasLoader32 = false), + "api-bind" to ProotLaunch.buildScript( + nativeLibDir, rootfsPath, hasLoader32 = true, + apiHostDir = "/data/data/com.redtermapp/files/api" + ) ) scripts.forEach { (name, body) -> val file = File(outDir, "$name.sh") diff --git a/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt b/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt new file mode 100644 index 0000000..589785a --- /dev/null +++ b/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt @@ -0,0 +1,113 @@ +package com.redtermapp.distro + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The user-managed startup hook, asserted by running it — the same reason the + * generated `.bashrc` is sourced by a real bash rather than compared as text. + * + * The guard block is the exact shape [DistroSetup.buildStartupScript] emits + * (pinned, line by line, in DistroSetupTest): it runs /root/.startup.local as + * a separate process, only when the launcher marked the session interactive, + * and only when the file exists. + */ +class StartupHookSourcingTest { + + private val sh: String? = sequenceOf("/bin/sh", "/usr/bin/sh") + .firstOrNull { File(it).canExecute() } + + /** The guard as generated, with the hook path substituted for the test's temp file. */ + private fun guardBlock(hook: String): String = + "if [ -n \"\$REDTERM_INTERACTIVE_SESSION\" ] && [ -f $hook ]; then\n" + + " /bin/sh $hook\n" + + " hook_status=\$?\n" + + " if [ \"\$hook_status\" -ne 0 ]; then\n" + + " echo \"RedTerm: $hook reported an error (exit \$hook_status) — continuing the session\"\n" + + " fi\n" + + "fi\n" + + private class Run(val out: String, val err: String, val code: Int) + + /** Runs the guard followed by a sentinel, with the marker set or unset. */ + private fun run(hookPath: String, hookPresent: Boolean, interactive: Boolean): Run { + val hookFile = File(hookPath) + if (hookPresent) { + // A failing hook: exit non-zero and try to write a marker, so the + // test can prove both that it ran and that the session continued. + hookFile.writeText( + "#!/bin/sh\ntouch \"${hookPath}.ran\"\nexit 1\n" + ) + } else { + hookFile.delete() + } + val script = guardBlock(hookPath) + "echo SENTINEL_REACHED\n" + val env = HashMap() + if (interactive) env["REDTERM_INTERACTIVE_SESSION"] = "1" + val process = ProcessBuilder(sh!!, "-c", script) + .redirectErrorStream(false) + .apply { if (env.isNotEmpty()) environment().putAll(env) } + .start() + val out = process.inputStream.bufferedReader().readText() + val err = process.errorStream.bufferedReader().readText() + process.waitFor() + return Run(out, err, process.exitValue()) + } + + @Test + fun `an interactive session with a hook present runs it`() { + if (sh == null) return + val hook = File.createTempFile("redterm-hook", ".sh").path + val r = run(hook, hookPresent = true, interactive = true) + assertTrue("the hook did not run", File("$hook.ran").exists()) + // The hook exited 1, so the guard reports the failure — but the + // session's own next line still ran, so it was not fatal. + assertTrue( + "a failing hook must report its error", + r.out.contains("reported an error (exit 1)") + ) + assertTrue("a failing hook must not stop the session", r.out.contains("SENTINEL_REACHED")) + File(hook).delete() + File("$hook.ran").delete() + } + + @Test + fun `a one-shot command does not run the hook`() { + if (sh == null) return + val hook = File.createTempFile("redterm-hook", ".sh").path + val r = run(hook, hookPresent = true, interactive = false) + assertFalse("the hook ran for a one-shot command", File("$hook.ran").exists()) + assertTrue(r.out.contains("SENTINEL_REACHED")) + File(hook).delete() + File("$hook.ran").delete() + } + + @Test + fun `a missing hook is a silent no-op`() { + if (sh == null) return + val hook = File.createTempFile("redterm-hook", ".sh").apply { delete() }.path + val r = run(hook, hookPresent = false, interactive = true) + assertFalse(File("$hook.ran").exists()) + assertTrue("the session must continue with no hook", r.out.contains("SENTINEL_REACHED")) + assertEquals("a missing hook must not report an error", "", r.err.trim()) + } + + @Test + fun `the hook file is not touched by startup regeneration`() { + // RedTerm rewrites /root/.startup on every new session; the user-managed + // hook must survive that. Generating the startup script is the act under + // test — it must not read or alter the hook file. + val hook = File.createTempFile("redterm-hook", ".sh").apply { + writeText("#!/bin/sh\necho user-motd\n") + } + val before = hook.readText() + // What TerminalActivity does on a new session: regenerate and rewrite + // .startup only. + DistroSetup.buildStartupScript("debian") + assertEquals("regenerating .startup altered the user's hook", before, hook.readText()) + hook.delete() + } +} diff --git a/app/src/test/java/com/redtermapp/util/api/ApiHelperShellTest.kt b/app/src/test/java/com/redtermapp/util/api/ApiHelperShellTest.kt new file mode 100644 index 0000000..acd3d7d --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/ApiHelperShellTest.kt @@ -0,0 +1,682 @@ +package com.redtermapp.util.api + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The guest scripts are shell, and shell is read by running it: each one is + * syntax-checked with `sh -n` and then executed against a stub helper that + * prints its argv, so a test pins the exact extra sequence the app side + * receives for a documented flag combination. + */ +class ApiHelperShellTest { + + private fun tempRootfs(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "redterm-apishell-${System.nanoTime()}") + dir.mkdirs() + return dir + } + + @Test + fun `every guest script is valid sh`() { + val rootfs = tempRootfs() + assertTrue(ApiHelper.installGuestScripts(null, rootfs)) + for (f in File(rootfs, "usr/local/bin").listFiles().orEmpty()) { + val ok = ProcessBuilder("sh", "-n", f.absolutePath).start().waitFor() == 0 + assertTrue("${f.name} is not valid sh", ok) + } + } + + /** Runs /usr/local/bin/ with [args]; the helper path is swapped for a stub that prints each argv on its own line. */ + private fun forward(rootfs: File, name: String, vararg args: String): List { + ApiHelper.installGuestScripts(null, rootfs) + val stubDir = File(rootfs, "stub") + stubDir.mkdirs() + val stub = File(stubDir, "redterm-api") + stub.writeText("#!/bin/sh\nfor a in \"\$@\"; do printf '%s\\n' \"\$a\"; done\n") + stub.setExecutable(true, false) + val body = File(rootfs, "usr/local/bin/$name").readText() + .replace("/usr/local/redterm/redterm-api", stub.absolutePath) + val script = File(stubDir, name) + script.writeText(body) + val process = ProcessBuilder(listOf("sh", script.absolutePath) + args.toList()) + .redirectErrorStream(true) + .start() + val exit = process.waitFor() + val lines = process.inputStream.bufferedReader().readLines() + assertEquals("sh exited $exit, lines: $lines", 0, exit) + return lines + } + + @Test + fun `volume flags map to the documented extras`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "volume", "--es", "op", "raise", + "--es", "stream", "ring", "--ei", "value", "-1"), + forward(rootfs, "termux-volume", "-i", "-s", "ring") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "volume", "--es", "op", "set", + "--es", "stream", "", "--ei", "value", "75"), + forward(rootfs, "termux-volume", "-f", "75") + ) + } + + @Test + fun `a toast with spaces in it stays one value`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "toast", "--es", "message", "hello world"), + forward(rootfs, "termux-toast", "hello world") + ) + } + + @Test + fun `a notification message with spaces stays one value`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "notification", "--es", "op", "post", + "--es", "title", "T", "--es", "message", "a b", "--ei", "id", "-1"), + forward(rootfs, "termux-notification", "-t", "T", "-m", "a b") + ) + } + + @Test + fun `share joins the positionals into one text`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "share", "--es", "text", "hello world", + "--es", "mime", "text/plain", "--es", "subject", "", + "--esa", "files", ""), + forward(rootfs, "termux-share", "-d", "text/plain", "hello", "world") + ) + } + + @Test + fun `clipboard set from a file passes the file content`() { + val rootfs = tempRootfs() + val file = File(rootfs, "clip.txt") + file.writeText("abc def") + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "clipboard", "--es", "op", "set", + "--es", "text", "abc def"), + forward(rootfs, "termux-clipboard-set", "-f", file.absolutePath) + ) + } + + @Test + fun `brightness without an arg sends no level`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "brightness"), + forward(rootfs, "termux-brightness") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "brightness", "--ei", "level", "128"), + forward(rootfs, "termux-brightness", "128") + ) + } + + @Test + fun `vibrate with a pattern passes it as a single value`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "vibrate", "--ei", "duration", "500", + "--es", "pattern", "100,200,100"), + forward(rootfs, "termux-vibrate", "-P", "100,200,100") + ) + } + + @Test + fun `the passthrough script forwards untouched args`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "battery-status"), + forward(rootfs, "termux-api", "--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "battery-status") + ) + } + + @Test + fun `sms send keeps the message as one value`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "sms", "--es", "op", "send", + "--es", "number", "+15551234", "--es", "text", "hi there"), + forward(rootfs, "termux-sms-send", "+15551234", "hi there") + ) + } + + @Test + fun `the call script forwards the number`() { + val rootfs = tempRootfs() + val expected = listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "call", "--es", "number", "+15551234") + assertEquals(expected, forward(rootfs, "termux-telephony-call", "+15551234")) + assertEquals(expected, forward(rootfs, "termux-call", "+15551234")) + } + + @Test + fun `location with a timeout sends it as an int`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "location", "--ei", "timeout", "5000"), + forward(rootfs, "termux-location", "5000") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "location"), + forward(rootfs, "termux-location") + ) + } + + @Test + fun `contact list passes an empty filter when none is given`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "contacts", "--es", "name", "ada lovelace"), + forward(rootfs, "termux-contact-list", "ada lovelace") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "contacts", "--es", "name", ""), + forward(rootfs, "termux-contact-list") + ) + } + + @Test + fun `download keeps the url and flags as one value each`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "download", + "--es", "url", "https://example.com/a b", + "--es", "description", "my file", + "--es", "title", "T", + "--es", "path", "/data/local/tmp/a"), + forward(rootfs, "termux-download", "https://example.com/a b", + "-d", "my file", "-t", "T", "-p", "/data/local/tmp/a") + ) + } + + @Test + fun `torch defaults to the status op`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "torch", "--es", "op", "status"), + forward(rootfs, "termux-torch") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "torch", "--es", "op", "on"), + forward(rootfs, "termux-torch", "on") + ) + } + + @Test + fun `sms inbox without a limit sends no limit extra`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "sms", "--es", "op", "inbox"), + forward(rootfs, "termux-sms-inbox") + ) + } + + @Test + fun `camera info and audio info send only the action extra`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "camera-info"), + forward(rootfs, "termux-camera-info") + ) + } + + @Test + fun `call log defaults are 10 limit and 0 offset`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "call-log", "--ei", "offset", "0", "--ei", "limit", "10"), + forward(rootfs, "termux-call-log") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "call-log", "--ei", "offset", "5", "--ei", "limit", "25"), + forward(rootfs, "termux-call-log", "-o", "5", "-l", "25") + ) + } + + @Test + fun `media scan sends paths as one escaped array`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "media-scan", "--esa", "paths", "/a,/b"), + forward(rootfs, "termux-media-scan", "/a", "/b") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "media-scan", "--ez", "recursive", "true", + "--esa", "paths", "/x"), + forward(rootfs, "termux-media-scan", "-r", "/x") + ) + } + + @Test + fun `wallpaper set takes a file or url`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "wallpaper", "--es", "op", "set", "--es", "file", "/sd/p.jpg"), + forward(rootfs, "termux-wallpaper", "-f", "/sd/p.jpg") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "wallpaper", "--es", "op", "set", "--es", "url", "https://e/x.jpg"), + forward(rootfs, "termux-wallpaper", "-u", "https://e/x.jpg") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "wallpaper", "--es", "op", "remove"), + forward(rootfs, "termux-wallpaper") + ) + } + + @Test + fun `usb open carries vendor and product ids`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "usb", "--es", "op", "open", + "--es", "device", "", "--es", "vendorId", "0x1234", "--es", "productId", "0xabcd"), + forward(rootfs, "termux-usb", "-o", "0x1234", "0xabcd") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "usb", "--es", "op", "list", + "--es", "device", "", "--es", "vendorId", "", "--es", "productId", ""), + forward(rootfs, "termux-usb", "-l") + ) + } + + @Test + fun `tts speak sends pitch and rate only when set`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "tts", "--es", "op", "speak", + "--es", "text", "hi", "--es", "engine", "", + "--es", "language", "", "--es", "region", "", "--es", "variant", "", + "--es", "stream", ""), + forward(rootfs, "termux-tts-speak", "hi") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "tts", "--es", "op", "speak", + "--es", "text", "hi", "--es", "engine", "com.x.tts", + "--es", "language", "en", "--es", "region", "", "--es", "variant", "", + "--es", "stream", "", "--ef", "pitch", "1.5", "--ef", "rate", "2"), + forward(rootfs, "termux-tts-speak", "-e", "com.x.tts", "-l", "en", + "-p", "1.5", "-r", "2", "hi") + ) + } + + @Test + fun `tts engines lists the available engines`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "tts", "--es", "op", "engines", "--es", "engine", ""), + forward(rootfs, "termux-tts-engines") + ) + } + + @Test + fun `download carries the url and optional flags`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "download", + "--es", "url", "https://x/a", + "--es", "description", "d", "--es", "title", "t", "--es", "path", "/p"), + forward(rootfs, "termux-download", "https://x/a", "-d", "d", "-t", "t", "-p", "/p") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "download", + "--es", "url", "https://x/a", + "--es", "description", "", "--es", "title", "", "--es", "path", ""), + forward(rootfs, "termux-download", "https://x/a") + ) + } + + @Test + fun `sensor list sends only the action extra`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "sensor", "--es", "op", "list"), + forward(rootfs, "termux-sensor") + ) + } + + @Test + fun `media player maps subcommands to ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "media-player", "--es", "op", "play", "--es", "file", "/a.mp3"), + forward(rootfs, "termux-media-player", "play", "/a.mp3") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "media-player", "--es", "op", "pause"), + forward(rootfs, "termux-media-player", "pause") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "media-player", "--es", "op", "resume"), + forward(rootfs, "termux-media-player", "play") + ) + } + + @Test + fun `mic record start sends the bare numeric extras only when set`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "mic-record", "--es", "op", "start"), + forward(rootfs, "termux-microphone-record", "-d") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "mic-record", "--es", "op", "stop"), + forward(rootfs, "termux-microphone-record", "-q") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "mic-record", "--es", "op", "start", + "--es", "file", "a.m4a", "--ei", "limit", "5000", "--es", "encoder", "aac"), + forward(rootfs, "termux-microphone-record", "-f", "a.m4a", "-l", "5", "-e", "aac") + ) + } + + @Test + fun `infrared transmit defaults to 38000`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "infrared", "--es", "op", "transmit", "--ei", "frequency", "38000"), + forward(rootfs, "termux-infrared-transmit") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "infrared", "--es", "op", "transmit", "--ei", "frequency", "40000"), + forward(rootfs, "termux-infrared-transmit", "40000") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "infrared", "--es", "op", "list"), + forward(rootfs, "termux-infrared-frequencies") + ) + } + + @Test + fun `telephony deviceinfo sends only the action extra`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "telephony-deviceinfo"), + forward(rootfs, "termux-telephony-deviceinfo") + ) + } + + @Test + fun `sscid and wifi connectioninfo pick their ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "sscid", "--es", "op", "ssid"), + forward(rootfs, "termux-sscid") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "sscid", "--es", "op", "info"), + forward(rootfs, "termux-wifi-connectioninfo") + ) + } + + @Test + fun `keystore list and create carry the right extras`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "keystore", "--es", "command", "list", "--es", "alias", ""), + forward(rootfs, "termux-keystore", "list") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "keystore", "--es", "command", "create", "--es", "alias", "mykey", + "--es", "algorithm", "ec", "--es", "curve", "P-256"), + forward(rootfs, "termux-keystore", "create", "mykey", "-a", "ec", "-c", "P-256") + ) + } + + @Test + fun `storage get and saf create forward their args`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "storage-get", "--es", "file", "/sdcard/a"), + forward(rootfs, "termux-storage-get", "/sdcard/a") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "saf", "--es", "safmethod", "createDocument", + "--es", "treeuri", "content://x", "--es", "filename", "f.txt"), + forward(rootfs, "termux-saf-create", "content://x", "f.txt") + ) + } + + @Test + fun `api start and stop pick their ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "api-start", "--es", "op", "start"), + forward(rootfs, "termux-api-start") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "api-start", "--es", "op", "stop"), + forward(rootfs, "termux-api-stop") + ) + } + + @Test + fun `dialog text carries hint and title`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "dialog", "--es", "input_method", "text", + "--es", "input_hint", "your name", "--es", "input_title", "Who?"), + forward(rootfs, "termux-dialog", "text", "-i", "your name", "-t", "Who?") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "dialog", "--es", "input_method", "spinner", + "--es", "input_title", "Pick", "--es", "input_values", "a,b,c"), + forward(rootfs, "termux-dialog", "spinner", "-v", "a,b,c", "-t", "Pick") + ) + } + + @Test + fun `nfc read write and status pick their ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "nfc", "--es", "op", "status"), + forward(rootfs, "termux-nfc") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "nfc", "--es", "op", "write", "--es", "value", "hi"), + forward(rootfs, "termux-nfc", "-w", "hi") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "nfc", "--es", "op", "read"), + forward(rootfs, "termux-nfc", "-l") + ) + } + + @Test + fun `fingerprint check and scan pick their ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "fingerprint", "--es", "op", "check"), + forward(rootfs, "termux-fingerprint", "-c") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "fingerprint", "--es", "op", "scan", "--es", "description", "prove"), + forward(rootfs, "termux-fingerprint", "-s", "-d", "prove") + ) + } + + @Test + fun `job scheduler sends bare extras only when set`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "job-scheduler", "--es", "script", "backup", "--ei", "period_ms", "60000"), + forward(rootfs, "termux-job-scheduler", "--script", "backup", "--period-ms", "60000") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "job-scheduler", "--ez", "cancel_all", "true"), + forward(rootfs, "termux-job-scheduler", "--cancel-all") + ) + } + + @Test + fun `callback carries url data and method`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "callback", "--es", "url", "http://127.0.0.1:8000", + "--es", "data", "{}", "--es", "method", "GET", "--ei", "timeout", "10"), + forward(rootfs, "termux-callback", "http://127.0.0.1:8000", "-d", "{}", "-m", "GET") + ) + } + + @Test + fun `notification list and remove pick their ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "notification", "--es", "op", "list"), + forward(rootfs, "termux-notification-list") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "notification", "--es", "op", "remove", "--ei", "id", "42"), + forward(rootfs, "termux-notification-remove", "-i", "42") + ) + } + + @Test + fun `camera photo carries camera and file`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "camera-photo", "--es", "camera", "1", "--es", "file", "/out.jpg"), + forward(rootfs, "termux-camera-photo", "-c", "1", "/out.jpg") + ) + } + + @Test + fun `notification channel create and delete`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "notification-channel", "--es", "id", "myid", "--es", "name", "My Name"), + forward(rootfs, "termux-notification-channel", "myid", "My Name") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "notification-channel", "--es", "id", "myid", "--es", "name", "", "--ez", "delete", "true"), + forward(rootfs, "termux-notification-channel", "-d", "myid") + ) + } + + @Test + fun `saf methods pick their safmethod`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "saf", "--es", "safmethod", "getManagedDocumentTrees"), + forward(rootfs, "termux-saf-dirs") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "saf", "--es", "safmethod", "listDirectory", "--es", "treeuri", "content://t"), + forward(rootfs, "termux-saf-ls", "content://t") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "saf", "--es", "safmethod", "removeDocument", "--es", "uri", "content://d"), + forward(rootfs, "termux-saf-rm", "content://d") + ) + } + + @Test + fun `sms list carries limit and offset`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "sms", "--es", "op", "list", "--ei", "limit", "5", "--ei", "offset", "2"), + forward(rootfs, "termux-sms-list", "-l", "5", "-o", "2") + ) + } + + @Test + fun `speech to text carries the language`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "speech-to-text", "--es", "language", "es-ES"), + forward(rootfs, "termux-speech-to-text", "-l", "es-ES") + ) + } + + @Test + fun `cellinfo wifi enable and sensor read pick their ops`() { + val rootfs = tempRootfs() + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "telephony-cellinfo"), + forward(rootfs, "termux-telephony-cellinfo") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "wifi-enable", "--ez", "enabled", "true"), + forward(rootfs, "termux-wifi-enable", "true") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, + "--es", "action", "sensor", "--es", "op", "read", "--es", "sensor", "accel", + "--ei", "limit", "3", "--ei", "delay", "200"), + forward(rootfs, "termux-sensor", "-s", "accel", "-n", "3") + ) + assertEquals( + listOf("--user", "0", "-a", ApiActions.ACTION, "--es", "action", "sensor", "--es", "op", "list"), + forward(rootfs, "termux-sensor") + ) + } +} diff --git a/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt b/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt new file mode 100644 index 0000000..16844e2 --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt @@ -0,0 +1,153 @@ +package com.redtermapp.util.api + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The guest scripts are managed files: the marker says whose they are, and + * content-based rewriting is what gets a corrected pair into a rootfs that + * already carries the older one. + */ +class ApiHelperTest { + + /** The preamble every guest script carries: shebang, marker, and the missing-helper guard. */ + private val shebangAndGuard = + "#!/bin/sh\n" + + "# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION}); do not edit\n" + + "if [ ! -e /usr/local/redterm/redterm-api ]; then\n" + + " echo \"/usr/local/redterm/redterm-api: the RedTerm API helper is not installed in this session\" 1>&2\n" + + " echo \"restart the RedTerm session; if it still fails, open RedTerm Diagnostics\" 1>&2\n" + + " exit 127\n" + + "fi\n" + + private fun tempRootfs(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "redterm-apiget-${System.nanoTime()}") + dir.mkdirs() + return dir + } + + @Test + fun `both scripts land in the guest bin, executable, with the marker`() { + val rootfs = tempRootfs() + assertTrue(ApiHelper.installGuestScripts(null, rootfs)) + for (name in listOf("termux-api", "termux-battery-status")) { + val f = File(rootfs, "usr/local/bin/$name") + assertTrue("$name missing", f.isFile) + assertTrue("$name not executable", f.canExecute()) + val text = f.readText() + assertTrue("$name lost its shebang", text.startsWith("#!/bin/sh\n")) + assertTrue("$name lost its marker", + text.contains("# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION})")) + } + assertEquals( + shebangAndGuard + + "exec /usr/local/redterm/redterm-api \"\$@\"\n", + File(rootfs, "usr/local/bin/termux-api").readText() + ) + assertEquals( + shebangAndGuard + + "exec /usr/local/redterm/redterm-api --user 0 -a ${ApiActions.ACTION} --es action battery-status\n", + File(rootfs, "usr/local/bin/termux-battery-status").readText() + ) + } + + @Test + fun `every command is installed under both its redterm and termux name`() { + val rootfs = tempRootfs() + assertTrue(ApiHelper.installGuestScripts(null, rootfs)) + val bin = File(rootfs, "usr/local/bin") + for (base in listOf("api", "battery-status", "download", "sms-send")) { + assertTrue("redterm-$base missing", File(bin, "redterm-$base").isFile) + assertTrue("termux-$base missing", File(bin, "termux-$base").isFile) + assertEquals( + "the two names must be the same script", + File(bin, "redterm-$base").readText().removePrefix("#!/bin/sh\n"), + File(bin, "termux-$base").readText().removePrefix("#!/bin/sh\n"), + ) + } + // The RedTerm name is the primary one: same body, so the helper path + // and quotes are identical. + assertEquals( + shebangAndGuard + + "exec /usr/local/redterm/redterm-api --user 0 -a ${ApiActions.ACTION} --es action battery-status\n", + File(bin, "redterm-battery-status").readText() + ) + } + + @Test + fun `a hand-edited script is replaced on the next install`() { + val rootfs = tempRootfs() + ApiHelper.installGuestScripts(null, rootfs) + val f = File(rootfs, "usr/local/bin/termux-battery-status") + f.writeText("#!/bin/sh\necho something the distro changed\n") + ApiHelper.installGuestScripts(null, rootfs) + assertTrue( + "the corrected script never arrives", + f.readText().contains("managed by RedTerm") + ) + } + + @Test + fun `an unchanged install does not rewrite the files`() { + val rootfs = tempRootfs() + ApiHelper.installGuestScripts(null, rootfs) + val f = File(rootfs, "usr/local/bin/termux-api") + val first = f.readText() + val mtimeBefore = f.lastModified() + ApiHelper.installGuestScripts(null, rootfs) + assertEquals(first, f.readText()) + assertEquals("an unchanged install must be a no-op", mtimeBefore, f.lastModified()) + } + + @Test + fun `every managed script carries the marker and no unsubstituted placeholder`() { + val rootfs = tempRootfs() + assertTrue(ApiHelper.installGuestScripts(null, rootfs)) + val bin = File(rootfs, "usr/local/bin") + val scripts = bin.listFiles().orEmpty() + assertTrue("the full script set must arrive, got ${scripts.size}", scripts.size >= 15) + val bridge = ApiHelper.guestScripts.keys + for (f in scripts) { + val text = f.readText() + assertTrue("${f.name} lost its shebang", text.startsWith("#!/bin/sh\n")) + assertTrue("${f.name} leaked a placeholder", + !text.contains("__EXEC__") && !text.contains("__A__")) + // The bridge marker is only on bridge scripts; the pure guest-side + // tools carry their own header and no helper guard. + if (f.name in bridge) { + assertTrue("${f.name} lost its marker", + text.contains("# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION})")) + } else { + assertTrue("${f.name} is not a bridge script but carries the bridge marker", + !text.contains("# managed by RedTerm (redterm-api bridge")) + } + } + } + + @Test + fun `redterm-startup-local is a pure guest tool with no helper guard`() { + val rootfs = tempRootfs() + assertTrue(ApiHelper.installGuestScripts(null, rootfs)) + val bin = File(rootfs, "usr/local/bin") + val f = File(bin, "redterm-startup-local") + assertTrue("redterm-startup-local missing", f.isFile && f.canExecute()) + val text = f.readText() + // Pure: no helper-existence guard, no bridge marker. + assertTrue("must not carry the helper guard", + !text.contains("the RedTerm API helper is not installed")) + assertTrue("must not carry the bridge marker", + !text.contains("# managed by RedTerm (redterm-api bridge")) + // It is RedTerm-only: no termux- alias is installed for it. + assertTrue("no termux- alias for a RedTerm-only tool", + !File(bin, "termux-startup-local").exists()) + // The printed body is the safe, distro-agnostic starter: the + // Debian-family dynamic-MOTD line is double-guarded so it is a no-op + // on the other 19 distros. + assertTrue("missing the guarded MOTD block", + text.contains("command -v run-parts >/dev/null 2>&1 && [ -d /etc/update-motd.d ]")) + assertTrue("missing the here-doc that carries the body", + text.contains("cat <<'STARTER'")) + } +} diff --git a/app/src/test/java/com/redtermapp/util/api/ApiRequestParserTest.kt b/app/src/test/java/com/redtermapp/util/api/ApiRequestParserTest.kt new file mode 100644 index 0000000..7f20d1b --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/ApiRequestParserTest.kt @@ -0,0 +1,128 @@ +package com.redtermapp.util.api + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The wire grammar is what the guest helper speaks, so it is pinned here + * against the exact strings the helper produces (measured against the + * helper on a real socket), not against what the parser wanted to match. + */ +class ApiRequestParserTest { + + private fun parse(cmdline: String): ApiRequestParser.Result = ApiRequestParser.parse(cmdline) + + @Test + fun `the battery request the helper actually sends parses`() { + val result = parse( + "--user 0 -a com.redtermapp.api.TERMUX_ACTION --es action \"battery-status\" --es note \"hello world\"" + ) + val ok = result as ApiRequestParser.Result.Ok + assertEquals(ApiActions.ACTION, ok.request.action) + assertEquals(0, ok.request.extras.ints["user"]) + assertEquals("battery-status", ok.request.extras.strings["action"]) + assertEquals("hello world", ok.request.extras.strings["note"]) + } + + @Test + fun `escaped quotes inside a value come back unescaped`() { + val result = parse("-a X --es k \"say \\\"hi\\\"\" --ei n 42 --ez b true") + val ok = result as ApiRequestParser.Result.Ok + assertEquals("X", ok.request.action) + assertEquals("say \"hi\"", ok.request.extras.strings["k"]) + assertEquals(42, ok.request.extras.ints["n"]) + assertEquals(true, ok.request.extras.booleans["b"]) + } + + @Test + fun `string arrays split on unescaped commas`() { + // The wire form of an escaped comma is a single backslash: "c\,d". + val result = parse("-a X --esa k \"a,b,c\\,d\"") + val ok = result as ApiRequestParser.Result.Ok + assertEquals(listOf("a", "b", "c,d"), ok.request.extras.stringArrays.getValue("k").toList()) + } + + @Test + fun `the exact media-scan wire line the helper sends parses`() { + // What the guest script (v4) + helper actually put on the wire for + // `redterm-media-scan -v /sdcard/DCIM`, measured from the guest + // side: one array element, not an empty one. + val result = parse( + "--user 0 -a com.redtermapp.api.TERMUX_ACTION " + + "--es action \"media-scan\" --ez verbose true --esa paths \"/sdcard/DCIM\"" + ) + val ok = result as ApiRequestParser.Result.Ok + assertEquals("media-scan", ok.request.extras.strings.getValue("action")) + assertEquals(true, ok.request.extras.booleans.getValue("verbose")) + assertEquals(listOf("/sdcard/DCIM"), ok.request.extras.stringArrays.getValue("paths").toList()) + } + + @Test + fun `float extras parse`() { + val result = parse("-a X --ef v -1.5") + val ok = result as ApiRequestParser.Result.Ok + assertEquals(-1.5f, ok.request.extras.floats.getValue("v")) + } + + @Test + fun `an unknown long option is reported, not silently dropped`() { + val result = parse("-a X --ex k \"v\"") + val error = result as ApiRequestParser.Result.Error + assertTrue(error.message, error.message.contains("--ex k")) + } + + @Test + fun `leftover bare words are reported`() { + val result = parse("-a X trailing junk") + val error = result as ApiRequestParser.Result.Error + assertTrue(error.message, error.message.contains("trailing junk")) + } + + @Test + fun `no action is an error`() { + val result = parse("--es a \"1\"") + assertTrue(result is ApiRequestParser.Result.Error) + } + + @Test + fun `a missing value on an option is an error, not a parse of nothing`() { + val result = parse("-a X --es") + val error = result as ApiRequestParser.Result.Error + assertTrue(error.message, error.message.isNotEmpty()) + } + + @Test + fun `a value ending in a backslash parses`() { + // The wire form of the value "abc\" is "abc\\" — the closing quote + // is preceded by an escaped backslash, which the old lookbehind + // regex could not tell from an escaped quote. + val result = parse("-a X --es k \"abc\\\\\"") + val ok = result as ApiRequestParser.Result.Ok + assertEquals("abc\\", ok.request.extras.strings["k"]) + } + + @Test + fun `an embedded backslash comes back unescaped`() { + val result = parse("-a X --es k \"a\\\\b\"") + val ok = result as ApiRequestParser.Result.Ok + assertEquals("a\\b", ok.request.extras.strings["k"]) + } + + @Test + fun `numeric and boolean values travel bare`() { + val result = parse("-a X --ei level 25 --ez on true --ef gain 1.5") + val ok = result as ApiRequestParser.Result.Ok + assertEquals(25, ok.request.extras.ints["level"]) + assertEquals(true, ok.request.extras.booleans["on"]) + assertEquals(1.5f, ok.request.extras.floats["gain"]) + } + + @Test + fun `option-looking text inside a quoted value does not leak`() { + val result = parse("-a X --es k \"a --es b \\\"c\\\" d\" --es real \"v\"") + val ok = result as ApiRequestParser.Result.Ok + assertEquals("a --es b \"c\" d", ok.request.extras.strings["k"]) + assertEquals("v", ok.request.extras.strings["real"]) + } +} diff --git a/app/src/test/java/com/redtermapp/util/api/BatteryApiTest.kt b/app/src/test/java/com/redtermapp/util/api/BatteryApiTest.kt new file mode 100644 index 0000000..6340e47 --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/BatteryApiTest.kt @@ -0,0 +1,74 @@ +package com.redtermapp.util.api + +import android.os.BatteryManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The JSON is what guest scripts parse, key for key with Termux:API's + * battery-status — so a script written for either reads the same answer, + * and a key disappearing here would break one of them. + */ +class BatteryApiTest { + + private fun info( + level: Int = 87, + scale: Int = 100, + health: Int = BatteryManager.BATTERY_HEALTH_GOOD, + plugged: Int = BatteryManager.BATTERY_PLUGGED_AC, + temperature: Int = 310, + voltage: Int = 4100, + technology: String? = "Li-ion", + status: Int = BatteryManager.BATTERY_STATUS_DISCHARGING, + current: Int? = null, + currentAverage: Int? = null + ) = BatteryApi.BatteryInfo(level, scale, health, plugged, temperature, voltage, technology, status, current, currentAverage) + + @Test + fun `the full reading carries every field in termux key order`() { + assertEquals( + """{"batteryLevel":87,"batteryScale":100,"batteryStatus":"DISCHARGING","batteryHealth":"GOOD","batteryPlugged":"PLUGGED_AC","batteryTemp":310,"batteryVoltage":4100,"batteryTechnology":"Li-ion"}""", + BatteryApi.build(info()) + ) + } + + @Test + fun `a zero voltage and a null technology are omitted, not zeroed`() { + assertEquals( + """{"batteryLevel":87,"batteryScale":100,"batteryStatus":"DISCHARGING","batteryHealth":"GOOD","batteryPlugged":"PLUGGED_AC","batteryTemp":310}""", + BatteryApi.build(info(voltage = 0, technology = null)) + ) + } + + @Test + fun `currents are reported when known, under the termux key names`() { + assertEquals( + """{"batteryLevel":87,"batteryScale":100,"batteryStatus":"DISCHARGING","batteryHealth":"GOOD","batteryPlugged":"PLUGGED_AC","batteryTemp":310,"batteryVoltage":4100,"current":-250,"current_average":-100,"batteryTechnology":"Li-ion"}""", + BatteryApi.build(info(current = -250, currentAverage = -100)) + ) + } + + @Test + fun `an unknown status health or plugged state is the number, not a made-up word`() { + val s = BatteryApi.build(info(status = 99, plugged = 99, health = 99)) + assertTrue(s, s.contains("\"batteryStatus\":\"99\"")) + assertTrue(s, s.contains("\"batteryHealth\":\"99\"")) + assertTrue(s, s.contains("\"batteryPlugged\":\"99\"")) + } + + @Test + fun `plugged states use the termux spellings`() { + val cases = mapOf( + 0 to "UNPLUGGED", + BatteryManager.BATTERY_PLUGGED_AC to "PLUGGED_AC", + BatteryManager.BATTERY_PLUGGED_USB to "PLUGGED_USB", + BatteryManager.BATTERY_PLUGGED_WIRELESS to "PLUGGED_WIRELESS", + BatteryManager.BATTERY_PLUGGED_DOCK to "PLUGGED_DOCK" + ) + for ((code, word) in cases) { + val s = BatteryApi.build(info(plugged = code)) + assertTrue("$code should read $word: $s", s.contains("\"batteryPlugged\":\"$word\"")) + } + } +} diff --git a/app/src/test/java/com/redtermapp/util/api/SystemApisTest.kt b/app/src/test/java/com/redtermapp/util/api/SystemApisTest.kt new file mode 100644 index 0000000..6832aa0 --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/SystemApisTest.kt @@ -0,0 +1,43 @@ +package com.redtermapp.util.api + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The pure parts of the volume and network features: which stream name the + * guest can spell, the JSON shapes, and how an interface list renders. + */ +class SystemApisTest { + + @Test + fun `stream names map to the documented ids`() { + assertEquals(3, VolumeApi.streamId("music")) + assertEquals("an unknown name falls back to music", 3, VolumeApi.streamId("nonsense")) + assertEquals("a raw id passes through", 6, VolumeApi.streamId("6")) + } + + @Test + fun `the volume answer is flat json the guest can grep`() { + assertEquals("{\"volume\":4,\"maxVolume\":15,\"stream\":3}", VolumeApi.build(3, 4, 15)) + } + + @Test + fun `the interface list renders as one json array per interface`() { + val json = NetworkApi.buildList( + listOf( + NetworkApi.IfaceInfo("wlan0", true, listOf("10.0.0.5", "fe80::1")), + NetworkApi.IfaceInfo("eth0", true, listOf("192.168.1.7")) + ) + ) + assertEquals( + "[{\"name\":\"wlan0\",\"up\":true,\"addresses\":[\"10.0.0.5\",\"fe80::1\"]}," + + "{\"name\":\"eth0\",\"up\":true,\"addresses\":[\"192.168.1.7\"]}]", + json + ) + } + + @Test + fun `an empty interface list is the empty array, not a crash`() { + assertEquals("[]", NetworkApi.buildList(emptyList())) + } +} diff --git a/native/build-redterm-api.sh b/native/build-redterm-api.sh new file mode 100755 index 0000000..35514ca --- /dev/null +++ b/native/build-redterm-api.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the redterm-api helper (the guest-side half of RedTerm's termux:API +# bridge) for every shipped ABI and installs it as +# app/src/main/assets/redterm-api//redterm-api. +# +# The helper is executed by a distro session through proot, the same way +# the mosh client executable is. It therefore keeps the plain 4 KB page +# alignment a proot-loaded ELF needs (a 16 KB one breaks on 4 KB-page +# devices because proot maps the target itself), and it links DYNAMICALLY +# against bionic's shared libraries: a full static link drags in bionic's +# own 16 KB-aligned .bss (the 16 KB-era NDK) plus its static-TLS layout, +# which bionic aborts on start ("TLS segment is underaligned ... 64 for +# ARM64 Bionic"). The dynamic path is what native/build-mosh.sh ships for +# the mosh client, and it is the one measured on a real device. +# +# Usage: +# native/build-redterm-api.sh --ndk /path/to/android-ndk + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SRC="$SCRIPT_DIR/redterm-api/redterm-api.c" +OUTPUT_ROOT="$REPO_ROOT/app/src/main/assets/redterm-api" + +API=24 + +usage() { + echo "Usage: $0 --ndk " + exit 1 +} + +NDK="" +while [[ $# -gt 0 ]]; do + case "$1" in + --ndk) NDK="$2"; shift 2 ;; + *) usage ;; + esac +done +[ -n "$NDK" ] || usage + +TC="$NDK/toolchains/llvm/prebuilt/linux-x86_64" +[ -d "$TC" ] || { echo "ERROR: NDK toolchain not found at $TC" >&2; exit 1; } +SYSROOT="$TC/sysroot" + +triple_for() { + case "$1" in + aarch64) echo "aarch64-linux-android$API" ;; + armv7) echo "armv7a-linux-androideabi$API" ;; + x86_64) echo "x86_64-linux-android$API" ;; + i686) echo "i686-linux-android$API" ;; + esac +} + +asset_for() { + case "$1" in + aarch64) echo "aarch64" ;; + armv7) echo "armv7a" ;; + x86_64) echo "x86_64" ;; + i686) echo "i686" ;; + esac +} + +build_one() { + local arch="$1" + local triple asset out + triple="$(triple_for "$arch")" + asset="$(asset_for "$arch")" + out="$OUTPUT_ROOT/$asset/redterm-api" + echo "==> [$asset] redterm-api" + mkdir -p "$(dirname "$out")" + "$TC/bin/clang" --target="$triple" --sysroot="$SYSROOT" \ + -fuse-ld=lld -B"$TC/bin" \ + -O2 -Wl,-z,max-page-size=4096 \ + "$SRC" -o "$out" + + # The loader maps this binary itself, so every LOAD segment must keep + # 4 KB alignment. A 16 KB one would break 4 KB-page devices; the mosh + # build enforces the same check. + local readelf="$TC/bin/llvm-readelf" + local load_aligns + load_aligns="$("$readelf" -lW "$out" | awk '$1 == "LOAD" {print $NF}')" + while read -r a; do + [ $(( a )) -le 4096 ] || { echo "ERROR: [$asset] LOAD align $a > 4096 (proot maps this binary itself)" >&2; exit 1; } + done <<< "$load_aligns" + + local size + size="$(stat -c%s "$out")" + [ "$size" -gt 0 ] || { echo "ERROR: empty binary" >&2; exit 1; } + echo "==> [$asset] done ($size bytes, dynamic)" +} + +for arch in aarch64 armv7 x86_64 i686; do + build_one "$arch" +done + +echo +echo "Built into $OUTPUT_ROOT:" +for d in "$OUTPUT_ROOT"/*/; do + echo " $(basename "$d")" + ls -1 "$d" | sed 's/^/ /' +done diff --git a/native/redterm-api/redterm-api.c b/native/redterm-api/redterm-api.c new file mode 100644 index 0000000..42ca226 --- /dev/null +++ b/native/redterm-api/redterm-api.c @@ -0,0 +1,219 @@ +/* + * redterm-api — the guest-side half of RedTerm's termux:API bridge. + * + * Termux:API's native helper speaks to its app over an abstract Unix socket: + * a uint16 LE length, then the text of a fake `am` command line, which the + * app parses into an action and extras and answers on the same connection + * (again length-prefixed). This helper does the same for RedTerm, with an + * argument surface that termux-style scripts understand: + * + * redterm-api [--user 0] -a [--es k "v"] [--esa k "a,b"] \ + * [--ei k n] [--ez k true|false] [--ef k f] + * + * Value tokens following an option are quoted and escaped before the + * request is sent, because the app side parses quoted values — a raw + * join would break on the first space inside a value. + * + * The app answers with the result (JSON for battery-status, text for + * errors) and closes; the body is printed to stdout. When the app's + * process is not up, the socket refuses and the helper says so rather + * than hanging: the listener is an app thread, not a system service. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define API_NAME "com.redtermapp://listen" +#define MAX_REQUEST 8192 + +/* + * Which options carry value tokens, and how they are sent. -a/--user are + * forwarded bare (an action or a uid never carries a space). The --es + * family takes a bare key and then the value: string values are quoted and + * escaped, because the app side parses quoted values — a raw join would + * break on the first space inside one. Numeric and boolean values travel + * bare, the way Termux:API's own helper sends them: they cannot hold a + * space, and the app side's grammar expects them unquoted. + */ +static int optionValues(const char *a) +{ + if (strcmp(a, "-e") == 0 || strcmp(a, "--es") == 0 || strcmp(a, "--esa") == 0) { + return 2; /* bare key, quoted value */ + } + if (strcmp(a, "--ei") == 0 || strcmp(a, "--ez") == 0 || strcmp(a, "--ef") == 0) { + return 3; /* bare key, bare value */ + } + return 0; +} + +/* Quote s in the wire grammar: backslash first, then double quote. */ +static void appendQuoted(char *buf, size_t *pos, const char *s) +{ + buf[(*pos)++] = '"'; + for (const char *p = s; *p != 0; p++) { + if (*p == '\\' || *p == '"') { + if (*pos + 2 >= MAX_REQUEST) goto too_long; + buf[(*pos)++] = '\\'; + } + if (*pos + 1 >= MAX_REQUEST) goto too_long; + buf[(*pos)++] = *p; + } + buf[(*pos)++] = '"'; + return; +too_long: + fprintf(stderr, "redterm-api: request too long\n"); + exit(1); +} + +static int connectWithRetry(int fd, const struct sockaddr_un *addr, socklen_t len) +{ + for (int attempt = 0; attempt < 25; attempt++) { + if (connect(fd, (const struct sockaddr *) addr, len) == 0) return 0; + int e = errno; + if (e != ECONNREFUSED && e != EAGAIN && e != ENOENT) return -1; + usleep(200 * 1000); + } + return -1; +} + +static int readFully(int fd, void *buf, size_t count) +{ + size_t got = 0; + while (got < count) { + ssize_t n = read(fd, (char *) buf + got, count - got); + if (n <= 0) return -1; + got += (size_t) n; + } + return 0; +} + +static int writeFully(int fd, const void *buf, size_t count) +{ + size_t sent = 0; + while (sent < count) { + ssize_t n = write(fd, (const char *) buf + sent, count - sent); + if (n <= 0) return -1; + sent += (size_t) n; + } + return 0; +} + +int main(int argc, char **argv) +{ + char request[MAX_REQUEST]; + size_t pos = 0; + request[0] = 0; + + for (int i = 1; i < argc; i++) { + const char *arg = argv[i]; + if (pos + strlen(arg) + 2 >= MAX_REQUEST) { + fprintf(stderr, "redterm-api: request too long\n"); + return 1; + } + if (i > 1) request[pos++] = ' '; + memcpy(request + pos, arg, strlen(arg)); + pos += strlen(arg); + int trailing = optionValues(arg); + if (trailing > 0) { + /* key is a bare word; the value follows, quoted or bare */ + if (i + 1 < argc) { + i++; + const char *key = argv[i]; + if (pos + strlen(key) + 2 >= MAX_REQUEST) { + fprintf(stderr, "redterm-api: request too long\n"); + return 1; + } + request[pos++] = ' '; + memcpy(request + pos, key, strlen(key)); + pos += strlen(key); + } + if (i + 1 < argc) { + i++; + if (trailing == 2) { + request[pos++] = ' '; + appendQuoted(request, &pos, argv[i]); + } else { + if (pos + strlen(argv[i]) + 2 >= MAX_REQUEST) { + fprintf(stderr, "redterm-api: request too long\n"); + return 1; + } + request[pos++] = ' '; + memcpy(request + pos, argv[i], strlen(argv[i])); + pos += strlen(argv[i]); + } + } + } + } + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + perror("redterm-api: socket"); + return 1; + } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof addr); + addr.sun_family = AF_UNIX; + addr.sun_path[0] = 0; /* abstract namespace */ + const char *name = API_NAME; + memcpy(addr.sun_path + 1, name, strlen(name)); + /* + * The kernel's abstract name is sun_path[1] up to the byte covered by + * addrlen; addrlen must therefore reach offsetof(sun_path)+1+strlen, + * not strlen+1. A short addrlen silently truncates the name and the + * connect lands on an empty address, which reads as ECONNREFUSED. + */ + socklen_t addrLen = (socklen_t) (offsetof(struct sockaddr_un, sun_path) + 1 + strlen(name)); + + if (connectWithRetry(fd, &addr, addrLen) != 0) { + fprintf(stderr, "redterm-api: cannot reach the RedTerm app: %s " + "(start RedTerm first; the API listener lives in its process)\n", + strerror(errno)); + close(fd); + return 1; + } + + uint16_t reqLen = (uint16_t) pos; + uint8_t header[2] = { (uint8_t) (reqLen & 0xff), (uint8_t) (reqLen >> 8) }; + if (writeFully(fd, header, 2) != 0 || writeFully(fd, request, pos) != 0) { + fprintf(stderr, "redterm-api: send failed: %s\n", strerror(errno)); + close(fd); + return 1; + } + + uint8_t respHeader[2]; + if (readFully(fd, respHeader, 2) != 0) { + fprintf(stderr, "redterm-api: no response from the app\n"); + close(fd); + return 1; + } + uint16_t respLen = (uint16_t) (respHeader[0] | (respHeader[1] << 8)); + + char *response = malloc((size_t) respLen + 1); + if (response == NULL) { + fprintf(stderr, "redterm-api: out of memory\n"); + close(fd); + return 1; + } + if (readFully(fd, response, respLen) != 0) { + fprintf(stderr, "redterm-api: short response from the app\n"); + close(fd); + free(response); + return 1; + } + response[respLen] = 0; + close(fd); + + fwrite(response, 1, respLen, stdout); + if (respLen > 0 && response[respLen - 1] != '\n') fputc('\n', stdout); + free(response); + return 0; +} From 7d65afc8c858db52aa8000ca9bf095c5108947d2 Mon Sep 17 00:00:00 2001 From: GlobalTechInfo Date: Wed, 4 Nov 2026 03:42:49 +0500 Subject: [PATCH 2/8] Zero-warning compile: reach the withdrawn members by name, not by reference The gate is reading the w: lines of compileDebugKotlin, not the exit code, and 13 compiler warnings had slipped through being reported as a clean build. None is fixed by a suppression: - The five unchecked as? List casts on reflective results (CellInfoApi, NetworkApis, SsidApi) become a star-projection cast plus per-element concrete casts, which the compiler can check. - The eight deprecated references stop being references: ScanResult.SSID is read by name (withdrawn on 33); setVisibleInDownloadsUi is invoked reflectively on the levels where it still has effect (<29, a documented no-op since); the no-arg MediaRecorder is reached by name with the Context constructor on 34+; the withdrawn CDMA/EHRPD/1xRTT constants stand as their stable documented values; the three-argument Locale constructor becomes Locale.forLanguageTag (non-deprecated on every level the app runs); and setWifiEnabled is reached by name on the declared type. --- .../com/redtermapp/util/api/CellInfoApi.kt | 8 +++---- .../com/redtermapp/util/api/DownloadApi.kt | 12 ++++++++++- .../com/redtermapp/util/api/MicRecordApi.kt | 12 ++++++++++- .../com/redtermapp/util/api/NetworkApis.kt | 21 ++++++++++++------- .../java/com/redtermapp/util/api/SsidApi.kt | 19 ++++++++++++----- .../redtermapp/util/api/TelephonyInfoApi.kt | 8 +++---- .../java/com/redtermapp/util/api/TtsApi.kt | 11 +++++++++- .../com/redtermapp/util/api/WifiEnableApi.kt | 10 ++++++++- 8 files changed, 76 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt b/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt index be273ef..ec2a3f2 100644 --- a/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/CellInfoApi.kt @@ -25,10 +25,10 @@ object CellInfoApi : ApiFeature { // untrackable by the static check and still answers in a build that // does declare and grant the permission; without it the invoke // throws SecurityException and the honest answer is "no cell data". - val cells = runCatching { - manager.javaClass.getMethod("getAllCellInfo") - .invoke(manager) as? List - }.getOrNull() + val raw = runCatching { + manager.javaClass.getMethod("getAllCellInfo").invoke(manager) + }.getOrNull() as? List<*> + val cells = raw?.mapNotNull { it as? android.telephony.CellInfo } if (cells.isNullOrEmpty()) return "ERROR: no cell information, or the location grant this build does not declare\n" val sb = StringBuilder("[") diff --git a/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt b/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt index 9e8e8b3..dde13ff 100644 --- a/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt @@ -3,6 +3,7 @@ package com.redtermapp.util.api import android.app.DownloadManager import android.content.Context import android.net.Uri +import android.os.Build import androidx.core.net.toUri import com.redtermapp.util.api.ApiRequestParser.ApiRequest import java.io.File @@ -30,7 +31,16 @@ object DownloadApi : ApiFeature { r.setTitle(title) r.setDescription(request.str("description") ?: title) r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - r.setVisibleInDownloadsUi(true) + // setVisibleInDownloadsUi was withdrawn on API 29, where it became + // a documented no-op; below that it is what puts the file in the + // Downloads UI, so it is reached by name on those levels only. A + // direct reference would name a member the platform has retired. + if (Build.VERSION.SDK_INT < 29) { + runCatching { + r.javaClass.getMethod("setVisibleInDownloadsUi", java.lang.Boolean.TYPE) + .invoke(r, true) + } + } request.str("path")?.let { r.setDestinationUri(Uri.fromFile(File(it))) } diff --git a/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt b/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt index b0c9d83..696c0c1 100644 --- a/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt @@ -3,6 +3,7 @@ package com.redtermapp.util.api import android.Manifest import android.content.Context import android.media.MediaRecorder +import android.os.Build import com.redtermapp.util.api.ApiRequestParser.ApiRequest import java.io.File @@ -40,7 +41,16 @@ object MicRecordApi : ApiFeature { val file = request.str("file") ?: File(context.filesDir, "api/recording-${System.currentTimeMillis()}").absolutePath val limitMs = (request.int("limit") ?: 0).toLong() - val r = MediaRecorder() + // The no-arg MediaRecorder constructor was withdrawn on API 34 in + // favour of the context-taking one; below that it is the only form. + // Both are reached by name — the context is handed where the + // platform wants it — so the build carries no reference to the + // withdrawn constructor. + val r = (if (Build.VERSION.SDK_INT >= 34) { + MediaRecorder::class.java.getConstructor(Context::class.java).newInstance(context) + } else { + MediaRecorder::class.java.getConstructor().newInstance() + }) as MediaRecorder try { r.setAudioSource(MediaRecorder.AudioSource.MIC) r.setOutputFormat(formatFor(enc)) diff --git a/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt index ed6df5a..4dc88a6 100644 --- a/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt +++ b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt @@ -114,10 +114,7 @@ object WifiApi : ApiFeature { // untrackable by the static check and still answer in a build that // does declare and grant the permission; without it they throw // SecurityException and the honest answer is the (empty) last scan. - var results = runCatching { - manager.javaClass.getMethod("getScanResults") - .invoke(manager) as? List - }.getOrNull().orEmpty() + var results = scanResultsOf(manager) // The scan list is only what the *last* scan produced; a device // that has not scanned recently answers [] to every question. // Trigger a scan and wait for it to settle. @@ -129,10 +126,7 @@ object WifiApi : ApiFeature { val deadline = System.currentTimeMillis() + 4000 while (System.currentTimeMillis() < deadline) { Thread.sleep(500) - val fresh = runCatching { - manager.javaClass.getMethod("getScanResults") - .invoke(manager) as? List - }.getOrNull().orEmpty() + val fresh = scanResultsOf(manager) if (fresh.isNotEmpty()) { results = fresh break @@ -148,6 +142,17 @@ object WifiApi : ApiFeature { private fun ssidOf(results: List): List = results.map { it.wifiSsid?.toString() ?: "" } + /** + * The scan list, reached by name: the invoke's result is a `List<*>` + * (the star projection is a checked cast), and each element is cast to + * the concrete type, so the build carries no unchecked generic cast. + */ + private fun scanResultsOf(manager: WifiManager): List = + (runCatching { + manager.javaClass.getMethod("getScanResults").invoke(manager) + }.getOrNull() as? List<*>)?.mapNotNull { it as? android.net.wifi.ScanResult } + ?: emptyList() + fun buildScan(results: List, ssids: List?): String { val sb = StringBuilder("[") results.forEachIndexed { i, r -> diff --git a/app/src/main/java/com/redtermapp/util/api/SsidApi.kt b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt index 9cae0fc..8cbb792 100644 --- a/app/src/main/java/com/redtermapp/util/api/SsidApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt @@ -33,10 +33,11 @@ object SsidApi : ApiFeature { // permission) does not flag it; without the grant the invoke throws // and the honest answer is "no scan data". The connection state // still answers, and it needs no permission. - val scans = runCatching { - manager.javaClass.getMethod("getScanResults") - .invoke(manager) as? List - }.getOrNull().orEmpty() + val scans = + (runCatching { + manager.javaClass.getMethod("getScanResults").invoke(manager) + }.getOrNull() as? List<*>)?.mapNotNull { it as? android.net.wifi.ScanResult } + ?: emptyList() val best = scans.maxByOrNull { it.level } // The scan is a sampling of the neighbourhood; the connection-info // object is the platform's own account of the association. @@ -86,7 +87,15 @@ object SsidApi : ApiFeature { private fun ssidOfScan(scan: android.net.wifi.ScanResult): String = if (Build.VERSION.SDK_INT >= 33) scan.wifiSsid.toString() - else scan.SSID ?: "" + else { + // The plain SSID field was withdrawn on API 33 in favour of the + // WifiSsid accessor; below that it is the only source, and it is + // read by name on the declared type so the build carries no + // reference to the withdrawn member. + runCatching { + android.net.wifi.ScanResult::class.java.getField("SSID").get(scan) + }.getOrNull() as? String ?: "" + } /** * The connection-info object behind the withdrawn `getConnectionInfo()`: diff --git a/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt b/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt index c439659..68e5395 100644 --- a/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/TelephonyInfoApi.kt @@ -96,7 +96,7 @@ object TelephonyInfoApi : ApiFeature { private fun phoneTypeName(type: Int): String = when (type) { TelephonyManager.PHONE_TYPE_GSM -> "gsm" - TelephonyManager.PHONE_TYPE_CDMA -> "cdma" + 3 -> "cdma" // PHONE_TYPE_CDMA: the constant is withdrawn, its value is not TelephonyManager.PHONE_TYPE_SIP -> "sip" TelephonyManager.PHONE_TYPE_NONE -> "none" else -> type.toString() @@ -118,7 +118,7 @@ object TelephonyInfoApi : ApiFeature { private fun networkTypeName(type: Int): String = when (type) { TelephonyManager.NETWORK_TYPE_GSM -> "gsm" - TelephonyManager.NETWORK_TYPE_CDMA -> "cdma" + 8 -> "cdma" // NETWORK_TYPE_CDMA: the constant is withdrawn, its value is not TelephonyManager.NETWORK_TYPE_UMTS -> "umts" TelephonyManager.NETWORK_TYPE_EDGE -> "edge" TelephonyManager.NETWORK_TYPE_LTE -> "lte" @@ -126,8 +126,8 @@ object TelephonyInfoApi : ApiFeature { TelephonyManager.NETWORK_TYPE_HSDPA -> "hsdpa" TelephonyManager.NETWORK_TYPE_HSPA -> "hspa" TelephonyManager.NETWORK_TYPE_HSUPA -> "hsupa" - TelephonyManager.NETWORK_TYPE_EHRPD -> "ehrpd" - TelephonyManager.NETWORK_TYPE_1xRTT -> "1xrtt" + 12 -> "ehrpd" // NETWORK_TYPE_EHRPD: the constant is withdrawn, its value is not + 14 -> "1xrtt" // NETWORK_TYPE_1xRTT: the constant is withdrawn, its value is not TelephonyManager.NETWORK_TYPE_UNKNOWN -> "unknown" else -> type.toString() } diff --git a/app/src/main/java/com/redtermapp/util/api/TtsApi.kt b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt index 3b45b41..829966d 100644 --- a/app/src/main/java/com/redtermapp/util/api/TtsApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt @@ -64,7 +64,16 @@ object TtsApi : ApiFeature { } val lang = request.str("language") if (lang != null) { - tts.setLanguage(Locale(lang, request.str("region") ?: "", request.str("variant") ?: "")) + // The three-argument Locale constructor is withdrawn on API 33 in + // favour of Locale.of, and Locale.of is absent below 33; + // forLanguageTag predates both, is non-deprecated on every level + // the app runs on, and takes the same parts as a BCP-47 tag, + // where empty segments are skipped — the old constructor's own + // treatment of empty strings. + val tag = listOf(lang, request.str("region"), request.str("variant")) + .filter { !it.isNullOrEmpty() } + .joinToString("-") + tts.setLanguage(if (tag.isEmpty()) Locale.getDefault() else Locale.forLanguageTag(tag)) } request.float("rate")?.let { tts.setSpeechRate(it) } request.float("pitch")?.let { tts.setPitch(it) } diff --git a/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt index cd6c490..0333199 100644 --- a/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt @@ -20,7 +20,15 @@ object WifiEnableApi : ApiFeature { val state = manager.wifiState if (enabled && state == WifiManager.WIFI_STATE_ENABLED) return "already on\n" if (!enabled && state == WifiManager.WIFI_STATE_DISABLED) return "already off\n" - val accepted = manager.setWifiEnabled(enabled) + // setWifiEnabled was withdrawn on API 28 with no public + // replacement; it is reached by name on the declared type so the + // build carries no reference to the retired member. The answer is + // the platform's own — true where an app may still flip it, false + // where it refuses. + val accepted = runCatching { + WifiManager::class.java.getMethod("setWifiEnabled", java.lang.Boolean.TYPE) + .invoke(manager, enabled) as? Boolean + }.getOrNull() ?: false return if (accepted) { if (enabled) "1\n" else "0\n" } else { From e05fb395e5b164f26f32e7ad17d1fb68c6c8d7b8 Mon Sep 17 00:00:00 2001 From: GlobalTechInfo Date: Wed, 4 Nov 2026 14:00:23 +0500 Subject: [PATCH 3/8] api bridge: project guest paths to the session rootfs, bound the unbounded reads, and close the review's gaps Guest paths are named on the distro side, but the bridge opens them in the app process. The launcher now exports REDTERM_ROOTFS_HOST, the guest helper attaches it to every request, and ApiPaths is the one place that projects a guest path to the host path it actually lives at (or refuses it). Share, storage-get, media-play and media-scan all go through it. Also, per the review: - ApiSocket reads run on a bounded pool with a deadline; LocalSocket has no read-timeout API, so a stuck peer is bounded by the pool cap and closed, not pinned. Response bodies over the uint16 field are refused, not sent. - A read/write size cap on saf readDocument; a counter-range guard; a bounded callback read; a non-cyclic media scan; a bound on concurrent requests. - A single JSON string encoder (ApiJson) so an SSID with control bytes is valid JSON; TtsApi keeps the exact language/region/variant; the wifi toggle checks its grant and a present boolean flag; NFC rejects a disabled radio, re-enables reader mode on resume, and posts a cancel on destroy. - installOne only replaces scripts it owns (the marker); ensureInstalled clears just the binary and stamp, keeping feature output. - the startup-hook sourcing test now runs the block the generator emits, not a hand copy. Helper binaries rebuilt for all four ABIs; GUEST_SCRIPT_VERSION is 7. --- CHANGELOG.md | 9 +- .../assets/redterm-api/aarch64/redterm-api | Bin 9744 -> 10808 bytes .../assets/redterm-api/armv7a/redterm-api | Bin 8264 -> 9160 bytes .../main/assets/redterm-api/i686/redterm-api | Bin 7972 -> 8876 bytes .../assets/redterm-api/x86_64/redterm-api | Bin 9280 -> 10088 bytes .../java/com/redtermapp/distro/ProotLaunch.kt | 5 + .../java/com/redtermapp/util/api/ApiHelper.kt | 33 +++- .../java/com/redtermapp/util/api/ApiJson.kt | 36 ++++ .../java/com/redtermapp/util/api/ApiPaths.kt | 56 ++++++ .../java/com/redtermapp/util/api/ApiSocket.kt | 176 +++++++++++++----- .../com/redtermapp/util/api/CallbackApi.kt | 24 ++- .../java/com/redtermapp/util/api/MediaApis.kt | 9 +- .../redtermapp/util/api/MediaPlaybackApi.kt | 4 +- .../redtermapp/util/api/MediaScannerApi.kt | 20 +- .../com/redtermapp/util/api/NetworkApis.kt | 14 +- .../java/com/redtermapp/util/api/NfcApi.kt | 5 + .../java/com/redtermapp/util/api/SafApi.kt | 42 ++++- .../java/com/redtermapp/util/api/SensorApi.kt | 15 +- .../java/com/redtermapp/util/api/ShareApi.kt | 26 ++- .../java/com/redtermapp/util/api/SsidApi.kt | 13 +- .../com/redtermapp/util/api/StorageGetApi.kt | 11 +- .../java/com/redtermapp/util/api/TtsApi.kt | 18 +- .../com/redtermapp/util/api/WifiEnableApi.kt | 15 +- .../util/api/ui/ApiDialogActivity.kt | 6 + .../redtermapp/util/api/ui/ApiNfcActivity.kt | 49 ++++- .../com/redtermapp/util/api/ui/ApiUiBridge.kt | 18 +- .../distro/StartupHookSourcingTest.kt | 36 ++-- .../com/redtermapp/util/api/ApiHelperTest.kt | 42 +++-- .../redtermapp/util/api/ApiPathsJsonTest.kt | 113 +++++++++++ native/redterm-api/redterm-api.c | 28 +++ 30 files changed, 669 insertions(+), 154 deletions(-) create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiJson.kt create mode 100644 app/src/main/java/com/redtermapp/util/api/ApiPaths.kt create mode 100644 app/src/test/java/com/redtermapp/util/api/ApiPathsJsonTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index da3b285..ed9d615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,8 +83,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Every command below is installed in the distro's `/usr/local/bin` under **both** a `redterm-` and a `termux-` name (same body), so existing Termux:API scripts keep -running. All answers are JSON on stdout; a gated command that is refused names the -missing permission and the Settings card to fix it. +running. Commands with structured output write JSON to stdout. Parser errors and +refused commands write plain text. A refusal may report a declared-but-missing +grant, or a permission this build does not declare at all. After the response +is received, the helper exits with status 0 — the exit status reports transport, +never the command's outcome, so the text is the answer. A missing grant on a +declared permission is fixed in Settings; an undeclared one cannot be enabled +there, and the refusal says so. **Bridge control** diff --git a/app/src/main/assets/redterm-api/aarch64/redterm-api b/app/src/main/assets/redterm-api/aarch64/redterm-api index 62d51fb3f96b4f6079d1d4393f15a45361f5d0d4..fac6f97d3506f51e085add3a013931789e4c6742 100755 GIT binary patch literal 10808 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 Z4B}q;mGJwxnNI%HtjsEcAum*tKH@8 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 literal 8264 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 delta 3801 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 delta 3448 zcmZu!3v64}89w*=k=S`SbyC|QPUQkkF%S(V@#f(L&S(ynSNY$+9f z3Z3=5Xqv4S_Sv>?W2Y{7tej&r`)KTS7=a3JA3W-fxwqUqI0#RB zmwXHNutWJEUHMOofXK zj|e@-$RKBrbT;g$;NptNMjblETBUJ^PJ>rzzeA_as&v4im$00Y89U@Ks8N%3!lC=V zac9RIx}0}zEjo0u97UxmhhCxDq3_Q)43)}&c-El@9Qv$7R})WW)1fbN_|G}?phLfm z`fbE%3gpHT=5_(3FBV0fmpXL0pSWJ-(8CV@Bg4LaZ4J}vT-U8enP#CAJJyFdintAN zKjJXrF~p}3rw|VzmSRU|5E)!u)~ua{jb+tcHE2^4L*rhDzbe#JS4g9_qo}FC$C1-_ zBJh0KQf~`dE_vYXvZVfd56qV}L99F#zrB_*W2E?4xiRKxU4iBNx_Ff_ws8XFiUxQ7q zl7VrU3*J%_!!>6VBALQk^GiG#Fe*h0`l^4n{5d>W&|vnXTIe(%5ylryjA;IXkw4j5 zG-Q7_d`#%qoByBr7Fq_@kIWB+Gxpov z!>il;Llr%Rj@RjYFwdir+M7gN-#Wkf((U-^?tkHQe1OWO8;yeJmW3|8hWd$yg)Tir z9!t^Vngx&Rsnc}(icW7t5A55m(7L!WH;3Ct4DIG`w-Z1wJ`?69ES<*M%wF{odfSO} zn9~xr6U4WY$->2I+llvxqdT)^IG-ktL19*r^OMBsthOdNKb|82mlU%OaN!l=xa62M z&iP^DbR1a2od1qEoi^4E&YvMpTiv>o^IsFEv)9UUzK1x))Jk*yIB~j-tQO95kCH%V zfEDAyAaOc$tt!s%Cr)Rr#W=s4IGwT9+*c?hZY56Vi8aglO~mo=zzA^uBjWhO2yos_ z9A^e2Kzv!^S`z3Uv<{#^v7_{70Sg!5QSgP=YBSIiUfa|{kADmWZo zlW|knFwy8KO$%V3ePusDMU%peY0egWzvS2pZl8kXk+u448)WoQZeNpdJ}B{3iIWn~ zNW3UhK0}_8Oaks!L**+;8l=!N|Nr`79GInG|ZBlD3%Gc(4 z@qHFS)bQ?#Z4d%+O4r~|XB8|9JFj@5wo1NT)a60(*Yye(gG^nxwo}$s)<$Vf!-I9H z+>{J7t!7HEP=i`L-dF%u1I+j;&lyo=>u^707J3vgF`O54xN&ruG=`iS-t=Pz^bW4wF< z8XHnawl;L>KlncUJhmOOdI{`sxnW!5vsxN_O{qm{MJa57o~D#G_MRUlg}=N{U|3Jz-+GaExfov zi+A|dWKH>@JKnzV-$eb}IPHZ!@l=&u#KX8nDcuQg##4SZKeN@KC))jKd>c_pFNW?! z`=UNc`|xU_RD^sY6^zJtPei^zOdm`qR{GUqjDI^}5a*eN3I-lk-cs3dL fmC83$C%l>L@TKJvzbIm~H11Ez<@-JkyRqSGmoc`B diff --git a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt index 5205838..a600504 100644 --- a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt +++ b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt @@ -158,6 +158,11 @@ export HOME=/root export PATH=/system/bin:/system/xbin:/bin:/sbin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin export ENV=/root/.startup $interactiveExport +# The host path of this rootfs, carried into the guest so the API helper +# can attach it to every request: the app process opens guest paths +# through this directory, and without the origin it cannot tell which +# rootfs a path came from. +export REDTERM_ROOTFS_HOST="$rp" export TERM=xterm-256color export COLORTERM=truecolor if command -v locale >/dev/null 2>&1 && locale -a 2>/dev/null | grep -qi '^C\.utf-\?8$'; then diff --git a/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt b/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt index 82f03c7..1018f37 100644 --- a/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt +++ b/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt @@ -14,7 +14,7 @@ import java.io.File */ object ApiHelper { - const val GUEST_SCRIPT_VERSION = 6 + const val GUEST_SCRIPT_VERSION = 7 /** The helper's fixed basename, both in app storage and at the bound guest path. */ const val BINARY_NAME = "redterm-api" @@ -24,16 +24,16 @@ object ApiHelper { private const val EXEC_PATH = "$GUEST_BIND_TARGET/$BINARY_NAME" + /** + * The ownership line every RedTerm-managed guest script carries. A file + * in the guest bin that lacks it is not ours: [installOne] leaves it + * in place rather than destroying a user-written script. + */ + const val MARKER = "# managed by RedTerm" + /** The app-storage directory the helper binary lives in; features that need a stable scratch file keep it here. */ fun helperDir(context: Context): File = File(context.filesDir, "api") - /** - * Extract the helper binary into app storage and return the directory - * to bind. A stamp file records what was installed; the expected - * stamp is the ABI plus the binary version, so an ABI or binary change - * re-extracts. Returns null when extraction is impossible, which makes - * the bridge unavailable but never breaks the launch. - */ /** * Extract the helper binary into app storage and return the directory * to bind. The binary is always named `redterm-api`, because that is @@ -84,7 +84,11 @@ object ApiHelper { if (binary.isFile && runCatching { stamp.readText() }.getOrNull()?.trim() == expected) { return hostDir } - runCatching { hostDir.deleteRecursively() } + // Only the extracted helper and its stamp are re-created: this + // directory also holds feature output (recordings, clipboard + // hand-offs) that outlives a re-extraction. + runCatching { binary.delete() } + runCatching { stamp.delete() } return try { if (!hostDir.mkdirs() && !hostDir.isDirectory) { AppLog.e(context, "api-helper", "could not create ${hostDir.absolutePath}") @@ -126,6 +130,14 @@ object ApiHelper { private fun installOne(binDir: File, name: String, body: String, context: Context?): Boolean { val script = File(binDir, name) val current = runCatching { script.readText() }.getOrNull() + // Ownership runs through the marker: only a file RedTerm wrote may be + // replaced. A user-written script that happens to carry this name is + // left in place — destroying it is how a hand-written .bashrc gets + // wiped, and a marker is what makes a file ours in the first place. + if (current != null && !current.contains(MARKER)) { + context?.let { ctx -> AppLog.w(ctx, "api-helper", "script $name is not managed by RedTerm; left in place") } + return true + } return if (current != body) { runCatching { script.writeText(body) @@ -154,7 +166,7 @@ object ApiHelper { */ private fun script(body: String): String = "#!/bin/sh\n" + - "# managed by RedTerm (redterm-api bridge v$GUEST_SCRIPT_VERSION); do not edit\n" + + "$MARKER (redterm-api bridge v$GUEST_SCRIPT_VERSION); do not edit\n" + "if [ ! -e $EXEC_PATH ]; then\n" + " echo \"$EXEC_PATH: the RedTerm API helper is not installed in this session\" 1>&2\n" + " echo \"restart the RedTerm session; if it still fails, open RedTerm Diagnostics\" 1>&2\n" + @@ -743,6 +755,7 @@ object ApiHelper { } private const val STARTUP_LOCAL_SCRIPT = """#!/bin/sh +# managed by RedTerm (redterm-api bridge v$GUEST_SCRIPT_VERSION); do not edit # redterm-startup-local — print a safe starter for /root/.startup.local, the # user-managed hook that runs once per new interactive RedTerm session. # Pure guest-side tool: no bridge, no API helper. Create the hook with: diff --git a/app/src/main/java/com/redtermapp/util/api/ApiJson.kt b/app/src/main/java/com/redtermapp/util/api/ApiJson.kt new file mode 100644 index 0000000..3b7c203 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiJson.kt @@ -0,0 +1,36 @@ +package com.redtermapp.util.api + +/** + * The one correct JSON string encoder for the bridge's JSON answers. The + * ApiFeatures hand-rolled a `replace("\"", "\\\"")` escaper, which produces + * invalid JSON the moment a value carries a control character — and an + * SSID is the value most likely to: `\n`, `\t` and raw 0x00-0x1F bytes are + * all legal in an SSID. Every JSON string the bridge emits goes through + * this one place so that invariant is in one file, not re-derived (or + * re-missed) per command. + */ +object ApiJson { + + fun string(value: String): String { + val sb = StringBuilder(value.length + 2) + sb.append('"') + for (c in value) { + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> { + if (c.code < 0x20) { + sb.append(java.lang.String.format("\\u%04x", c.code)) + } else { + sb.append(c) + } + } + } + } + sb.append('"') + return sb.toString() + } +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiPaths.kt b/app/src/main/java/com/redtermapp/util/api/ApiPaths.kt new file mode 100644 index 0000000..5aae9d8 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiPaths.kt @@ -0,0 +1,56 @@ +package com.redtermapp.util.api + +import com.redtermapp.distro.ProotLaunch +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.io.File + +/** + * Where a guest-named path actually lives for the app process. + * + * The bridge runs in the app's process, but the paths its features open + * are named on the *guest* side: a guest `/root/photo.jpg` is a file in + * this session's rootfs, at the host path the launcher exports as + * `REDTERM_ROOTFS_HOST`, not at the app's own `/root`. A path under one of + * proot's bind directories (`/sdcard`, `/system`, …) is the same string on + * both sides. This object is the one place that knows that, so a feature + * that opens a guest path goes through it rather than reopening the raw + * string. + */ +object ApiPaths { + + /** + * The extra the guest helper attaches to every request when the + * launcher exported [REDTERM_ROOTFS_HOST]: the host path of the + * session's rootfs. Absent for a session started any other way. + */ + const val EXTRA_ROOTFS_HOST = "redterm-rootfs-host" + + const val REDTERM_ROOTFS_HOST = "REDTERM_ROOTFS_HOST" + + /** + * The host path the app process opens for [guestPath], or null when + * it cannot be projected: a relative path needs the guest's own + * `realpath` (the app does not know the guest's working directory), + * a request without the rootfs context has no origin to project + * from, and a `..` that escapes the rootfs would point the app at + * a tree it should not reach. + */ + fun hostPath(request: ApiRequest, guestPath: String): String? { + val path = guestPath.trim() + if (path.isEmpty() || !path.startsWith("/")) return null + // A bind mount is the same path on both sides of proot. + for (dir in ProotLaunch.BOUND_DIRS) { + if (path == dir || path.startsWith("$dir/")) return path + } + val rootfsHost = request.str(EXTRA_ROOTFS_HOST) + if (rootfsHost.isNullOrEmpty()) return null + val root = File(rootfsHost).absoluteFile.normalize().absolutePath + val target = File(root, path.removePrefix("/")).normalize().absolutePath + return if (target == root || target.startsWith("$root/")) target else null + } + + /** The answer a feature gives when a path cannot be projected. */ + fun unreachable(path: String): String = + "ERROR: '$path' is not reachable from the app process: guest paths must be absolute " + + "(a relative one needs the guest's realpath), and the session must carry its rootfs origin\n" +} diff --git a/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt b/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt index 7b3450a..dc5a9a7 100644 --- a/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt +++ b/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt @@ -6,7 +6,13 @@ import android.net.LocalSocket import android.os.Build import androidx.annotation.RequiresApi import com.redtermapp.util.AppLog -import java.io.EOFException +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.Semaphore +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException /** * The app-side half of RedTerm's termux:API bridge, and why it is a thread @@ -30,6 +36,41 @@ object ApiSocket { /** The abstract socket name. 108 bytes is the kernel's ceiling; this is 22. */ const val SOCKET_NAME = "com.redtermapp://listen" + /** + * How many request threads may work at once. Each accepted connection + * gets a thread that blocks on the socket; an unbounded accept loop + * means a guest (or a buggy script) that opens connections without + * sending can exhaust the process thread limit, and the resulting + * OutOfMemoryError is not an Exception, so it would take the listener + * down with it. + */ + private const val MAX_CONCURRENT_REQUESTS = 16 + + /** How long a request thread waits for the peer before giving up. */ + private const val READ_TIMEOUT_MS = 30_000 + + private val requestSlots = Semaphore(MAX_CONCURRENT_REQUESTS) + + /** + * `LocalSocket` has no per-socket read deadline, so a peer that holds the + * connection open without sending would block a worker forever. The read + * runs on this bounded pool and the handler waits for it with a deadline; + * on timeout the handler closes the socket, which is what unblocks the + * worker. Capping the pool to the same number as [requestSlots] means a + * burst of stuck peers can never grow past the process's thread budget. + */ + private val readWorkers = Executors.newFixedThreadPool( + MAX_CONCURRENT_REQUESTS, + ThreadFactory { r -> Thread(r, "redterm-api-read").apply { isDaemon = true } } + ) + + /** What the read worker hands back: a parsed request, a refusal, or silence. */ + private sealed interface ReadResult { + data class Ok(val request: ApiRequest) : ReadResult + data class Err(val message: String) : ReadResult + object PeerClosed : ReadResult + } + @Volatile private var started = false @@ -56,9 +97,20 @@ object ApiSocket { } val app = context.applicationContext stopRequested = false + // Bind on the calling thread: `api-start` reports the bind result + // right after start() returns, so `running` must be true only once + // the socket actually holds the name. A failed bind (the previous + // listener still holding the socket name after a stop) says so + // instead of clearing the flag a moment later in a finally. + val listen = try { + LocalServerSocket(SOCKET_NAME) + } catch (e: Exception) { + AppLog.e(app, "api-socket", "could not bind the API socket: ${e.message}") + return + } running = true Thread({ - serve(app) + serve(app, listen) }, "redterm-api-socket").also { it.isDaemon = true; it.start() } } @@ -76,17 +128,26 @@ object ApiSocket { } @RequiresApi(Build.VERSION_CODES.P) - private fun serve(app: Context) { - var listen: LocalServerSocket? = null + private fun serve(app: Context, listen: LocalServerSocket) { try { - listen = LocalServerSocket(SOCKET_NAME) while (!stopRequested) { val con = runCatching { listen.accept() }.getOrNull() ?: continue // One thread per request: a location fix blocks for its // timeout, and a single handler would stall every other - // guest command behind it. + // guest command behind it. The slots cap the threads at + // [MAX_CONCURRENT_REQUESTS]; a peer that holds connections + // open without sending gets a busy answer, not a leak. + if (!requestSlots.tryAcquire(5, TimeUnit.SECONDS)) { + runCatching { writeResponse(con, "ERROR: the API bridge is busy; retry in a moment\n", app) } + runCatching { con.close() } + continue + } Thread({ - handleConnection(con, app) + try { + handleConnection(con, app) + } finally { + requestSlots.release() + } }, "redterm-api-request").also { it.isDaemon = true; it.start() } } } catch (e: Exception) { @@ -95,7 +156,7 @@ object ApiSocket { if (!stopRequested) AppLog.e(app, "api-socket", "API socket stopped: ${e.message}") } finally { running = false - runCatching { listen?.close() } + runCatching { listen.close() } } } @@ -106,50 +167,79 @@ object ApiSocket { con.close() return } - // The helper writes the request length as a LITTLE-endian - // uint16, exactly like the response length this class writes - // (see writeResponse). DataInputStream.readUnsignedShort is - // big-endian, which reads an 84-byte request as 84*256 = - // 21504 and rejects it as "too long": the one round every - // command failed with. - val input = con.inputStream - val b0 = input.read() - val b1 = input.read() - if (b0 < 0 || b1 < 0) { - con.close() + val future = readWorkers.submit { readRequest(con) } + val outcome = try { + future.get(READ_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + // The peer held the connection open past the deadline. Cancel + // the task and close the socket: the close is what unblocks + // the worker, since a blocked socket read cannot be + // interrupted by thread interrupt. + future.cancel(true) + AppLog.e(app, "api-socket", "API request timed out after ${READ_TIMEOUT_MS}ms; connection closed") + runCatching { con.close() } return - } - val length = (b1 shl 8) or b0 - if (length > 16384) { - writeResponse(con, "ERROR: request too long ($length bytes)\n") + } catch (e: ExecutionException) { + AppLog.e(app, "api-socket", "API request read failed: ${e.cause?.let { it.javaClass.simpleName + ": " + it.message }}") + runCatching { con.close() } return } - val buf = ByteArray(length) - // A socket read may return fewer bytes than asked; read until - // the request is complete or the peer hangs up. - var filled = 0 - while (filled < length) { - val n = input.read(buf, filled, length - filled) - if (n < 0) throw java.io.EOFException("request truncated at $filled/$length bytes") - filled += n + try { + when (outcome) { + is ReadResult.Ok -> writeResponse(con, ApiActions.run(app, outcome.request), app) + is ReadResult.Err -> writeResponse(con, outcome.message, app) + is ReadResult.PeerClosed -> Unit + } + } catch (e: Exception) { + // An exception here would otherwise leave the guest holding a + // closed pipe with no word: say what happened, to both sides. + AppLog.e(app, "api-socket", "API request failed: ${e.javaClass.simpleName}: ${e.message}") + runCatching { writeResponse(con, "ERROR: ${e.javaClass.simpleName}: ${e.message}\n", app) } } - val result = when (val parsed = ApiRequestParser.parse(String(buf, Charsets.UTF_8))) { - is ApiRequestParser.Result.Ok -> ApiActions.run(app, parsed.request) - is ApiRequestParser.Result.Error -> parsed.message - } - writeResponse(con, result) - } catch (e: Exception) { - // An exception here would otherwise leave the guest holding a - // closed pipe with no word: say what happened, to both sides. - AppLog.e(app, "api-socket", "API request failed: ${e.javaClass.simpleName}: ${e.message}") - runCatching { writeResponse(con, "ERROR: ${e.javaClass.simpleName}: ${e.message}\n") } } finally { runCatching { con.close() } } } - private fun writeResponse(con: LocalSocket, body: String) { - val bytes = body.toByteArray(Charsets.UTF_8) + /** + * Read and parse one request, on a worker thread. The helper writes the + * request length as a LITTLE-endian uint16, exactly like the response + * length this class writes (see writeResponse); a big-endian reader + * would take an 84-byte request for 84*256 = 21504 and reject it. + */ + private fun readRequest(con: LocalSocket): ReadResult { + val input = con.inputStream + val b0 = input.read() + val b1 = input.read() + if (b0 < 0 || b1 < 0) return ReadResult.PeerClosed + val length = (b1 shl 8) or b0 + if (length > 16384) return ReadResult.Err("ERROR: request too long ($length bytes)\n") + val buf = ByteArray(length) + // A socket read may return fewer bytes than asked; read until the + // request is complete or the peer hangs up. + var filled = 0 + while (filled < length) { + val n = input.read(buf, filled, length - filled) + if (n < 0) return ReadResult.Err("ERROR: the request was truncated at $filled/$length bytes; it was not run\n") + filled += n + } + return when (val parsed = ApiRequestParser.parse(String(buf, Charsets.UTF_8))) { + is ApiRequestParser.Result.Ok -> ReadResult.Ok(parsed.request) + is ApiRequestParser.Result.Error -> ReadResult.Err(parsed.message) + } + } + + private fun writeResponse(con: LocalSocket, body: String, app: Context) { + var bytes = body.toByteArray(Charsets.UTF_8) + if (bytes.size > 0xFFFF) { + // The length field is a uint16: a longer body cannot be + // described, and writing it anyway would desynchronise the + // stream for the reader. Answer with a refusal that fits, and + // log the loss so the feature that produced it can be bounded. + AppLog.e(app, "api-socket", "API answer of ${bytes.size} bytes exceeds the 65535-byte wire field; refusing") + bytes = "ERROR: the answer is ${bytes.size} bytes; the wire carries at most 65535 — it was not sent\n" + .toByteArray(Charsets.UTF_8) + } val out = con.outputStream out.write(bytes.size and 0xff) out.write((bytes.size shr 8) and 0xff) diff --git a/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt b/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt index 2a88691..bbc0c8d 100644 --- a/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt @@ -21,7 +21,15 @@ object CallbackApi : ApiFeature { val url = request.str("url") ?: return "ERROR: callback needs a url\n" val method = (request.str("method") ?: "POST").uppercase() val payload = request.str("data") ?: "" - val timeout = (request.int("timeout") ?: 10) * 1000 + val timeoutSeconds = request.int("timeout") ?: 10 + if (timeoutSeconds <= 0 || timeoutSeconds > Int.MAX_VALUE / 1000) { + // Zero would disable both deadlines and leave the request + // blocking for good; a large value multiplied into + // milliseconds overflows. Both are rejected, not silently + // converted. + return "ERROR: the callback timeout must be between 1 and ${Int.MAX_VALUE / 1000} seconds\n" + } + val timeout = timeoutSeconds * 1000 return try { val conn = URL(url).openConnection() as HttpURLConnection conn.connectTimeout = timeout @@ -34,7 +42,19 @@ object CallbackApi : ApiFeature { conn.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } } val code = conn.responseCode - val body = conn.inputStream.bufferedReader().readText().take(512) + // The body is read in bounded pieces and stops at 512 + // characters: readText() would hold a hostile server's whole + // response in memory before the take() threw most of it away. + val body = conn.inputStream.bufferedReader().use { reader -> + val sb = StringBuilder() + val buf = CharArray(512) + while (sb.length < 512) { + val n = reader.read(buf, 0, 512 - sb.length) + if (n < 0) break + sb.append(buf, 0, n) + } + sb.toString() + } conn.disconnect() "HTTP $code $body\n" } catch (e: Exception) { diff --git a/app/src/main/java/com/redtermapp/util/api/MediaApis.kt b/app/src/main/java/com/redtermapp/util/api/MediaApis.kt index 4a99276..8646239 100644 --- a/app/src/main/java/com/redtermapp/util/api/MediaApis.kt +++ b/app/src/main/java/com/redtermapp/util/api/MediaApis.kt @@ -110,8 +110,13 @@ object ToastApi : ApiFeature { val message = request.extras.str("message") ?: return "ERROR: toast needs a message\n" val long = request.bool("long") == true - val toast = android.widget.Toast.makeText(context, message, if (long) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT) - Handler(Looper.getMainLooper()).post { toast.show() } + // Create and show on the main thread: the request thread is a + // bridge worker, and a Toast constructed off the main looper is + // the configuration some OEM firmwares reject. + Handler(Looper.getMainLooper()).post { + val toast = android.widget.Toast.makeText(context, message, if (long) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT) + toast.show() + } return "" } } diff --git a/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt b/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt index a122056..38903cb 100644 --- a/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt @@ -22,7 +22,9 @@ object MediaPlaybackApi : ApiFeature { val op = request.str("op") ?: "info" return when (op) { "play" -> { - val file = request.str("file") ?: return "ERROR: play needs a file\n" + val guestFile = request.str("file") ?: return "ERROR: play needs a file\n" + val file = ApiPaths.hostPath(request, guestFile) + ?: return ApiPaths.unreachable(guestFile) val p = MediaPlayer().apply { setDataSource(context, android.net.Uri.fromFile(java.io.File(file))) prepare() diff --git a/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt b/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt index 5dd5dcd..f232868 100644 --- a/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt @@ -29,7 +29,12 @@ object MediaScannerApi : ApiFeature { val verbose = request.bool("verbose") == true var total = 0 val out = StringBuilder() - for (path in paths) { + for (guestPath in paths) { + val path = ApiPaths.hostPath(request, guestPath) + if (path == null) { + out.append("Warning: not scannable: ").append(guestPath).append('\n') + continue + } val file = File(path) when { file.isFile -> { @@ -38,19 +43,26 @@ object MediaScannerApi : ApiFeature { } file.isDirectory -> { val queue = ArrayDeque() + // A directory is visited at most once, by canonical + // path: a symlink that loops back into an ancestor + // (or two names for the same tree, as /sdcard and + // /storage/emulated both reach it) would otherwise + // queue the loop forever. + val visited = HashSet() queue.add(file) while (queue.isNotEmpty()) { val dir = queue.removeFirst() for (entry in dir.listFiles().orEmpty()) { - if (entry.isDirectory && recursive) queue.add(entry) - else if (entry.isFile) { + if (entry.isDirectory && recursive) { + if (visited.add(entry.canonicalPath)) queue.add(entry) + } else if (entry.isFile) { scan(context, entry, out, verbose) total++ } } } } - else -> out.append("Warning: does not exist: ").append(path).append('\n') + else -> out.append("Warning: does not exist: ").append(guestPath).append('\n') } } out.append("Finished scanning ").append(total).append(" file(s)\n") diff --git a/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt index 4dc88a6..2aa7209 100644 --- a/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt +++ b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt @@ -62,21 +62,18 @@ object NetworkApi : ApiFeature { val sb = StringBuilder("[") interfaces.forEachIndexed { i, iface -> if (i > 0) sb.append(',') - sb.append("{\"name\":").append(json(iface.name)) + sb.append("{\"name\":").append(ApiJson.string(iface.name)) sb.append(",\"up\":").append(iface.up) sb.append(",\"addresses\":[") iface.addresses.forEachIndexed { j, addr -> if (j > 0) sb.append(',') - sb.append(json(addr)) + sb.append(ApiJson.string(addr)) } sb.append("]}") } sb.append(']') return sb.toString() } - - private fun json(value: String): String = - "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" } /** @@ -158,8 +155,8 @@ object WifiApi : ApiFeature { results.forEachIndexed { i, r -> if (i > 0) sb.append(',') sb.append('{') - if (ssids != null) sb.append("\"ssid\":").append(json(ssids[i])).append(',') - sb.append("\"bssid\":").append(json(r.BSSID ?: "")) + if (ssids != null) sb.append("\"ssid\":").append(ApiJson.string(ssids[i])).append(',') + sb.append("\"bssid\":").append(ApiJson.string(r.BSSID ?: "")) sb.append(",\"level\":").append(r.level) sb.append(",\"frequency\":").append(r.frequency) sb.append('}') @@ -167,7 +164,4 @@ object WifiApi : ApiFeature { sb.append(']') return sb.toString() } - - private fun json(value: String): String = - "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" } diff --git a/app/src/main/java/com/redtermapp/util/api/NfcApi.kt b/app/src/main/java/com/redtermapp/util/api/NfcApi.kt index 854914a..bee077c 100644 --- a/app/src/main/java/com/redtermapp/util/api/NfcApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/NfcApi.kt @@ -27,6 +27,10 @@ object NfcApi : ApiFeature { "enable", "disable" -> "ERROR: toggling NFC needs the system settings screen; enable/disable it there, then use -l or -w\n" "read" -> { if (adapter == null) return "ERROR: this device has no NFC hardware\n" + // enableReaderMode returns without registering anything + // while the radio is off, so the request would wait out + // its whole timeout for no reason; say so up front. + if (!adapter.isEnabled) return "ERROR: NFC is disabled; enable it in system settings, then retry\n" ApiUiBridge.show( context, ApiNfcActivity::class.java, @@ -36,6 +40,7 @@ object NfcApi : ApiFeature { "write" -> { if (adapter == null) return "ERROR: this device has no NFC hardware\n" val value = request.str("value") ?: return "ERROR: write needs a value\n" + if (!adapter.isEnabled) return "ERROR: NFC is disabled; enable it in system settings, then retry\n" ApiUiBridge.show( context, ApiNfcActivity::class.java, diff --git a/app/src/main/java/com/redtermapp/util/api/SafApi.kt b/app/src/main/java/com/redtermapp/util/api/SafApi.kt index c7fa36e..f364f6b 100644 --- a/app/src/main/java/com/redtermapp/util/api/SafApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/SafApi.kt @@ -51,7 +51,7 @@ object SafApi : ApiFeature { perms.forEachIndexed { i, p -> if (i > 0) sb.append(',') sb.append('{') - .append("\"uri\":").append(json(p.uri.toString())) + .append("\"uri\":").append(ApiJson.string(p.uri.toString())) .append(",\"tree\":").append(if (p.uri.path?.startsWith("/tree/") == true) "true" else "false") .append(",\"read\":").append(p.isReadPermission.toString()) .append(",\"write\":").append(p.isWritePermission.toString()) @@ -89,8 +89,8 @@ object SafApi : ApiFeature { if (!first) sb.append(",") first = false sb.append('{') - .append("\"name\":").append(json(c.getString(c.getColumnIndexOrThrow("document_id")) ?: "")) - .append(",\"mime\":").append(json(c.getString(c.getColumnIndexOrThrow("mime_type")) ?: "")) + .append("\"name\":").append(ApiJson.string(c.getString(c.getColumnIndexOrThrow("document_id")) ?: "")) + .append(",\"mime\":").append(ApiJson.string(c.getString(c.getColumnIndexOrThrow("mime_type")) ?: "")) .append('}') } } @@ -124,7 +124,26 @@ object SafApi : ApiFeature { return try { context.contentResolver.openInputStream(uri.toUri()).use { input -> if (input == null) "ERROR: the provider refused the read\n" - else input.readBytes().toString(Charsets.UTF_8) + else { + // A provider stream is unbounded; readBytes() would hand + // a large document to the heap whole. The cap turns an + // oversized read into an answer instead of an + // OutOfMemoryError (which is not an Exception, so the + // catches below could not catch it anyway). + val out = java.io.ByteArrayOutputStream() + val chunk = ByteArray(8192) + var total = 0 + while (true) { + val n = input.read(chunk) + if (n < 0) break + total += n + if (total > 8 * 1024 * 1024) { + return "ERROR: the document is larger than 8 MiB\n" + } + out.write(chunk, 0, n) + } + out.toByteArray().toString(Charsets.UTF_8) + } } } catch (e: java.lang.SecurityException) { "ERROR: no permission for that document URI: grant its tree with redterm-saf-grant first\n" @@ -155,7 +174,18 @@ object SafApi : ApiFeature { val uri = request.str("uri") ?: return "ERROR: removeDocument needs a document URI\n" val docUri = uri.toUri() return try { - val parent = DocumentsContract.getDocumentId(docUri).substringBeforeLast('/') + // DocumentsContract.removeDocument needs the parent's document + // URI, and the only portable signal for the parent is the + // child's document ID: the DocumentsUI tree provider encodes + // the path in it, so a slash means an inferable parent. A + // provider with opaque child IDs has no ancestry in the ID, + // and guessing one is how a delete lands on the wrong + // document — so the honest answer is "not supported here". + val docId = DocumentsContract.getDocumentId(docUri) + if ('/' !in docId) { + return "ERROR: this provider's document id '$docId' does not encode a parent; deletion is not supported for it\n" + } + val parent = docId.substringBeforeLast('/') val parentUri = DocumentsContract.buildDocumentUriUsingTree(docUri, parent) val ok = DocumentsContract.removeDocument(context.contentResolver, docUri, parentUri) if (ok) "1\n" else "0\n" @@ -165,6 +195,4 @@ object SafApi : ApiFeature { "ERROR: removeDocument failed: ${e.message}\n" } } - - private fun json(s: String): String = "\"${s.replace("\"", "\\\"")}\"" } diff --git a/app/src/main/java/com/redtermapp/util/api/SensorApi.kt b/app/src/main/java/com/redtermapp/util/api/SensorApi.kt index ea6d5a9..6b0f8c2 100644 --- a/app/src/main/java/com/redtermapp/util/api/SensorApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/SensorApi.kt @@ -51,11 +51,17 @@ object SensorApi : ApiFeature { val delayUs = ((request.int("delay") ?: 200) * 1000).coerceAtLeast(1000) val sensor = manager.getSensorList(Sensor.TYPE_ALL).firstOrNull { it.name == wanted } ?: return "ERROR: no sensor named '$wanted'\n" - val samples = ArrayList() + // The listener runs on the sensor thread and this request thread + // reads the result, so the list is shared across threads: it is + // guarded by its own monitor, and the check-then-add is made + // atomic by a counter, because synchronizedList synchronises + // each call but not the pair. + val samples = java.util.Collections.synchronizedList(ArrayList()) + val taken = java.util.concurrent.atomic.AtomicInteger() val latch = CountDownLatch(limit) val listener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { - if (samples.size < limit) { + if (taken.incrementAndGet() <= limit) { samples.add(event.values.copyOf()) latch.countDown() } @@ -68,8 +74,11 @@ object SensorApi : ApiFeature { // silent, so a request thread can never be held forever. latch.await((limit * 500L).coerceAtLeast(2000L), TimeUnit.MILLISECONDS) manager.unregisterListener(listener) + // No callback can add after unregister, but take a snapshot under + // the monitor so the iteration itself is safe. + val result = synchronized(samples) { samples.toList() } val sb = StringBuilder("[") - for ((i, values) in samples.withIndex()) { + for ((i, values) in result.withIndex()) { if (i > 0) sb.append(",") sb.append('[').append(values.joinToString(",")).append(']') } diff --git a/app/src/main/java/com/redtermapp/util/api/ShareApi.kt b/app/src/main/java/com/redtermapp/util/api/ShareApi.kt index 769fd2a..eb9ff6d 100644 --- a/app/src/main/java/com/redtermapp/util/api/ShareApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/ShareApi.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider +import com.redtermapp.util.AppLog import com.redtermapp.util.api.ApiRequestParser.ApiRequest import java.io.File @@ -27,9 +28,14 @@ object ShareApi : ApiFeature { return "ERROR: share needs text and/or files\n" } val intent = if (files.isNotEmpty()) { + val uris = filesToUris(context, request, files) + ?: return "ERROR: a file could not be shared (see the app log for which)\n" Intent(Intent.ACTION_SEND_MULTIPLE) .setType(request.str("mime") ?: "*/*") - .putExtra(Intent.EXTRA_STREAM, filesToUris(context, files).toTypedArray()) + // ACTION_SEND_MULTIPLE's contract expects the streams in an + // ArrayList extra; a typed array is what receivers that + // follow the contract omit or reject. + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } else { Intent(Intent.ACTION_SEND) @@ -41,11 +47,17 @@ object ShareApi : ApiFeature { return "" } - private fun filesToUris(context: Context, paths: List): List = paths.map { path -> - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - File(path) - ) + /** The provider URIs for the guest-named files, or null when one cannot be projected. */ + private fun filesToUris(context: Context, request: ApiRequest, paths: List): List? { + val uris = ArrayList(paths.size) + for (path in paths) { + val host = ApiPaths.hostPath(request, path) + if (host == null) { + AppLog.w(context, "api-share", "cannot share $path: ${ApiPaths.unreachable(path)}") + return null + } + uris.add(FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", File(host))) + } + return uris } } diff --git a/app/src/main/java/com/redtermapp/util/api/SsidApi.kt b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt index 8cbb792..c09a59a 100644 --- a/app/src/main/java/com/redtermapp/util/api/SsidApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt @@ -55,17 +55,17 @@ object SsidApi : ApiFeature { } return when (request.str("op") ?: "ssid") { - "ssid" -> "\"$ssid\"\n" + "ssid" -> ApiJson.string(ssid) + "\n" "info" -> { val sb = StringBuilder("{") sb.append("\"connected\":").append(connected) - sb.append(",\"ssid\":").append(json(ssid)) - sb.append(",\"ssid_source\":").append(json(source)) + sb.append(",\"ssid\":").append(ApiJson.string(ssid)) + sb.append(",\"ssid_source\":").append(ApiJson.string(source)) if (best != null) { - sb.append(",\"bssid\":").append(json(best.BSSID ?: "")) + sb.append(",\"bssid\":").append(ApiJson.string(best.BSSID ?: "")) sb.append(",\"level\":").append(best.level) sb.append(",\"frequency\":").append(best.frequency) - sb.append(",\"capabilities\":").append(json(best.capabilities ?: "")) + sb.append(",\"capabilities\":").append(ApiJson.string(best.capabilities ?: "")) } sb.append('}') sb.toString() + "\n" @@ -150,7 +150,4 @@ object SsidApi : ApiFeature { } catch (e: Exception) { "" } - - private fun json(s: String): String = - "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}" } diff --git a/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt b/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt index 5646837..e80b8d6 100644 --- a/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt @@ -16,12 +16,17 @@ object StorageGetApi : ApiFeature { override val permissions = emptySet() override fun run(context: Context, request: ApiRequest): String { - val path = request.str("file") ?: return "ERROR: file path not passed\n" + val guestPath = request.str("file") ?: return "ERROR: file path not passed\n" + val path = ApiPaths.hostPath(request, guestPath) + ?: return ApiPaths.unreachable(guestPath) val f = File(path) - if (!f.exists()) return "ERROR: $path does not exist\n" + if (!f.exists()) return "ERROR: $guestPath does not exist\n" val sb = StringBuilder("{") sb.append("\"name\":").append(json(f.name)) - sb.append(",\"uri\":\"file://$path\"") + // The answer speaks the guest's namespace: the caller named a + // guest path and gets a guest path back, not the app's internal + // projection of it. + sb.append(",\"uri\":\"file://$guestPath\"") sb.append(",\"type\":").append(if (f.isDirectory) "\"dir\"" else "\"file\"") if (f.isFile) { sb.append(",\"length\":").append(f.length()) diff --git a/app/src/main/java/com/redtermapp/util/api/TtsApi.kt b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt index 829966d..74fefcc 100644 --- a/app/src/main/java/com/redtermapp/util/api/TtsApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt @@ -64,16 +64,14 @@ object TtsApi : ApiFeature { } val lang = request.str("language") if (lang != null) { - // The three-argument Locale constructor is withdrawn on API 33 in - // favour of Locale.of, and Locale.of is absent below 33; - // forLanguageTag predates both, is non-deprecated on every level - // the app runs on, and takes the same parts as a BCP-47 tag, - // where empty segments are skipped — the old constructor's own - // treatment of empty strings. - val tag = listOf(lang, request.str("region"), request.str("variant")) - .filter { !it.isNullOrEmpty() } - .joinToString("-") - tts.setLanguage(if (tag.isEmpty()) Locale.getDefault() else Locale.forLanguageTag(tag)) + // The three-argument constructor preserves each part exactly + // (language / region-as-country / variant) and is not + // deprecated on any level this build compiles against — + // confirmed against the SDK jar, so it carries no warning. + // Empty parts pass through as the constructor normalises + // them. forLanguageTag was wrong here: BCP-47 round-trips + // the tag, not the three separately-set parts. + tts.setLanguage(Locale(lang, request.str("region") ?: "", request.str("variant") ?: "")) } request.float("rate")?.let { tts.setSpeechRate(it) } request.float("pitch")?.let { tts.setPitch(it) } diff --git a/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt index 0333199..5b6380b 100644 --- a/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt +++ b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt @@ -1,6 +1,8 @@ package com.redtermapp.util.api +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.net.wifi.WifiManager import com.redtermapp.util.api.ApiRequestParser.ApiRequest @@ -16,7 +18,18 @@ object WifiEnableApi : ApiFeature { override fun run(context: Context, request: ApiRequest): String { val manager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager - val enabled = request.bool("enabled") == true + // The `enabled` extra must be present *and* a boolean. A missing or + // int-typed flag must not silently read as "turn it off" — that is + // how a typo kills the device's connectivity. + val enabled = request.bool("enabled") + ?: return "ERROR: wifi-enable needs the enabled flag (--ez enabled true|false)\n" + // Flipping the radio is a CHANGE_WIFI_STATE grant. This build trims + // the manifest to a core that does not declare it, so check whether + // it is held before touching the reflective call: a denial here is + // a missing declaration, not a missing runtime grant. + if (context.checkSelfPermission(Manifest.permission.CHANGE_WIFI_STATE) != PackageManager.PERMISSION_GRANTED) { + return "ERROR: not available in this build: ${Manifest.permission.CHANGE_WIFI_STATE} is not declared\n" + } val state = manager.wifiState if (enabled && state == WifiManager.WIFI_STATE_ENABLED) return "already on\n" if (!enabled && state == WifiManager.WIFI_STATE_DISABLED) return "already off\n" diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt index e2c759b..e1dbdbd 100644 --- a/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt @@ -142,6 +142,12 @@ class ApiDialogActivity : Activity() { val parts = range.split(',') val min = parts.getOrNull(0)?.toIntOrNull() ?: 0 val max = parts.getOrNull(1)?.toIntOrNull() ?: 100 + if (min > max) { + // coerceIn(min, max) below would throw on such a range and + // kill the process; the answer says so instead. + answer("ERROR: counter range minimum is greater than maximum\n") + return + } val start = parts.getOrNull(2)?.toIntOrNull() ?: min val input = EditText(this).apply { setText(String.format(java.util.Locale.getDefault(), "%d", start)) diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt index b441411..110aa9b 100644 --- a/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt @@ -19,26 +19,43 @@ import android.os.Bundle class ApiNfcActivity : Activity() { private var adapter: NfcAdapter? = null + private var kind: String = "" + @Volatile private var answered = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + kind = intent.getStringExtra("redterm.api.kind") ?: "" + enableReaderMode() + } + + private fun enableReaderMode() { val adapter = NfcAdapter.getDefaultAdapter(this) if (adapter == null) { - ApiUiBridge.postResult(this, "ERROR: this device has no NFC hardware\n") + postOnce("ERROR: this device has no NFC hardware\n") finish() return } this.adapter = adapter - when (intent.getStringExtra("redterm.api.kind")) { + when (kind) { "nfc-read" -> adapter.enableReaderMode(this, readCallback, 0, null) "nfc-write" -> adapter.enableReaderMode(this, writeCallback, 0, null) else -> { - ApiUiBridge.postResult(this, "ERROR: unknown NFC kind\n") + postOnce("ERROR: unknown NFC kind\n") finish() } } } + override fun onResume() { + super.onResume() + // onPause disables reader mode and Android does not restore it, + // so a tag tapped right after the pause/resume transition would + // be missed and the waiting request would run out its timeout. + // Re-enable it for the stored request kind; an answered session + // has no reason to read tags again. + if (!answered) enableReaderMode() + } + override fun onPause() { super.onPause() val adapter = adapter @@ -48,11 +65,25 @@ class ApiNfcActivity : Activity() { this.adapter = null } + override fun onDestroy() { + super.onDestroy() + // Back or a swipe destroys the activity without any tag: without + // this, the request thread is left to time out instead of + // receiving the dismissal it was dismissed with. + if (isFinishing) postOnce("ERROR: the NFC dialog was dismissed before a tag\n") + } + + private fun postOnce(result: String) { + if (answered) return + answered = true + ApiUiBridge.postResult(this, result) + } + private val readCallback = object : NfcAdapter.ReaderCallback { override fun onTagDiscovered(tag: Tag) { val ndef = Ndef.get(tag) if (ndef == null) { - ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: the tag has no NDEF chip\n") + postOnce("ERROR: the tag has no NDEF chip\n") finish() return } @@ -64,7 +95,7 @@ class ApiNfcActivity : Activity() { ndef.close() } }.getOrNull() - ApiUiBridge.postResult(this@ApiNfcActivity, dump(message)) + postOnce(dump(message)) finish() } } @@ -82,7 +113,7 @@ class ApiNfcActivity : Activity() { ) val ndef = Ndef.get(tag) if (ndef == null) { - ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: the tag has no NDEF chip\n") + postOnce("ERROR: the tag has no NDEF chip\n") finish() return } @@ -90,16 +121,16 @@ class ApiNfcActivity : Activity() { ndef.connect() try { if (!ndef.isWritable) { - ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: the tag is read-only\n") + postOnce("ERROR: the tag is read-only\n") return } ndef.writeNdefMessage(NdefMessage(record)) } finally { ndef.close() } - ApiUiBridge.postResult(this@ApiNfcActivity, "\"$value\"") + postOnce("\"$value\"") } catch (e: Exception) { - ApiUiBridge.postResult(this@ApiNfcActivity, "ERROR: write failed: ${e.message}\n") + postOnce("ERROR: write failed: ${e.message}\n") } finish() } diff --git a/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt b/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt index b2975e0..a765501 100644 --- a/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt @@ -25,14 +25,26 @@ object ApiUiBridge { private val slots = ConcurrentHashMap() + /** The longest a dialog is held for an answer; longer waits are refused up front. */ + const val MAX_DIALOG_TIMEOUT_SEC = 300 + /** * Start [activity] carrying [spec] and block until it answers or * [spec] times out. The returned string is the Activity's JSON * result, or a readable ERROR line when the slot timed out. */ fun show(context: Context, activity: Class, spec: ApiUiSpec): String { + // Validate the timeout before it is converted: a non-positive + // value makes the latch return at once and the opened dialog is + // answered into the void, and a huge value pins this request + // thread (and the slot) until the activity answers, which never + // happens if it is killed. Both are refused, not converted. + if (spec.timeoutSec <= 0) { + return "ERROR: the dialog timeout must be at least 1 second\n" + } + val timeoutSec = spec.timeoutSec.coerceAtMost(MAX_DIALOG_TIMEOUT_SEC) val id = UUID.randomUUID().toString() - val slot = Slot(CountDownLatch(1), spec.timeoutSec * 1000L) + val slot = Slot(CountDownLatch(1), timeoutSec * 1000L) slots[id] = slot val intent = Intent(context, activity).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -45,9 +57,9 @@ object ApiUiBridge { slot.latch.countDown() return "ERROR: could not show the UI: ${e.message}\n" } - val timedOut = !slot.latch.await(spec.timeoutSec + 15L, TimeUnit.SECONDS) + val timedOut = !slot.latch.await(timeoutSec + 15L, TimeUnit.SECONDS) slots.remove(id) - if (timedOut) return "ERROR: the dialog timed out after ${spec.timeoutSec}s\n" + if (timedOut) return "ERROR: the dialog timed out after ${timeoutSec}s\n" return slot.result ?: "ERROR: the dialog ended without a result\n" } diff --git a/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt b/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt index 589785a..58985ec 100644 --- a/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt +++ b/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt @@ -10,25 +10,35 @@ import org.junit.Test * The user-managed startup hook, asserted by running it — the same reason the * generated `.bashrc` is sourced by a real bash rather than compared as text. * - * The guard block is the exact shape [DistroSetup.buildStartupScript] emits - * (pinned, line by line, in DistroSetupTest): it runs /root/.startup.local as - * a separate process, only when the launcher marked the session interactive, - * and only when the file exists. + * The guard block under test is *the one [DistroSetup.buildStartupScript] + * actually emits*, extracted from its output and re-pointed at the test's + * temp file — not a hand copy. A copy diverges silently from the generator + * (the way a test that only asserts the api bind appears somewhere passes on + * every build that carries the bug); the extraction keeps the two in lockstep. */ class StartupHookSourcingTest { private val sh: String? = sequenceOf("/bin/sh", "/usr/bin/sh") .firstOrNull { File(it).canExecute() } - /** The guard as generated, with the hook path substituted for the test's temp file. */ - private fun guardBlock(hook: String): String = - "if [ -n \"\$REDTERM_INTERACTIVE_SESSION\" ] && [ -f $hook ]; then\n" + - " /bin/sh $hook\n" + - " hook_status=\$?\n" + - " if [ \"\$hook_status\" -ne 0 ]; then\n" + - " echo \"RedTerm: $hook reported an error (exit \$hook_status) — continuing the session\"\n" + - " fi\n" + - "fi\n" + /** The guard exactly as [DistroSetup.buildStartupScript] emits it, re-pointed at [hook]. */ + private fun guardBlock(hook: String): String { + val lines = DistroSetup.buildStartupScript("debian").lineSequence().toList() + // The guard is the only place the interactive marker is referenced; + // it opens one `if` that contains a nested `if`, so it closes after + // the second `fi` — the outer one. + val start = lines.indexOfFirst { it.contains("REDTERM_INTERACTIVE_SESSION") } + check(start >= 0) { "generated startup script lost the interactive guard" } + val block = ArrayList() + var fiSeen = 0 + for (line in lines.subList(start, lines.size)) { + block.add(line) + if (line.trim() == "fi") fiSeen++ + if (fiSeen == 2) break + } + check(fiSeen == 2) { "generated guard did not close: $block" } + return block.joinToString("\n") { it.replace("/root/.startup.local", hook) } + "\n" + } private class Run(val out: String, val err: String, val code: Int) diff --git a/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt b/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt index 16844e2..7e98457 100644 --- a/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt +++ b/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt @@ -77,18 +77,33 @@ class ApiHelperTest { } @Test - fun `a hand-edited script is replaced on the next install`() { + fun `a hand-edited managed script is replaced on the next install`() { val rootfs = tempRootfs() ApiHelper.installGuestScripts(null, rootfs) val f = File(rootfs, "usr/local/bin/termux-battery-status") - f.writeText("#!/bin/sh\necho something the distro changed\n") + // A hand edit that keeps the marker is still RedTerm's file: the + // corrected body must arrive. + f.writeText("#!/bin/sh\n# managed by RedTerm\necho something the distro changed\n") ApiHelper.installGuestScripts(null, rootfs) assertTrue( "the corrected script never arrives", - f.readText().contains("managed by RedTerm") + f.readText().contains("# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION})") ) } + @Test + fun `an unmanaged script is left in place`() { + val rootfs = tempRootfs() + ApiHelper.installGuestScripts(null, rootfs) + val f = File(rootfs, "usr/local/bin/termux-battery-status") + // No marker: not a file RedTerm wrote, so an install must not + // destroy it. + val userScript = "#!/bin/sh\necho the user's own script\n" + f.writeText(userScript) + ApiHelper.installGuestScripts(null, rootfs) + assertEquals("an unmanaged script must survive an install", userScript, f.readText()) + } + @Test fun `an unchanged install does not rewrite the files`() { val rootfs = tempRootfs() @@ -108,21 +123,15 @@ class ApiHelperTest { val bin = File(rootfs, "usr/local/bin") val scripts = bin.listFiles().orEmpty() assertTrue("the full script set must arrive, got ${scripts.size}", scripts.size >= 15) - val bridge = ApiHelper.guestScripts.keys for (f in scripts) { val text = f.readText() assertTrue("${f.name} lost its shebang", text.startsWith("#!/bin/sh\n")) + // Every RedTerm-installed script carries the marker — it is what + // makes the file managed and eligible for replacement. + assertTrue("${f.name} lost its marker", + text.contains("# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION})")) assertTrue("${f.name} leaked a placeholder", !text.contains("__EXEC__") && !text.contains("__A__")) - // The bridge marker is only on bridge scripts; the pure guest-side - // tools carry their own header and no helper guard. - if (f.name in bridge) { - assertTrue("${f.name} lost its marker", - text.contains("# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION})")) - } else { - assertTrue("${f.name} is not a bridge script but carries the bridge marker", - !text.contains("# managed by RedTerm (redterm-api bridge")) - } } } @@ -134,11 +143,12 @@ class ApiHelperTest { val f = File(bin, "redterm-startup-local") assertTrue("redterm-startup-local missing", f.isFile && f.canExecute()) val text = f.readText() - // Pure: no helper-existence guard, no bridge marker. + // Pure: no helper-existence guard, but it is still RedTerm-managed, + // so it carries the marker like every installed script. assertTrue("must not carry the helper guard", !text.contains("the RedTerm API helper is not installed")) - assertTrue("must not carry the bridge marker", - !text.contains("# managed by RedTerm (redterm-api bridge")) + assertTrue("must carry the managed marker", + text.contains("# managed by RedTerm (redterm-api bridge v${ApiHelper.GUEST_SCRIPT_VERSION})")) // It is RedTerm-only: no termux- alias is installed for it. assertTrue("no termux- alias for a RedTerm-only tool", !File(bin, "termux-startup-local").exists()) diff --git a/app/src/test/java/com/redtermapp/util/api/ApiPathsJsonTest.kt b/app/src/test/java/com/redtermapp/util/api/ApiPathsJsonTest.kt new file mode 100644 index 0000000..8bd2270 --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/ApiPathsJsonTest.kt @@ -0,0 +1,113 @@ +package com.redtermapp.util.api + +import com.redtermapp.util.api.ApiRequestParser.ApiExtras +import com.redtermapp.util.api.ApiRequestParser.ApiRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The two pure objects the bridge's path- and string-projection depends on: + * a guest path only becomes a host path when the session carries its rootfs + * origin (or the path is under a proot bind), and a JSON string is only + * valid when every control character is escaped. Both are asserted by value + * so a regression in the projection or the encoder shows up here, not in a + * half-broken share sheet or an invalid SSID on a real device. + */ +class ApiPathsJsonTest { + + private val rootfs = "/data/user/0/redterm/rootfs" + private fun requestWithRootfs(): ApiRequest = + ApiRequest("a", ApiExtras(strings = mapOf(ApiPaths.EXTRA_ROOTFS_HOST to rootfs))) + private fun requestWithoutRootfs(): ApiRequest = ApiRequest("a", ApiExtras()) + + // --- ApiPaths.hostPath ------------------------------------------------- + + @Test + fun `a path under a proot bind is the same string on both sides`() { + for (p in listOf("/sdcard/DCIM/a.jpg", "/system/bin/sh", "/sdcard/Download/b")) { + assertEquals(p, ApiPaths.hostPath(requestWithRootfs(), p)) + } + } + + @Test + fun `an absolute guest path is projected under the session rootfs`() { + assertEquals("$rootfs/root/photo.jpg", ApiPaths.hostPath(requestWithRootfs(), "/root/photo.jpg")) + } + + @Test + fun `a relative path cannot be projected`() { + assertNull(ApiPaths.hostPath(requestWithRootfs(), "photo.jpg")) + } + + @Test + fun `an absolute path with no session rootfs origin is unreachable`() { + assertNull(ApiPaths.hostPath(requestWithoutRootfs(), "/root/photo.jpg")) + } + + @Test + fun `a path that escapes the rootfs by traversal is rejected`() { + assertNull(ApiPaths.hostPath(requestWithRootfs(), "/../../etc/passwd")) + assertNull(ApiPaths.hostPath(requestWithRootfs(), "/root/../../etc/shadow")) + } + + @Test + fun `an empty rootfs extra behaves as no rootfs origin`() { + val empty = ApiRequest("a", ApiExtras(strings = mapOf(ApiPaths.EXTRA_ROOTFS_HOST to ""))) + assertNull(ApiPaths.hostPath(empty, "/root/photo.jpg")) + } + + @Test + fun `unreachable names the path`() { + assertEquals( + "ERROR: '/root/x' is not reachable from the app process: guest paths must be absolute " + + "(a relative one needs the guest's realpath), and the session must carry its rootfs origin\n", + ApiPaths.unreachable("/root/x") + ) + } + + // --- ApiJson.string ---------------------------------------------------- + + @Test + fun `plain text is quoted unchanged`() { + assertEquals("\"hello\"", ApiJson.string("hello")) + } + + @Test + fun `quotes and backslashes are escaped`() { + assertEquals("\"a\\\"b\"", ApiJson.string("a\"b")) + assertEquals("\"a\\\\b\"", ApiJson.string("a\\b")) + } + + @Test + fun `the common control characters get their short escapes`() { + assertEquals("\"a\\nb\"", ApiJson.string("a\nb")) + assertEquals("\"a\\tb\"", ApiJson.string("a\tb")) + assertEquals("\"a\\rb\"", ApiJson.string("a\rb")) + } + + @Test + fun `rare control characters are escaped as unicode`() { + // 0x01 has no short escape; it must come out as \u0001, not raw. + assertEquals("\"\\u0001\"", ApiJson.string("\u0001")) + // 0x1F (unit separator) is likewise in the 0x00-0x1F range. + assertEquals("\"\\u001f\"", ApiJson.string("\u001f")) + } + + @Test + fun `DEL and above are passed through`() { + // 0x7F is at/above 0x20, so it is kept as the literal character, + // not escaped. The result is quote + DEL + quote. + assertEquals("\"\u007f\"", ApiJson.string("\u007f")) + } + + @Test + fun `an SSID full of every escape kind stays one valid JSON string`() { + val ssid = "cafe\n\u0001tab\t\"q\"\\slash" + val encoded = ApiJson.string(ssid) + // Round-trip through a real JSON decoder proves the output is valid + // JSON, not merely that a few characters changed. + val parsed = org.json.JSONObject("{\"s\": $encoded}").getString("s") + assertEquals(ssid, parsed) + } +} diff --git a/native/redterm-api/redterm-api.c b/native/redterm-api/redterm-api.c index 42ca226..cac2ebf 100644 --- a/native/redterm-api/redterm-api.c +++ b/native/redterm-api/redterm-api.c @@ -153,6 +153,34 @@ int main(int argc, char **argv) } } + /* + * The launcher exports REDTERM_ROOTFS_HOST: the host path of the + * rootfs this session runs in. The app process cannot open guest + * paths directly (they live under that directory), so the origin + * travels with every request and the app projects guest paths to + * host paths. A session started any other way has no such variable + * and nothing is sent. + */ + { + const char *rootfsHost = getenv("REDTERM_ROOTFS_HOST"); + if (rootfsHost != NULL && rootfsHost[0] != '\0') { + const char *prefix = "--es redterm-rootfs-host"; + if (pos + 1 >= MAX_REQUEST) { + fprintf(stderr, "redterm-api: request too long\n"); + return 1; + } + if (pos > 0) request[pos++] = ' '; + while (*prefix != 0) { + if (pos + 1 >= MAX_REQUEST) { + fprintf(stderr, "redterm-api: request too long\n"); + return 1; + } + request[pos++] = *prefix++; + } + appendQuoted(request, &pos, rootfsHost); + } + } + int fd = socket(AF_UNIX, SOCK_STREAM, 0); if (fd < 0) { perror("redterm-api: socket"); From 896325d81cc5353d9616f204407a9244fa56dc6d Mon Sep 17 00:00:00 2001 From: GlobalTechInfo Date: Wed, 4 Nov 2026 21:34:38 +0500 Subject: [PATCH 4/8] api: fix CodeRabbit findings across bridge/parser/ui, rebuild helper, clean build --- CHANGELOG.md | 2 +- app/src/main/AndroidManifest.xml | 3 +- .../assets/redterm-api/aarch64/redterm-api | Bin 10808 -> 10808 bytes .../assets/redterm-api/armv7a/redterm-api | Bin 9160 -> 9176 bytes .../main/assets/redterm-api/i686/redterm-api | Bin 8876 -> 8892 bytes .../assets/redterm-api/x86_64/redterm-api | Bin 10088 -> 10088 bytes .../com/redtermapp/distro/DistroRunner.kt | 2 +- .../com/redtermapp/ui/TerminalActivity.kt | 8 +- .../java/com/redtermapp/util/api/ApiHelper.kt | 82 +++++++------ .../java/com/redtermapp/util/api/ApiPaths.kt | 74 +++++++++--- .../redtermapp/util/api/ApiRequestParser.kt | 43 ++++++- .../java/com/redtermapp/util/api/ApiSocket.kt | 14 +++ .../com/redtermapp/util/api/BatteryApi.kt | 4 +- .../com/redtermapp/util/api/CameraInfoApi.kt | 6 +- .../com/redtermapp/util/api/ClipboardApi.kt | 2 +- .../com/redtermapp/util/api/DownloadApi.kt | 8 +- .../com/redtermapp/util/api/FingerprintApi.kt | 5 + .../com/redtermapp/util/api/InfraredApi.kt | 41 +++++-- .../java/com/redtermapp/util/api/MediaApis.kt | 10 +- .../redtermapp/util/api/MediaPlaybackApi.kt | 9 +- .../redtermapp/util/api/MediaScannerApi.kt | 2 +- .../redtermapp/util/api/NotificationApi.kt | 4 +- .../util/api/NotificationChannelApi.kt | 1 + .../java/com/redtermapp/util/api/SafApi.kt | 52 ++++----- .../java/com/redtermapp/util/api/SensorApi.kt | 6 +- .../java/com/redtermapp/util/api/ShareApi.kt | 3 +- .../java/com/redtermapp/util/api/SsidApi.kt | 76 ++++-------- .../com/redtermapp/util/api/StorageGetApi.kt | 8 +- .../java/com/redtermapp/util/api/TtsApi.kt | 30 +++-- .../java/com/redtermapp/util/api/UsbApi.kt | 2 +- .../util/api/ui/ApiDialogActivity.kt | 6 +- .../redtermapp/util/api/ui/ApiNfcActivity.kt | 110 +++++++++--------- .../com/redtermapp/util/api/ui/ApiUiBridge.kt | 12 +- .../redtermapp/util/api/ApiPathsJsonTest.kt | 37 +++--- native/redterm-api/redterm-api.c | 8 ++ 35 files changed, 412 insertions(+), 258 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9d615..9e95022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,7 +108,7 @@ there, and the refusal says so. - `redterm-battery-status` — level, status, health, plugged, temp, voltage, current - `redterm-audio-info` — playback/recording device info -- `redterm-telephony-deviceinfo` — IMEI/MEID/serial (read reflectively; the privileged ones answer only where the OS allows) +- `redterm-telephony-deviceinfo` — `device_id` (IMEI/MEID) and `sim_serial_number` from the subscription (read reflectively; privileged identifiers answer only where the OS allows) - `redterm-telephony-cellinfo` — current + neighbouring cells - `redterm-wifi-connectioninfo` — active connection (IP, speed, BSSID, frequency; the SSID is masked without a location grant) - `redterm-wifi-enable true|false` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95956d6..3fd6388 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ - +