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..9e95022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,301 @@ 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. 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** + +- `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` — `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` +- `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..3fd6388 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ - + + + + + + + + + + android:taskAffinity="" + android:excludeFromRecents="true" + android:theme="@style/Theme.RedTermApp.Transparent" /> + + + + + + + + + + + /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..a600504 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,25 @@ 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 +# 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 @@ -145,7 +192,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 +222,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..abf9359 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,43 @@ 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) + val scriptsOk = com.redtermapp.util.api.ApiHelper.installGuestScripts(applicationContext, rootfsDir) + if (apiDir != null) { + if (scriptsOk) { + 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", "partial: helper bound at ${com.redtermapp.util.api.ApiHelper.GUEST_BIND_TARGET} but guest scripts could not all be written (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..e68b684 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiHelper.kt @@ -0,0 +1,805 @@ +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 = 7 + + /** 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 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. 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. + */ + @Synchronized + 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 + } + // 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}") + return null + } + context.assets.open("redterm-api/$arch/redterm-api").use { input -> + binary.outputStream().use { output -> input.copyTo(output) } + } + if (!binary.setExecutable(true, false)) { + // The helper runs through proot's user-space loader, so the + // kernel never enforces the bit — but the guest's own shell + // checks it for `./redterm-api`, so record a refusal. + AppLog.w(context, "api-helper", "helper not executable after extraction") + } + 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() + // 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 + } + if (current != body) { + val written = runCatching { script.writeText(body) }.isSuccess + if (!written) { + context?.let { ctx -> AppLog.e(ctx, "api-helper", "script $name not written") } + return false + } + // setExecutable reports a false it does not throw: a script left + // without the execute bit is one that fails at run time, so a + // failed chmod is a failed install, not a success. + if (!script.setExecutable(true, false)) { + context?.let { ctx -> AppLog.e(ctx, "api-helper", "script $name written but not executable") } + return false + } + return true + } + if (!script.canExecute()) { + if (!script.setExecutable(true, false)) { + context?.let { ctx -> AppLog.e(ctx, "api-helper", "script $name present but not executable") } + return false + } + } + return 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" + + "$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" + + " 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=; PATHS=\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" + + " *)\n" + + " # Paths come in as real arguments, not a word-split\n" + + " # string, so a spaced path stays one element. A comma\n" + + " # is the --esa separator, so a comma inside a path is\n" + + " # escaped; a path with none passes through unchanged.\n" + + " case \"\$1\" in\n" + + " *,*) p=\$(printf '%s' \"\$1\" | sed 's/,/\\\\,/g');;\n" + + " *) p=\"\$1\";;\n" + + " esac\n" + + " if [ -z \"\$PATHS\" ]; then PATHS=\"\$p\"; else PATHS=\"\$PATHS,\$p\"; fi\n" + + " shift;;\n" + + " esac\n" + + "done\n" + + "if [ -z \"\$PATHS\" ]; then echo \"media-scan: missing file argument\"; 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" + + "set -- --es op \"\$op\"\n" + + "[ -n \"\$file\" ] && set -- \"\$@\" --es file \"\$file\"\n" + + "[ -n \"\$limit\" ] && set -- \"\$@\" --ei limit \"\$((limit * 1000))\"\n" + + "[ -n \"\$encoder\" ] && set -- \"\$@\" --es encoder \"\$encoder\"\n" + + "[ -n \"\$bitrate\" ] && set -- \"\$@\" --ei bitrate \"\$((bitrate * 1000))\"\n" + + "[ -n \"\$srate\" ] && set -- \"\$@\" --ei srate \"\$srate\"\n" + + "[ -n \"\$channels\" ] && set -- \"\$@\" --ei channels \"\$channels\"\n" + + "exec $EXEC_PATH --user 0 -a $ACTION --es action mic-record \"\$@\"" + ), + "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 +# 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: +# +# 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/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/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/ApiPaths.kt b/app/src/main/java/com/redtermapp/util/api/ApiPaths.kt new file mode 100644 index 0000000..a5b0e42 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiPaths.kt @@ -0,0 +1,100 @@ +package com.redtermapp.util.api + +import android.content.Context +import com.redtermapp.distro.ProotLaunch +import com.redtermapp.util.AppLog +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. + * + * Both the origin and the containment check are untrusted from the guest: + * the origin is an extra the guest's helper sets, and a path may carry `..` + * or a symlink, so each is normalised (and the origin pinned to the app's + * own storage) before it is trusted. + */ +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`, a request without the rootfs extra has no origin, an + * origin that is not one of the app's own rootfs directories is + * rejected, and a `..` or symlink that escapes the rootfs is refused. + */ + fun hostPath(context: Context, request: ApiRequest, guestPath: String): String? { + val rootfsHost = request.str(EXTRA_ROOTFS_HOST) + if (rootfsHost.isNullOrEmpty()) return null + // The origin is guest-supplied, so it is pinned to the app's own + // storage before it is trusted: only one of this app's rootfs + // trees (all under its files dir) may be the projection root. + val origin = File(rootfsHost).absoluteFile.normalize() + if (!isUnder(origin, canonicalOr(context.filesDir))) { + AppLog.w(context, "api-paths", "rootfs origin '$rootfsHost' is outside the app's storage; not projecting '$guestPath'") + return null + } + return project(origin.absolutePath, guestPath) + } + + /** + * Pure projection: given a trusted [origin], the host path for + * [guestPath], or null. The bind check and the containment check live + * here so they are testable without a Context; [hostPath] adds the + * origin validation on top. + */ + fun project(origin: String, guestPath: String): String? { + val path = guestPath.trim() + if (path.isEmpty() || !path.startsWith("/")) return null + // Normalise *first*: a bind prefix must match the path after `..` + // collapse, or `/dev/../data/...` would pass the bind check as-is + // and the app would stat the host path the `..` resolves to. + val lexical = normalizeLexically(path) + for (dir in ProotLaunch.BOUND_DIRS) { + if (lexical == dir || lexical.startsWith("$dir/")) return lexical + } + // Resolve symlinks and `..` at the filesystem level before asking + // whether the target stayed inside the rootfs; a lexical check is + // fooled by a symlink whose target lies outside. + val root = canonicalOr(File(origin)) + val target = canonicalOr(File(root, path.removePrefix("/"))) + return if (target == root || target.path.startsWith(root.path + File.separator)) target.path else null + } + + /** Resolve symlinks; fall back to the lexical path when that is not possible. */ + private fun canonicalOr(f: File): File = + runCatching { f.canonicalFile }.getOrDefault(f.absoluteFile) + + /** Lexical `..`/`.` collapse without touching the filesystem. */ + private fun normalizeLexically(path: String): String = File(path).normalize().path + + private fun isUnder(child: File, parent: File): Boolean { + val c = canonicalOr(child) + val p = canonicalOr(parent) + return c == p || c.path.startsWith(p.path + File.separator) + } + + /** 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 a rootfs origin " + + "that is one of the app's own rootfs trees\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..a9c7845 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiRequestParser.kt @@ -0,0 +1,207 @@ +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 only on commas not escaped. Count backslashes before each + // comma: an odd number means the comma is escaped. + val parts = ArrayList() + val sb = StringBuilder() + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '\\' && i + 1 < raw.length) { + sb.append(c).append(raw[i + 1]) + i += 2 + continue + } + if (c == ',') { + // Count backslashes immediately before this comma + var backslashCount = 0 + var j = sb.length - 1 + while (j >= 0 && sb[j] == '\\') { + backslashCount++ + j-- + } + if (backslashCount % 2 == 1) { + // Comma is escaped + sb.append(c) + i++ + continue + } else { + parts.add(sb.toString()) + sb.setLength(0) + i++ + continue + } + } + sb.append(c) + i++ + } + parts.add(sb.toString()) + stringArrayExtras[key] = parts + .map { unescape(it) } + .toTypedArray() + } else { + stringExtras[key] = unescape(raw) + } + } + var rest = blanked(cmdline, stringSpans) + + var m = EXTRA_BOOLEAN.matcher(rest) + while (m.find()) { + val key = m.group(1) ?: continue + val raw = m.group(2) + val arg = when (raw?.lowercase()) { + "true", "t" -> 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..88b320a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ApiSocket.kt @@ -0,0 +1,263 @@ +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 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 + * 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" + + /** + * 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 + + @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 + // 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 + } + listener = listen + running = true + Thread({ + serve(app, listen) + }, "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 + + /** The bound listener; [stop] closes it so a blocked `accept` returns. */ + @Volatile + private var listener: LocalServerSocket? = null + + fun stop() { + stopRequested = true + running = false + // Closing the bound socket is what releases the accept loop; the + // flag alone leaves it blocked in accept() for the life of the + // process. The in-flight request threads finish on their own. + val l = listener + if (l != null) { + runCatching { l.close() } + listener = null + } + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun serve(app: Context, listen: LocalServerSocket) { + try { + 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. 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({ + try { + handleConnection(con, app) + } finally { + requestSlots.release() + } + }, "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 + listener = null + 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 + } + 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 + } catch (e: ExecutionException) { + AppLog.e(app, "api-socket", "API request read failed: ${e.cause?.let { it.javaClass.simpleName + ": " + it.message }}") + runCatching { con.close() } + return + } + 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) } + } + } finally { + runCatching { con.close() } + } + } + + /** + * 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) + 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..0bc8f03 --- /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 && it != Int.MIN_VALUE } + val currentAverage = batteryManager + ?.let { runCatching { it.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE) }.getOrNull() } + ?.takeIf { it != 0 && it != Int.MIN_VALUE } + 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..bbc0c8d --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CallbackApi.kt @@ -0,0 +1,64 @@ +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 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 + 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 + // 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) { + "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..a55a76d --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/CameraInfoApi.kt @@ -0,0 +1,50 @@ +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("[") + var first = true + for (id in ids) { + val c = runCatching { manager.getCameraCharacteristics(id) }.getOrNull() + if (c == null) continue + if (!first) sb.append(",") + first = false + 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..ec2a3f2 --- /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 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("[") + 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..b676811 --- /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.strings["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..4d164fb --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/DownloadApi.kt @@ -0,0 +1,55 @@ +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 + +/** + * `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) + // 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 { guestPath -> + val dest = ApiPaths.hostPath(context, request, guestPath) + ?: return "ERROR: download destination not allowed\n" + val file = File(dest) + file.parentFile?.mkdirs() + r.setDestinationUri(Uri.fromFile(file)) + } + + 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..f924483 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/FingerprintApi.kt @@ -0,0 +1,63 @@ +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 + 1 -> "not available (hardware unavailable)\n" // BIOMETRIC_ERROR_HW_UNAVAILABLE + 2 -> "not available (no biometrics enrolled)\n" // BIOMETRIC_ERROR_NONE_ENROLLED + 3 -> "not available (no hardware)\n" // BIOMETRIC_ERROR_NO_HARDWARE + 4 -> "not available (security update required)\n" // BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED + 8 -> "not available (keyguard not secure)\n" // BIOMETRIC_ERROR_KEYGUARD_NOT_SECURE + 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..ae540a4 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/InfraredApi.kt @@ -0,0 +1,90 @@ +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 { + val m = mgr.javaClass.getMethod("getCarrierFrequencies") + val r = m.invoke(mgr) + if (r is IntArray) { + r + } else if (r is Array<*>) { + val out = mutableListOf() + for (range in r) { + if (range == null) continue + val min = runCatching { range.javaClass.getMethod("getMinFrequency").invoke(range) as Int }.getOrNull() + val max = runCatching { range.javaClass.getMethod("getMaxFrequency").invoke(range) as Int }.getOrNull() + if (min != null) out.add(min) + else if (max != null) out.add(max) + } + if (out.isNotEmpty()) out.toIntArray() else intArrayOf(38000, 36000, 56000) + } else { + intArrayOf(38000, 36000, 56000) + } + }.getOrElse { + 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 pattern = intArrayOf(freq / 1000, freq / 1000) + val invoked = runCatching { + val clazz = mgr.javaClass + try { + val m = clazz.getMethod("transmit", Int::class.javaPrimitiveType, IntArray::class.java) + m.invoke(mgr, freq, pattern) + true + } catch (_: NoSuchMethodException) { + try { + val m = clazz.getMethod("sendCarrierWave", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + m.invoke(mgr, freq, 1000, 0) + true + } catch (_: Exception) { + false + } + } + }.getOrDefault(false) + return if (invoked) "TRANSMITTED $freq Hz\n" else "ERROR: the blaster refused the carrier\n" + } + + private const val INFRARED_SERVICE_NAME = Context.CONSUMER_IR_SERVICE +} 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..7e437b8 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MediaApis.kt @@ -0,0 +1,128 @@ +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" -> { + val level = request.int("level") + if (level != null) { + audio.setStreamVolume(stream, level.coerceIn(0, max), 0) + } else if (value >= 0) { + audio.setStreamVolume(stream, (value.coerceIn(0, 100) * max / 100), 0) + } else { + return "ERROR: volume set needs a value\n" + } + } + "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 + // 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 new file mode 100644 index 0000000..59da947 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MediaPlaybackApi.kt @@ -0,0 +1,71 @@ +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 + + @Synchronized + override fun run(context: Context, request: ApiRequest): String { + val op = request.str("op") ?: "info" + return when (op) { + "play" -> { + val guestFile = request.str("file") ?: return "ERROR: play needs a file\n" + val file = ApiPaths.hostPath(context, request, guestFile) + ?: return ApiPaths.unreachable(guestFile) + val old = player + if (old != null) { + runCatching { old.stop() } + runCatching { old.release() } + player = null + } + 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..71b90d4 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MediaScannerApi.kt @@ -0,0 +1,84 @@ +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 (guestPath in paths) { + val path = ApiPaths.hostPath(context, request, guestPath) + if (path == null) { + out.append("Warning: not scannable: ").append(guestPath).append('\n') + continue + } + val file = File(path) + when { + file.isFile -> { + scan(context, file, out, verbose) + total++ + } + 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) { + 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(guestPath).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..696c0c1 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/MicRecordApi.kt @@ -0,0 +1,117 @@ +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 + +/** + * `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() + // 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)) + 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..2aa7209 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NetworkApis.kt @@ -0,0 +1,167 @@ +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(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(ApiJson.string(addr)) + } + sb.append("]}") + } + sb.append(']') + return sb.toString() + } +} + +/** + * `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 = 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. + 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 = scanResultsOf(manager) + 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() ?: "" } + + /** + * 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 -> + if (i > 0) sb.append(',') + sb.append('{') + 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('}') + } + sb.append(']') + return sb.toString() + } +} 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..bee077c --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NfcApi.kt @@ -0,0 +1,57 @@ +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" + // 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, + 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" + if (!adapter.isEnabled) return "ERROR: NFC is disabled; enable it in system settings, then retry\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..1b187df --- /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") ?: java.util.UUID.randomUUID().hashCode() + 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 = + ApiJson.string(value) +} 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..6a4f424 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/NotificationChannelApi.kt @@ -0,0 +1,34 @@ +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.deleteNotificationChannel(id) + 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..288bf6e --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SafApi.kt @@ -0,0 +1,196 @@ +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(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()) + .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(ApiJson.string(c.getString(c.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) ?: "")) + .append(",\"mime\":").append(ApiJson.string(c.getString(c.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)) ?: "")) + .append(",\"id\":").append(ApiJson.string(c.getString(c.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)) ?: "")) + .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 treeUri = treeUriStr.toUri() + val root = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)) + val docUri = DocumentsContract.createDocument( + context.contentResolver, root, 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 uriStr = request.str("uri") ?: return "ERROR: readDocument needs a document URI\n" + if (!uriStr.startsWith("content://")) return "ERROR: only content: URIs are allowed\n" + return try { + context.contentResolver.openInputStream(uriStr.toUri()).use { input -> + if (input == null) "ERROR: the provider refused the read\n" + else { + 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 > 0xFFFF) { + return "ERROR: the document is larger than the 65535-byte answer limit\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" + } catch (e: Exception) { + "ERROR: readDocument failed: ${e.message}\n" + } + } + + private fun writeDocument(context: Context, request: ApiRequest): String { + val uriStr = request.str("uri") ?: return "ERROR: writeDocument needs a document URI\n" + if (!uriStr.startsWith("content://")) return "ERROR: only content: URIs are allowed\n" + val data = request.str("data") ?: "" + return try { + context.contentResolver.openOutputStream(uriStr.toUri(), "wt").use { out -> + if (out == null) return "ERROR: the provider refused the write\n" + 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 { + // 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" + } 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" + } + } +} 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..add5f40 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SensorApi.kt @@ -0,0 +1,95 @@ +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) + // A large `delay` seconds value would overflow the Int before the + // *1000, so the period came back smaller than requested. Multiply + // in Long and clamp to the int registerListener accepts. + val delayUs = ((request.int("delay") ?: 200).toLong() * 1000L) + .coerceIn(1000L, Int.MAX_VALUE.toLong()).toInt() + val sensor = manager.getSensorList(Sensor.TYPE_ALL).firstOrNull { it.name == wanted } + ?: return "ERROR: no sensor named '$wanted'\n" + // 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 (taken.incrementAndGet() <= 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) + // 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 result.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..caecbc3 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ShareApi.kt @@ -0,0 +1,64 @@ +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.AppLog +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()) { + 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") ?: "*/*") + // 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) + .apply { if (!text.isNullOrEmpty()) putExtra(Intent.EXTRA_TEXT, text) } + } 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 "" + } + + /** 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(context, 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/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..9632298 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/SsidApi.kt @@ -0,0 +1,123 @@ +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. + */ +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) + if (!connected) { + return when (request.str("op") ?: "ssid") { + "ssid" -> ApiJson.string("") + "\n" + "info" -> "{\"connected\":false}\n" + else -> "ERROR: unknown sscid op\n" + } + } + val scans = + (runCatching { + manager.javaClass.getMethod("getScanResults").invoke(manager) + }.getOrNull() as? List<*>)?.mapNotNull { it as? android.net.wifi.ScanResult } + ?: emptyList() + val info = connectionInfoOf(manager) + val best = scans.maxByOrNull { it.level } + val infoSsid = info?.let { ssidOfInfo(it) } + val ssid = when { + infoSsid != null && !infoSsid.isNullOrEmpty() && infoSsid != "" && !infoSsid.startsWith("<") -> infoSsid + best != null -> ssidOfScan(best) + infoSsid != null -> infoSsid + else -> "" + } + val source = when { + infoSsid != null && !infoSsid.isNullOrEmpty() && infoSsid != "" && !infoSsid.startsWith("<") -> "connection" + best != null -> "scan" + infoSsid != null -> "connection" + else -> "none" + } + + return when (request.str("op") ?: "ssid") { + "ssid" -> ApiJson.string(ssid) + "\n" + "info" -> { + val sb = StringBuilder("{") + sb.append("\"connected\":").append(connected) + sb.append(",\"ssid\":").append(ApiJson.string(ssid)) + sb.append(",\"ssid_source\":").append(ApiJson.string(source)) + if (best != null) { + sb.append(",\"bssid\":").append(ApiJson.string(best.BSSID ?: "")) + sb.append(",\"level\":").append(best.level) + sb.append(",\"frequency\":").append(best.frequency) + sb.append(",\"capabilities\":").append(ApiJson.string(best.capabilities ?: "")) + } + info?.let { + val bssid = runCatching { + it.javaClass.getMethod("getBSSID").invoke(it) as? String + }.getOrNull() + if (bssid != null && best == null) { + sb.append(",\"bssid\":").append(ApiJson.string(bssid)) + } + } + sb.append('}') + sb.toString() + "\n" + } + else -> "ERROR: unknown sscid op\n" + } + } + + 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 { + runCatching { + android.net.wifi.ScanResult::class.java.getField("SSID").get(scan) + }.getOrNull() as? String ?: "" + } + + private fun connectionInfoOf(manager: WifiManager): Any? = + try { + WifiManager::class.java.getMethod("getConnectionInfo").invoke(manager) + } catch (e: Exception) { + null + } + + 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) { + "" + } +} 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..52ebf49 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/StorageGetApi.kt @@ -0,0 +1,38 @@ +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 guestPath = request.str("file") ?: return "ERROR: file path not passed\n" + val path = ApiPaths.hostPath(context, request, guestPath) + ?: return ApiPaths.unreachable(guestPath) + val f = File(path) + if (!f.exists()) return "ERROR: $guestPath does not exist\n" + val sb = StringBuilder("{") + sb.append("\"name\":").append(ApiJson.string(f.name)) + // 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\":").append(ApiJson.string("file://$guestPath")) + 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" + } +} 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..68e5395 --- /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" + 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() + } + + 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" + 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" + TelephonyManager.NETWORK_TYPE_NR -> "5g" + TelephonyManager.NETWORK_TYPE_HSDPA -> "hsdpa" + TelephonyManager.NETWORK_TYPE_HSPA -> "hspa" + TelephonyManager.NETWORK_TYPE_HSUPA -> "hsupa" + 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() + } + + 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..68f3ed2 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/TtsApi.kt @@ -0,0 +1,112 @@ +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 { + live = null + return "ERROR: TTS unavailable: ${it.message}\n" + } + if (!ready.await(5, TimeUnit.SECONDS)) { + created.shutdown() + live = null + return "ERROR: TTS did not initialise\n" + } + if (status != TextToSpeech.SUCCESS) { + created.shutdown() + live = null + return "ERROR: TTS engine failed to start\n" + } + live = wanted to created + created + } + val lang = request.str("language") + if (lang != null) { + // The three-argument Locale constructor is deprecated in the + // compile SDK in favour of Locale.of (API 33+), but it is not + // removed on any level we ship, and it is the one that keeps + // language / region / variant as three exact parts — forLanguageTag + // re-normalises them through BCP-47 and cannot represent an + // empty region with a present variant. It is therefore reached + // by name: the deprecation warning is about a source reference + // to the withdrawn member, not about the platform answering a + // reflective one. + val locale = Locale::class.java + .getConstructor(String::class.java, String::class.java, String::class.java) + .newInstance(lang, request.str("region") ?: "", request.str("variant") ?: "") as Locale + tts.setLanguage(locale) + } + 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("\\", "\\\\").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..1afefaf --- /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\":").append(device.productId) + .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..5b6380b --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/WifiEnableApi.kt @@ -0,0 +1,51 @@ +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 + +/** + * `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 + // 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" + // 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 { + "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..c25fe58 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiDialogActivity.kt @@ -0,0 +1,176 @@ +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", "sheet" -> spinner(title, values) + "checkbox" -> 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().replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + } + 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 + 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)) + 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..e23d6ef --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiNfcActivity.kt @@ -0,0 +1,149 @@ +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 +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 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 + private var kind: String = "" + // The check-and-set must be one atomic step: a tag callback on a binder + // thread and onDestroy on the main thread could otherwise both pass a + // plain check-then-set and each post a result. + private val answered = AtomicBoolean() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + kind = intent.getStringExtra("redterm.api.nfc.kind") ?: "" + adapter = NfcAdapter.getDefaultAdapter(this) + if (adapter == null) { + postOnce("ERROR: this device has no NFC hardware\n") + finish() + return + } + if (kind != "read" && kind != "write") { + postOnce("ERROR: unknown NFC kind\n") + finish() + } + } + + override fun onResume() { + super.onResume() + if (answered.get()) { + finish() + return + } + enableReaderMode() + } + + override fun onPause() { + super.onPause() + runCatching { adapter?.disableReaderMode(this) } + } + + 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.compareAndSet(false, true)) return + ApiUiBridge.postResult(this, result) + } + + private val readCallback = object : NfcAdapter.ReaderCallback { + override fun onTagDiscovered(tag: Tag) { + val ndef = Ndef.get(tag) + if (ndef == null) { + postOnce("ERROR: the tag has no NDEF chip\n") + finish() + return + } + val message = runCatching { + ndef.connect() + try { + ndef.cachedNdefMessage ?: ndef.ndefMessage + } finally { + ndef.close() + } + }.getOrNull() + if (message == null) { + postOnce("ERROR: the tag has no NDEF chip\n") + finish() + return + } + postOnce(dump(message)) + finish() + } + } + + private val writeCallback = object : NfcAdapter.ReaderCallback { + override fun onTagDiscovered(tag: Tag) { + val ndef = Ndef.get(tag) + if (ndef == null) { + postOnce("ERROR: the tag has no NDEF chip\n") + finish() + return + } + val text = intent.getStringExtra("redterm.api.nfc.text") ?: "" + val record = NdefRecord.createTextRecord("en", text) + val message = NdefMessage(arrayOf(record)) + val wrote = runCatching { + ndef.connect() + try { + if (!ndef.isWritable) throw IllegalStateException("read-only") + ndef.writeNdefMessage(message) + true + } finally { + ndef.close() + } + }.onFailure { e -> + postOnce("ERROR: write failed: ${e.message}\n") + finish() + }.getOrDefault(false) + if (wrote) { + postOnce("\"$text\"") + finish() + } + } + } + + private fun enableReaderMode() { + if (answered.get()) return + val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V + adapter?.enableReaderMode(this, if (kind == "write") writeCallback else readCallback, flags, null) + } + + private fun dump(message: NdefMessage): String { + val sb = StringBuilder("[") + var first = true + for (r in message.records) { + if (!first) sb.append(",") + first = false + sb.append('{') + sb.append("\"type\":\"").append(String(r.type)).append('"') + sb.append(",\"payload\":\"").append(String(r.payload)).append('"') + sb.append('}') + } + sb.append(']') + return sb.toString() + "\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..7cadd1a --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/api/ui/ApiUiBridge.kt @@ -0,0 +1,96 @@ +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() + + /** 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), 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(timeoutSec + 15L, TimeUnit.SECONDS) + slots.remove(id) + if (timedOut) { + runCatching { + val intent = Intent(context, activity).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(ApiUiBridge.EXTRA_SLOT_ID, id) + putExtra("redterm.api.cancel", true) + } + context.startActivity(intent) + } + return "ERROR: the dialog timed out after ${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..58985ec --- /dev/null +++ b/app/src/test/java/com/redtermapp/distro/StartupHookSourcingTest.kt @@ -0,0 +1,123 @@ +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 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 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) + + /** 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..7e98457 --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/ApiHelperTest.kt @@ -0,0 +1,163 @@ +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 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") + // 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 (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() + 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) + 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__")) + } + } + + @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, 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 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()) + // 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/ApiPathsJsonTest.kt b/app/src/test/java/com/redtermapp/util/api/ApiPathsJsonTest.kt new file mode 100644 index 0000000..9b78614 --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/api/ApiPathsJsonTest.kt @@ -0,0 +1,106 @@ +package com.redtermapp.util.api + +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" + + // --- ApiPaths.project -------------------------------------------------- + + @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.project(rootfs, p)) + } + } + + @Test + fun `an absolute guest path is projected under the origin`() { + assertEquals("$rootfs/root/photo.jpg", ApiPaths.project(rootfs, "/root/photo.jpg")) + } + + @Test + fun `a relative path cannot be projected`() { + assertNull(ApiPaths.project(rootfs, "photo.jpg")) + } + + @Test + fun `a bind path with a dotdot that leaves the bind is projected, not returned`() { + // /dev/../data/... collapses to /data/..., which is not a bind dir, + // so it is projected under the origin, not handed back as-is. + val out = ApiPaths.project(rootfs, "/dev/../data/user/0/x") + assertEquals("$rootfs/data/user/0/x", out) + } + + @Test + fun `a path that escapes the origin by traversal is rejected`() { + assertNull(ApiPaths.project(rootfs, "/../../etc/passwd")) + assertNull(ApiPaths.project(rootfs, "/root/../../etc/shadow")) + } + + @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 a rootfs origin " + + "that is one of the app's own rootfs trees\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/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..913fd1f --- /dev/null +++ b/native/redterm-api/redterm-api.c @@ -0,0 +1,255 @@ +/* + * 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]); + } + } + } + } + + /* + * 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++; + } + /* The value is its own word: a space separates the key from the + * opening quote, or the parser's `--es key "value"` shape does + * not match and the whole request is rejected. */ + if (pos + 1 >= MAX_REQUEST) { + fprintf(stderr, "redterm-api: request too long\n"); + return 1; + } + request[pos++] = ' '; + appendQuoted(request, &pos, rootfsHost); + } + } + + 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; +}