chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:16:30 +08:00
commit a06359fc30
216 changed files with 32971 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# CodeRabbit configuration for OpenClaw on Android
# Documentation: https://docs.coderabbit.ai/reference/configuration
language: en-US
early_access: false
reviews:
request_changes_workflow: false
high_level_summary: true
sequence_diagrams: true
auto_review:
enabled: true
base_branches:
- main
drafts: false
poem: false
reviewer:
enabled: true
auto_assign: false
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
chat:
auto_reply: true
# Path filters — ignore generated and build artifacts
path_filters:
- "!**/build/**"
- "!**/node_modules/**"
- "!**/.gradle/**"
- "!**/android/www/dist/**"
- "!**/*.apk"
# Review instructions specific to this project
review_instructions:
- "Focus on Kotlin best practices and idiomatic Android code"
- "Check for Android memory leaks (Activity/Context references, unregistered receivers)"
- "Verify proper lifecycle handling (Activity, Service, coroutine scope cancellation)"
- "Ensure shell scripts are POSIX-compatible and follow scripts/lib.sh conventions"
- "Check path handling — Termux paths ($PREFIX) vs standard Linux paths"
- "Review glibc-runner compatibility (bionic vs glibc boundary issues)"
- "Verify WebView ↔ Kotlin JsBridge interface consistency"
- "Ensure no hardcoded paths that break on different Android versions"
+27
View File
@@ -0,0 +1,27 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
[*.{sh,bash}]
indent_size = 4
[*.{xml,json,yaml,yml}]
indent_size = 2
[*.{ts,tsx,js,jsx,css}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+26
View File
@@ -0,0 +1,26 @@
* text=auto eol=lf
*.sh text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.h text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.xml text eol=lf
*.md text eol=lf
*.gradle text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.properties text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.jar binary
*.so binary
*.apk binary
*.aab binary
*.zip binary
gradlew text eol=lf
gradlew.bat text eol=crlf
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Pre-commit hook: runs code quality checks on staged files
# Enable with: git config core.hooksPath .githooks
set -e
ROOT_DIR="$(git rev-parse --show-toplevel)"
ANDROID_DIR="$ROOT_DIR/android"
# --- Kotlin checks (ktlint + detekt) ---
STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.kts?$' || true)
if [ -n "$STAGED_KT" ]; then
echo "Running ktlint check..."
if ! (cd "$ANDROID_DIR" && ./gradlew ktlintCheck --daemon -q 2>/dev/null); then
echo ""
echo "ktlint check failed. Run './gradlew ktlintFormat' to auto-fix."
exit 1
fi
echo "Running detekt..."
if ! (cd "$ANDROID_DIR" && ./gradlew detekt --daemon -q 2>/dev/null); then
echo ""
echo "detekt check failed. Fix the issues above before committing."
exit 1
fi
fi
# --- Shell script checks (shellcheck) ---
STAGED_SH=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.sh$' || true)
if [ -n "$STAGED_SH" ]; then
if command -v shellcheck >/dev/null 2>&1; then
echo "Running shellcheck..."
FAILED=0
for file in $STAGED_SH; do
# Skip build artifacts
case "$file" in
android/app/build/*) continue ;;
esac
if [ -f "$ROOT_DIR/$file" ]; then
if ! shellcheck "$ROOT_DIR/$file"; then
FAILED=1
fi
fi
done
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "shellcheck failed. Fix the issues above before committing."
exit 1
fi
else
echo "Warning: shellcheck not installed. Skipping shell script lint."
echo "Install with: sudo apt-get install shellcheck"
fi
fi
# --- Markdown lint ---
STAGED_MD=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.md$' || true)
if [ -n "$STAGED_MD" ]; then
if command -v markdownlint-cli2 >/dev/null 2>&1; then
echo "Running markdownlint..."
if ! markdownlint-cli2 $STAGED_MD 2>&1; then
echo ""
echo "markdownlint failed. Fix the issues above before committing."
exit 1
fi
else
echo "Warning: markdownlint-cli2 not installed. Skipping markdown lint."
fi
fi
# --- WebView/React lint (www/) ---
STAGED_TS=$(git diff --cached --name-only --diff-filter=ACM | grep -E 'android/www/.*\.(ts|tsx)$' || true)
if [ -n "$STAGED_TS" ]; then
if [ -f "$ANDROID_DIR/www/node_modules/.bin/eslint" ]; then
echo "Running ESLint on www/..."
STAGED_TS_REL=$(echo "$STAGED_TS" | sed 's|^android/www/||')
if ! (cd "$ANDROID_DIR/www" && npx eslint $STAGED_TS_REL 2>&1); then
echo ""
echo "ESLint failed. Fix the issues above before committing."
exit 1
fi
else
echo "Warning: www/node_modules not found. Run 'cd android/www && npm install'."
fi
fi
# --- Script sync check (post-setup.sh) ---
ROOT_POST_SETUP="$ROOT_DIR/post-setup.sh"
APP_POST_SETUP="$ROOT_DIR/android/app/src/main/assets/post-setup.sh"
STAGED_SYNC=$(git diff --cached --name-only | grep -E '(^post-setup\.sh$|android/app/src/main/assets/post-setup\.sh)' || true)
if [ -n "$STAGED_SYNC" ]; then
if [ -f "$ROOT_POST_SETUP" ] && [ -f "$APP_POST_SETUP" ]; then
if ! diff -q "$ROOT_POST_SETUP" "$APP_POST_SETUP" >/dev/null 2>&1; then
echo ""
echo "ERROR: post-setup.sh sync mismatch!"
echo " Root: post-setup.sh"
echo " App: android/app/src/main/assets/post-setup.sh"
echo ""
echo "Both files must have identical content."
echo "Copy one to the other and stage both before committing."
exit 1
fi
fi
fi
echo "All checks passed."
+34
View File
@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/android"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
reviewers:
- "AidanPark"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "ci"
reviewers:
- "AidanPark"
- package-ecosystem: "npm"
directory: "/android/www"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
reviewers:
- "AidanPark"
+91
View File
@@ -0,0 +1,91 @@
name: Android Build
on:
push:
branches: [main]
paths:
- 'android/**'
- '.github/workflows/android-build.yml'
pull_request:
branches: [main]
paths:
- 'android/**'
- '.github/workflows/android-build.yml'
jobs:
build-www:
name: Build WebView UI
runs-on: ubuntu-latest
defaults:
run:
working-directory: android/www
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: android/www/package-lock.json
- run: npm ci
- run: npm run build
- run: cd dist && zip -r ../www.zip .
- uses: actions/upload-artifact@v4
with:
name: www-zip
path: android/www/www.zip
build-apk:
name: Build APK
runs-on: ubuntu-latest
needs: build-www
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- uses: android-actions/setup-android@v4
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/libs.versions.toml') }}
restore-keys: gradle-
- name: Build debug APK
run: ./gradlew assembleDebug
- uses: actions/upload-artifact@v4
with:
name: app-debug
path: android/app/build/outputs/apk/debug/app-debug.apk
release:
name: Create Release
runs-on: ubuntu-latest
needs: [build-www, build-apk]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: app-debug
- uses: actions/download-artifact@v4
with:
name: www-zip
# Release is created manually — artifacts are available for download
# To create a tagged release, push a tag: git tag v1.0.0 && git push --tags
+187
View File
@@ -0,0 +1,187 @@
name: Code Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get install -y shellcheck
- name: Run shellcheck
run: |
find . -name '*.sh' \
-not -path './android/app/build/*' \
-not -path './node_modules/*' \
-not -path './.agent/*' \
-print0 | xargs -0 --no-run-if-empty shellcheck --format=gcc
script-sync:
name: Script Sync Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify post-setup.sh sync
run: |
if ! diff -q post-setup.sh android/app/src/main/assets/post-setup.sh; then
echo "ERROR: post-setup.sh files are out of sync!"
echo " Root: post-setup.sh"
echo " App: android/app/src/main/assets/post-setup.sh"
echo "Both files must have identical content."
exit 1
fi
echo "post-setup.sh files are in sync."
markdownlint:
name: Markdown Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install markdownlint-cli2
run: npm install -g markdownlint-cli2
- name: Run markdownlint
run: markdownlint-cli2 '**/*.md' '!node_modules' '!android/www/node_modules' '!android/www/dist' '!android/app/build'
doc-freshness:
name: Document Freshness
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check document freshness
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
WARN=0
check_doc() {
local pattern="$1"
local doc="$2"
local label="$3"
if echo "$CHANGED" | grep -qE "$pattern"; then
if ! echo "$CHANGED" | grep -qE "$doc"; then
echo "Warning: $label code changed but $doc was not updated."
WARN=1
fi
fi
}
check_doc '^install\.sh$' '^README\.md$' 'install.sh'
check_doc '^oa\.sh$' '^README\.md$' 'oa.sh'
check_doc '^(update\.sh|update-core\.sh)$' '^(README\.md|CHANGELOG\.md)$' 'update scripts'
check_doc '^bootstrap\.sh$' '^README\.md$' 'bootstrap.sh'
check_doc '^scripts/install-' '^README\.md$' 'install scripts'
check_doc '^platforms/openclaw/' '^README\.md$' 'platform scripts'
# Check 3-language sync for docs/ files
for doc in $(echo "$CHANGED" | grep -E '^docs/.*\.md$' | grep -v '\.(ko|zh)\.md$'); do
ko_doc="${doc%.md}.ko.md"
zh_doc="${doc%.md}.zh.md"
if [ -f "$ko_doc" ] && ! echo "$CHANGED" | grep -qF "$ko_doc"; then
echo "Warning: $doc changed but Korean version $ko_doc was not updated."
WARN=1
fi
if [ -f "$zh_doc" ] && ! echo "$CHANGED" | grep -qF "$zh_doc"; then
echo "Warning: $doc changed but Chinese version $zh_doc was not updated."
WARN=1
fi
done
# Check 3-language sync for README
if echo "$CHANGED" | grep -qE '^README\.md$'; then
if ! echo "$CHANGED" | grep -qE '^README\.ko\.md$'; then
echo "Warning: README.md changed but README.ko.md was not updated."
WARN=1
fi
if ! echo "$CHANGED" | grep -qE '^README\.zh\.md$'; then
echo "Warning: README.md changed but README.zh.md was not updated."
WARN=1
fi
fi
if [ "$WARN" -eq 1 ]; then
echo ""
echo "Document freshness check: some docs may need updating."
else
echo "Document freshness check passed."
fi
kotlin-lint:
name: Kotlin Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- uses: android-actions/setup-android@v4
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/libs.versions.toml') }}
restore-keys: gradle-
- name: Run ktlint
run: ./gradlew ktlintCheck
- name: Run detekt
run: ./gradlew detekt
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- uses: android-actions/setup-android@v4
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/libs.versions.toml') }}
restore-keys: gradle-
- name: Run unit tests
run: ./gradlew test
+50
View File
@@ -0,0 +1,50 @@
# Project specific
.obsidian/
.plan/
docs/plan/
.issues
# Android
*.apk
*.aab
*.dex
*.class
*.so
/build/
/app/build/
/captures/
.externalNativeBuild/
.cxx/
local.properties
# Gradle
.gradle/
gradle-app.setting
!gradle-wrapper.jar
build/
# IDE
.idea/
*.iml
*.iws
.project
.classpath
.settings/
# Node
node_modules/
# Kotlin
*.kotlin_module
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Secrets
*.env
*.keystore
*.jks
+17
View File
@@ -0,0 +1,17 @@
config:
default: true
MD013: false
MD007: false
MD031: false
MD032: false
MD033: false
MD040: false
MD041: false
MD060: false
MD024:
siblings_only: true
ignores:
- "build/**"
- "android/www/dist/**"
- "android/www/node_modules/**"
View File
+13
View File
@@ -0,0 +1,13 @@
# ShellCheck configuration for OpenClaw on Android
# Termux 환경에서 실행되는 Shell Script를 위한 설정
# Default shell dialect
shell=bash
# Enable following sourced files
external-sources=true
# SC2155: Declare and assign separately — Termux 스크립트에서 자주 사용하는 패턴
# SC1091: Not following sourced file — source 대상이 런타임에만 존재 (Termux $PREFIX 등)
# SC2034: Variable appears unused — lib.sh에서 정의하고 다른 스크립트에서 사용
disable=SC2155,SC1091,SC2034
+241
View File
@@ -0,0 +1,241 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [Script v1.0.27] - 2026-04-13
### Fixed
- Fix `oa --update` showing syntax error after self-update — the running shell process continued reading the replaced `oa` script file, causing a parse error at the new file's line 240. Added `exit 0` after update completes to prevent this ([#110](https://github.com/AidanPark/openclaw-android/issues/110))
## [Script v1.0.26] - 2026-04-12
### Changed
- Switch Codex CLI from upstream `@openai/codex` to Termux-optimized `@mmmbuto/codex-cli-termux` (DioNanos/codex-termux fork). The upstream package ships a static musl binary whose DNS resolver hardcodes `/etc/resolv.conf` — a file that doesn't exist on Android — causing unreliable network connections. The fork builds as a dynamic Bionic binary that uses Android's native DNS stack, fixing the `Stream disconnected` / `error sending request` pattern reported by users behind proxies. CLI command name (`codex`) is unchanged. ([#108](https://github.com/AidanPark/openclaw-android/issues/108))
### Fixed
- Fix Codex CLI launcher failing on `com.openclaw.android` namespace — the npm-created `$PREFIX/bin/codex` symlink points to a JS launcher chain that miscalculates paths under the non-standard Android app namespace. Replace the symlink with a bash wrapper that sets `LD_LIBRARY_PATH` and directly exec's `codex.bin`, matching the pattern used for the openclaw CLI wrapper. Applied across all delivery paths (App Install, Termux Install, Update) via npm wrapper hook and inline post-install creation. ([#108](https://github.com/AidanPark/openclaw-android/issues/108))
## [Script v1.0.25] - 2026-04-11
### Fixed
- Fix `bad interpreter: Permission denied` when running npm globally-installed CLI tools (codex, claude, clawdhub, etc.) directly from shell on Android/Termux. The root cause is `#!/usr/bin/env node` shebang in `.js` entry points, which Android cannot resolve. Two-layer fix: (1) npm wrapper hook automatically rewrites shebangs after every `npm install -g`, (2) defense-in-depth calls in install/update scripts catch anything Layer 1 missed.
## [Script v1.0.24] - 2026-04-11
### Fixed
- Stop permanently polluting user's `~/.npmrc` during install — previously `post-setup.sh` would detect slow `registry.npmjs.org` access and write `registry=https://registry.npmmirror.com` to `~/.npmrc`, affecting all of the user's npm projects forever with no self-recovery. Now the installer uses session-scoped `NPM_CONFIG_REGISTRY` env var and caches the chosen registry at `~/.openclaw-android/.npm-registry`, re-exporting from `~/.bashrc` on each login. Users bitten by v1.0.22/v1.0.23 are auto-rescued on next `oa --update` because env vars override `~/.npmrc`, and their personal npmrc is left untouched (preserving auth tokens, scope registries, etc.) ([#107](https://github.com/AidanPark/openclaw-android/issues/107))
- Cover all three install paths for the npm registry detection — App Install (`post-setup.sh`), Termux Install (`install.sh`), and Update (`update-core.sh`). `scripts/setup-env.sh` now injects the `NPM_CONFIG_REGISTRY` re-export line inside the `# >>> OpenClaw on Android >>>` marker block of `~/.bashrc` so Termux-install and update paths get the same session-to-session re-evaluation as App Install.
## [Script v1.0.23] - 2026-04-11
### Fixed
- Preserve user's existing `~/.gitconfig` during post-setup — previously `cat > ~/.gitconfig` overwrote all user settings (name, email, aliases). Now uses `git config --global` to set only `http.sslCAInfo` and `url.https://github.com/.insteadOf` keys while keeping user entries intact ([#107](https://github.com/AidanPark/openclaw-android/issues/107))
## [Script v1.0.22] - 2026-04-10
### Added
- ELF binary auto-wrapping: detect glibc binaries via PT_INTERP and route through ld.so, enabling npx-installed native binaries like codex-acp to run on Android ([#103](https://github.com/AidanPark/openclaw-android/issues/103))
- Shebang resolution: handle `#!/usr/bin/env` scripts without libtermux-exec.so by resolving interpreters from PATH in JavaScript
- Shell invocation interception: detect `spawn('sh', ['-c', 'cmd'])` pattern used by npm/npx and resolve commands directly
- Supplementary glibc library deployment: bundle libcap.so.2 for third-party native binary support
- Localhost DNS shortcut: return 127.0.0.1 immediately for localhost lookups without querying external DNS ([#105](https://github.com/AidanPark/openclaw-android/issues/105))
- Create `$PREFIX/glibc/etc/hosts` if missing, ensuring getaddrinfo can resolve localhost ([#105](https://github.com/AidanPark/openclaw-android/issues/105))
### Changed
- Stop restoring LD_PRELOAD in glibc-compat.js — libtermux-exec.so re-injects it via execve hook, crashing glibc child processes with "Could not find a PHDR" errors
- Always use Termux shell for exec/execSync on all Android versions (previously only Android 7-8)
## [Script v1.0.21] - 2026-04-07
### Fixed
- Fix `oa --backup` exiting with error code 1 due to `tmpdir: unbound variable` — trap used local variable that went out of scope
- Fix `oa --backup` / `oa --restore` and `ask_yn` failing in tty-less environments (SSH pipe, non-interactive) — fallback to stdin when `/dev/tty` is unavailable
## [Script v1.0.20] - 2026-04-06
### Fixed
- Fix dep restore blocked by sharp build failure — `npm install` inside openclaw dir triggers sharp's native build which fails on Termux, blocking all other deps. Now runs `postinstall-bundled-plugins.mjs` directly with `npm_config_ignore_scripts=true` to skip sharp while installing channel deps ([#92](https://github.com/AidanPark/openclaw-android/issues/92))
- Fix dep restore skipped when openclaw already at latest version — check `@buape/carbon` presence instead of `OPENCLAW_UPDATED` flag
## [Script v1.0.19] - 2026-04-06
### Fixed
- Fix missing channel dependencies after `--ignore-scripts` install — reinstall deps inside openclaw package dir to restore optional modules like `@buape/carbon`, `grammy` ([#92](https://github.com/AidanPark/openclaw-android/issues/92))
## [Script v1.0.18] - 2026-04-04
### Fixed
- Fix `process.execPath` pointing to `ld-linux-aarch64.so.1` instead of node wrapper — glibc-compat.js had wrong path (`node/bin/node` instead of `bin/node`), causing OpenClaw 4.2 child process spawns with `--disable-warning=ExperimentalWarning` to fail ([#88](https://github.com/AidanPark/openclaw-android/issues/88))
- Add `_OA_WRAPPER_PATH` env var to node wrapper — eliminates path guessing in glibc-compat.js
- Fix verify-compat.sh checking wrong wrapper paths — tests now verify behavior (executable script, not ELF) instead of hardcoded paths
- Fix `install.sh` session PATH missing `$BIN_DIR` — node/npm commands could fail to resolve after Step 5
- Fix README (en/ko/zh) documenting wrong wrapper path (`node/bin/node``bin/node`)
- Fix npm wrapper writing through symlink and corrupting `openclaw.mjs` — npm creates symlink `$PREFIX/bin/openclaw``openclaw.mjs`, our shim writer followed it and destroyed the original file ([#89](https://github.com/AidanPark/openclaw-android/issues/89))
## [Script v1.0.17] - 2026-04-03
### Fixed
- Fix false-positive "glibc node wrapper not found" in install verification — verify-install.sh and status.sh referenced old `node/bin/` path instead of new `bin/` path ([#87](https://github.com/AidanPark/openclaw-android/issues/87))
- Add `BIN_DIR` constant to lib.sh to prevent path hardcoding drift across verification scripts
## [Script v1.0.16] - 2026-04-02
### Fixed
- Auto-repatch openclaw CLI wrapper after `npm install/update -g openclaw` — prevents `/usr/bin/env` shebang breakage on Termux ([#86](https://github.com/AidanPark/openclaw-android/issues/86))
- Move node/npm/npx wrappers to dedicated `bin/` directory safe from npm overwrites
- Fix missing `bin/node` wrapper creation in already-installed repair path
## [Script v1.0.15] - 2026-04-01
### Fixed
- Fix `dns.promises.lookup` not patched in glibc-compat.js — OpenClaw's web_search SSRF guard uses `node:dns/promises` which bypassed the c-ares DNS fix, causing `getaddrinfo EAI_AGAIN` on hosts without `resolv.conf` ([#83](https://github.com/AidanPark/openclaw-android/issues/83))
## [Script v1.0.14] - 2026-04-01
### Fixed
- Auto-disable Bonjour/mDNS at runtime when only loopback interface is visible — Android/Termux cannot send multicast, causing repeated "Announcement failed as of socket errors!" gateway logs ([#84](https://github.com/AidanPark/openclaw-android/issues/84))
## [Script v1.0.13] - 2026-03-31
### Added
- Playwright as optional install tool (`oa --install`) — installs `playwright-core`, auto-configures Chromium path and environment variables
- Auto-repatch openclaw CLI wrapper after `npm install/update -g openclaw` — prevents shebang breakage on Termux (#86)
### Changed
- Bump Gson 2.12.1 → 2.13.2
- Bump androidx.core:core-ktx 1.17.0 → 1.18.0
- Bump ktlint gradle plugin 14.1.0 → 14.2.0
- Bump Gradle wrapper 9.3.1 → 9.4.1
- Bump eslint 9.39.4 → 10.0.3
- Bump globals 16.5.0 → 17.4.0
- Bump eslint-plugin-react-refresh 0.4.24 → 0.5.2
- Bump GitHub Actions: checkout v4→v6, setup-node v4→v6, setup-java v4→v5, upload-artifact v4→v7, download-artifact v4→v8
## [App v0.4.0 / Script v1.0.12] - 2026-03-30
### Added
- App: i18n support — English, Korean (한국어), Chinese (中文) with auto-detection
- App: Language selector in Settings
- Add Chinese README (README.zh.md) with China mirror download link
- Add language switcher links to README.md, README.ko.md, README.zh.md
- GitHub mirror fallback for China/restricted networks (ghfast.top, ghproxy.net)
- npm registry auto-switch to npmmirror.com when npmjs.org is unreachable
- Add AppLogger centralized logging wrapper, replace all android.util.Log calls
- Add unit test infrastructure (JUnit5 + MockK, 22 tests)
- Add CI code-quality workflow (shellcheck, sync check, markdownlint, doc freshness, kotlin lint, unit tests)
- Add shellcheck, markdownlint to pre-commit hook
- Add post-setup.sh sync verification to pre-commit hook
- Add Claude Code hooks (push warning, document freshness, shellcheck auto-run)
### Changed
- Resolve all 48 detekt violations — no baseline needed
- Resolve all 43 shellcheck violations across all scripts
- Resolve all 125 markdownlint violations across all documents
- Refactor BootstrapManager, JsBridge, MainActivity for reduced complexity
- Convert A&&B||C patterns to if/then/else in install.sh, install-tools.sh
- Bump app version to v0.4.0 (versionCode 9)
- Bump script version to v1.0.12
## [1.0.6] - 2026-03-10
### Changed
- Clean up existing installation on reinstall
## [1.0.5] - 2026-03-06
### Added
- Standalone Android APK with WebView UI, native terminal, and extra keys bar
- Multi-session terminal tab bar with swipe navigation
- Boot auto-start via BootReceiver
- Chromium browser automation support (`scripts/install-chromium.sh`)
- `oa --install` command for installing optional tools independently
### Fixed
- `update-core.sh` syntax error (extra `fi` on line 237)
- sharp image processing with WASM fallback for glibc/bionic boundary
### Changed
- Switch terminal input mode to `TYPE_NULL` for strict terminal behavior
## [1.0.4] - 2025-12-15
### Changed
- Upgrade Node.js to v22.22.0 for FTS5 support (`node:sqlite` static bundle)
- Show version in all update skip and completion messages
### Removed
- oh-my-opencode support (OpenCode uses internal Bun, PATH-based plugins not detected)
### Fixed
- Update version glob picks oldest instead of latest
- Native module build failures during update
## [1.0.3] - 2025-11-20
### Added
- `.gitattributes` for LF line ending enforcement
### Changed
- Bump version to v1.0.3
## [1.0.2] - 2025-10-15
### Added
- Platform-plugin architecture (`platforms/<name>/` structure)
- Shared script library (`scripts/lib.sh`)
- Verification system (`tests/verify-install.sh`)
### Changed
- Refactor install flow into modular scripts
- Separate platform-specific code from infrastructure
## [1.0.1] - 2025-09-01
### Fixed
- Initial bug fixes and stability improvements
## [1.0.0] - 2025-08-15
### Added
- Initial release
- glibc-runner based execution (no proot-distro required)
- One-command installer (`curl | bash`)
- Node.js glibc wrapper for standard Linux binaries on Android
- Path conversion for Termux compatibility
- Optional tools: tmux, code-server, OpenCode, AI CLIs
- Post-install verification
+133
View File
@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official project
communication channel, posting via an official social media account, or acting
as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainer at
[github.com/AidanPark](https://github.com/AidanPark).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+151
View File
@@ -0,0 +1,151 @@
# Contributing to OpenClaw on Android
Thanks for your interest in contributing! This guide will help you get started.
## First-Time Contributors
Welcome — contributions of all sizes are valued. If this is your first contribution:
1. **Find an issue.** Look for issues labeled [`good first issue`](https://github.com/AidanPark/openclaw-android/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — these are scoped for newcomers.
2. **Pick a scope.** Good first contributions include:
- Typo and documentation fixes
- Shell script improvements
- Bug fixes with clear reproduction steps
3. **Follow the fork → PR workflow** described below.
## Development Setup
### Shell Scripts (installer, updater, patches)
```bash
# Clone the repo
git clone https://github.com/AidanPark/openclaw-android.git
cd openclaw-android
# Validate shell scripts
bash -n install.sh
bash -n update-core.sh
bash -n oa.sh
```
Shell scripts follow POSIX-compatible style with 4-space indentation. See `scripts/lib.sh` for shared conventions.
### Android App
```bash
cd android
# Build APK
./gradlew assembleDebug
# Run lint checks
./gradlew ktlintCheck
./gradlew detekt
# Format code
./gradlew ktlintFormat
```
**Prerequisites**: JDK 21, Android SDK (API 28+), NDK 28+, Node.js 22+ (for WebView UI).
### WebView UI
```bash
cd android/www
npm install
npm run build
```
### Enable Git Hooks
```bash
git config core.hooksPath .githooks
```
This enables the pre-commit hook that automatically runs before every commit:
- **Kotlin**: ktlint (formatting) + detekt (static analysis)
- **Shell scripts**: shellcheck (requires `shellcheck` installed)
- **Markdown**: markdownlint (requires `markdownlint-cli2` installed)
- **WebView**: ESLint on TypeScript/React files in `android/www/`
- **Sync check**: Verifies `post-setup.sh` root and app assets are identical
## How to Contribute
### 1. Fork and Clone
```bash
git clone https://github.com/<your-username>/openclaw-android.git
cd openclaw-android
```
### 2. Make Your Changes
All work happens on `main` — we use a single-branch workflow with no prefixes.
### 3. Test Your Changes
- **Shell scripts**: Run `bash -n <script>` to validate syntax
- **Android app**: Run `./gradlew assembleDebug` to verify build
- **Kotlin code**: Run `./gradlew ktlintCheck && ./gradlew detekt`
### 4. Commit
Commit messages use English, imperative style, with no prefix:
```
Fix update-core.sh syntax error
Add multi-session terminal tab bar
Upgrade Node.js to v22.22.0 for FTS5 support
```
- Start with a capital letter, no period at the end
- Keep the subject line under 50 characters
- Use imperative present tense ("Fix", not "Fixed" or "Fixes")
### 5. Open a Pull Request
Open a PR against `main`. Describe:
- What the change does
- Why it's needed
- How to test it
## Project Structure
The project has two main parts:
- **Shell scripts** (root) — Installer, updater, patches, CLI. These run in Termux on Android.
- **Android app** (`android/`) — Kotlin/Android APK with WebView UI and native terminal.
See the [README](README.md) for the full project structure and architecture details.
## Code Style
| Language | Style | Indentation |
|----------|-------|-------------|
| Shell (bash) | POSIX compatible, `scripts/lib.sh` conventions | 4 spaces |
| Kotlin | [Official coding conventions](https://kotlinlang.org/docs/coding-conventions.html) | 4 spaces |
| XML | Standard Android conventions | 2 spaces |
| TypeScript/React | ESLint config in `android/www/` | 2 spaces |
## Key Considerations
When contributing to this project, keep in mind:
- **Termux compatibility** — Scripts must work in Termux's environment (`$PREFIX` paths, no root)
- **glibc boundary** — Node.js runs under glibc-runner while system tools use Bionic libc
- **Path handling** — Standard Linux paths (`/tmp`, `/bin/sh`) must be converted to Termux equivalents
- **Android version range** — The app targets `minSdk 24` (Android 7.0) to `targetSdk 28`
- **Idempotency** — Install and update scripts should be safe to run multiple times
## Reporting Issues
- **Bugs**: Include Android version, device model, Termux version, steps to reproduce
- **Features**: Describe the use case and proposed approach
- **Security**: See [SECURITY.md](SECURITY.md) for responsible disclosure
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OpenClaw on Android Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+599
View File
@@ -0,0 +1,599 @@
# OpenClaw on Android
[English](README.md) | [中文](README.zh.md)
<img src="docs/images/openclaw_android.jpg" alt="OpenClaw on Android">
![Android 7.0+](https://img.shields.io/badge/Android-7.0%2B-brightgreen)
![Termux](https://img.shields.io/badge/Termux-Required-orange)
![No proot](https://img.shields.io/badge/proot--distro-Not%20Required-blue)
![License MIT](https://img.shields.io/github/license/AidanPark/openclaw-android)
![GitHub Stars](https://img.shields.io/github/stars/AidanPark/openclaw-android)
나야, [OpenClaw](https://github.com/openclaw). 근데 이제 Android-Termux 를 곁들인...
## 리눅스 설치 없이
일반적으로 Android에서 OpenClaw를 실행하려면 proot-distro로 Linux를 설치해야 하고, 700MB~1GB의 저장공간이 필요합니다. OpenClaw on Android는 glibc 동적 링커(ld.so)만 설치하여, 전체 Linux 배포판 없이 OpenClaw를 실행할 수 있게 합니다.
**기존 방식**: Termux에서 proot-distro를 통해 전체 Linux 배포판을 설치합니다.
```
┌───────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌───────────────────────────────────────────────┐ │
│ │ Android · Bionic libc · Termux │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ proot-distro · Debian/Ubuntu │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ GNU glibc │ │ │ │
│ │ │ │ Node.js → OpenClaw │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
**이 프로젝트**: proot-distro 없이, glibc 동적 링커만 설치합니다.
```
┌───────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌───────────────────────────────────────────────┐ │
│ │ Android · Bionic libc · Termux │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ glibc ld.so (linker only) │ │ │
│ │ │ ld.so → Node.js → OpenClaw │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
| | 기존 방식 (proot-distro) | 이 프로젝트 |
|---|---|---|
| 저장공간 오버헤드 | 1-2GB (Linux + 패키지) | ~200MB |
| 설치 시간 | 20-30분 | 3-10분 |
| 성능 | 느림 (proot 레이어) | 네이티브 속도 |
| 설정 과정 | 디스트로 설치, Linux 설정, Node.js 설치, 경로 수정... | 명령어 하나 실행 |
## <img src="docs/images/claw-icon.svg" width="28" alt="Claw icon"> Claw 앱
독립 실행형 Android 앱도 제공됩니다. 터미널 에뮬레이터와 WebView 기반 UI를 하나의 APK에 담았으며, Termux 없이 사용할 수 있습니다.
- 원탭 설치: 앱 안에서 부트스트랩, Node.js, OpenClaw 자동 설치
- 내장 대시보드: 게이트웨이 제어, 런타임 정보, 도구 관리
- Termux와 독립 실행 — 앱 설치가 기존 Termux + `oa` 환경에 영향을 주지 않음
[Releases](https://github.com/AidanPark/openclaw-android/releases) 페이지에서 APK를 다운로드하세요.
## 요구사항
- Android 7.0 이상 (Android 10 이상 권장)
- 약 1GB 이상의 여유 저장공간
- Wi-Fi 또는 모바일 데이터 연결
## 동작 원리
설치 스크립트는 Termux와 일반 Linux 환경의 차이를 자동으로 해결합니다. 사용자가 직접 할 일은 없으며, 설치 명령어 하나로 아래 내용이 모두 처리됩니다:
1. **glibc 환경** — glibc 동적 링커(pacman의 glibc-runner)를 설치하여 표준 Linux 바이너리가 수정 없이 실행되도록 설정
2. **Node.js (glibc)** — 공식 Node.js linux-arm64 바이너리를 다운로드하고 ld.so 로더 스크립트로 래핑 (patchelf는 Android에서 segfault를 유발하므로 미사용)
3. **경로 변환** — 일반 Linux 경로(`/tmp`, `/bin/sh`, `/usr/bin/env`)를 Termux 경로로 자동 변환
4. **임시 폴더 설정** — Android에서 접근 가능한 임시 폴더로 자동 설정
5. **서비스 관리자 우회** — systemd 없이도 정상 동작하도록 설정
6. **OpenCode 통합** — 선택 시, proot + ld.so 결합 방식으로 Bun 독립 실행 바이너리인 OpenCode 설치
## 처음부터 설치하기 (초기화된 폰 기준)
1. [폰 준비](#1단계-폰-준비)
2. [Termux 설치](#2단계-termux-설치)
3. [Termux 초기 설정](#3단계-termux-초기-설정)
4. [OpenClaw 설치](#4단계-openclaw-설치) — 명령어 하나
5. [OpenClaw 설정 시작](#5단계-openclaw-설정-시작)
6. [OpenClaw(게이트웨이) 실행](#6단계-openclaw게이트웨이-실행)
### 1단계: 폰 준비
개발자 옵션, 화면 켜짐 유지, 충전 제한, 배터리 최적화 설정을 진행합니다. [프로세스 라이브 상태 유지 가이드](docs/disable-phantom-process-killer.ko.md)를 참고하세요.
### 2단계: Termux 설치
> **중요**: Google Play Store의 Termux는 업데이트가 중단되어 정상 동작하지 않습니다. 반드시 F-Droid에서 설치하세요.
1. 폰 브라우저에서 [F-Droid 공식 사이트](https://f-droid.org)에 접속
2. `Termux` 검색 후 **Download APK**를 눌러 다운로드 및 설치
- "출처를 알 수 없는 앱" 설치 허용 팝업이 뜨면 **허용**
### 3단계: Termux 초기 설정
Termux 앱을 열고 아래 명령어를 붙여넣으세요. 다음 단계에 필요한 curl을 설치합니다.
```bash
pkg update -y && pkg install -y curl
```
> 처음 실행하면 저장소 미러를 선택하라는 메시지가 나올 수 있습니다. 아무거나 선택해도 되지만, 지역적으로 가까운 미러를 고르면 더 빠릅니다.
### 4단계: OpenClaw 설치
> **팁: SSH로 편하게 입력하기**
> 이 단계부터는 폰 화면 대신 컴퓨터 키보드로 명령어를 입력할 수 있습니다. [Termux SSH 접속 가이드](docs/termux-ssh-guide.ko.md)를 참고하세요.
Termux에 아래 명령어를 붙여넣으세요.
```bash
curl -sL myopenclawhub.com/install | bash && source ~/.bashrc
```
명령어 하나로 모든 설치가 자동으로 진행됩니다. 3~10분 정도 소요되며 (네트워크 속도와 기기 성능에 따라 다름), Wi-Fi 환경을 권장합니다.
설치가 완료되면 OpenClaw 버전이 출력되고, `openclaw onboard`로 설정을 시작하라는 안내가 나타납니다.
### 5단계: OpenClaw 설정 시작
설치 완료 메시지의 안내에 따라 아래 명령어를 실행합니다.
```bash
openclaw onboard
```
화면의 안내에 따라 초기 설정을 진행합니다.
![openclaw onboard](docs/images/openclaw-onboard.png)
### 6단계: OpenClaw(게이트웨이) 실행
설정이 끝나면 게이트웨이를 실행합니다:
> **중요**: `openclaw gateway`는 SSH가 아닌, 폰의 Termux 앱에서 직접 실행하세요. SSH로 실행하면 SSH 연결이 끊어질 때 게이트웨이도 함께 종료됩니다.
게이트웨이는 실행 중 터미널을 점유하므로, 별도 탭에서 실행하세요. 하단 메뉴바의 **햄버거 아이콘(☰)**을 탭하거나, 화면 왼쪽 가장자리에서 오른쪽으로 스와이프하면 (하단 메뉴바 위 영역) 사이드 메뉴가 나타납니다. **NEW SESSION**을 눌러 새 탭을 추가하세요.
<img src="docs/images/termux_menu.png" width="300" alt="Termux 사이드 메뉴">
새 탭에서 실행합니다:
```bash
openclaw gateway
```
<img src="docs/images/termux_tab_1.png" width="300" alt="openclaw gateway 실행 화면">
> 게이트웨이를 중지하려면 `Ctrl+C`를 누르세요. `Ctrl+Z`는 프로세스를 종료하지 않고 일시 중지만 시키므로, 반드시 `Ctrl+C`를 사용하세요.
## 프로세스 라이브 상태 유지
Android는 백그라운드 프로세스를 종료하거나 화면이 꺼지면 스로틀링할 수 있습니다. [프로세스 라이브 상태 유지 가이드](docs/disable-phantom-process-killer.ko.md)에서 모든 권장 설정(개발자 옵션, 화면 켜짐 유지, 충전 제한, 배터리 최적화, Phantom Process Killer)을 확인하세요.
## PC에서 대시보드 접속
SSH 접속 및 대시보드 터널 설정은 [Termux SSH 접속 가이드](docs/termux-ssh-guide.ko.md)를 참고하세요.
## 여러 디바이스 관리
같은 네트워크에서 여러 기기에 OpenClaw를 운영한다면, <a href="https://myopenclawhub.com" target="_blank">Dashboard Connect</a> 도구로 PC에서 편리하게 관리할 수 있습니다.
- 각 기기의 연결 정보(IP, 토큰, 포트)를 닉네임과 함께 저장
- SSH 터널 명령어와 대시보드 URL을 자동 생성
- **데이터는 로컬에만 저장** — 연결 정보(IP, 토큰, 포트)는 브라우저의 localStorage에만 저장되며 어떤 서버로도 전송되지 않습니다.
## CLI 명령어
설치 후 `oa` 명령어로 설치를 관리할 수 있습니다:
| 옵션 | 설명 |
|------|------|
| `oa --update` | OpenClaw 및 Android 패치 업데이트 |
| `oa --install` | 선택적 도구 설치 (tmux, code-server, AI CLI 등) |
| `oa --uninstall` | OpenClaw on Android 제거 |
| `oa --backup` | OpenClaw 데이터 전체 백업 생성 |
| `oa --restore` | 백업에서 복구 |
| `oa --status` | 설치 상태 및 모든 설치된 컴포넌트 정보 표시 |
| `oa --version` | 버전 표시 |
| `oa --help` | 사용 가능한 옵션 표시 |
## 업데이트
```bash
oa --update && source ~/.bashrc
```
이 명령어 하나로 설치된 모든 컴포넌트를 한번에 업데이트합니다:
- **OpenClaw** — 코어 패키지 (`openclaw@latest`)
- **code-server** — 브라우저 IDE
- **OpenCode** — AI 코딩 어시스턴트
- **AI CLI 도구** — Claude Code, Gemini CLI, Codex CLI (Termux)
- **Android 패치** — 이 프로젝트의 호환성 패치
이미 최신인 컴포넌트는 스킵됩니다. 설치하지 않은 컴포넌트는 건드리지 않고 — 기기에 이미 설치된 것만 업데이트합니다. 여러 번 실행해도 안전합니다.
> `oa` 명령어가 없는 경우 (이전 설치 사용자), curl로 실행:
> ```bash
> curl -sL myopenclawhub.com/update | bash && source ~/.bashrc
> ```
## 백업 및 복구
OpenClaw의 내장 백업 명령어(`openclaw backup create`)는 Android의 앱 전용 저장소에서 하드링크(`fs.link()`) 생성이 제한되어 있어 실패하는 경우가 많습니다. `oa --backup` 명령어는 `tar`를 직접 사용하여 이 문제를 해결하면서도, OpenClaw 백업 규격과 완벽하게 호환되는 아카이브를 생성합니다.
백업 생성:
```bash
oa --backup
```
백업은 `~/.openclaw-android/backup/` 폴더에 타임스탬프가 포함된 파일명(예: `2026-03-14T00-00-00.000Z-openclaw-backup.tar.gz`)으로 저장됩니다. `oa --backup ~/my-backups/`와 같이 사용자 지정 경로를 지정할 수도 있습니다. 백업에는 설정, 상태, 워크스페이스, 에이전트 데이터가 모두 포함됩니다.
백업에서 복구:
```bash
oa --restore
```
이 명령어를 실행하면 기본 백업 폴더에 있는 사용 가능한 백업 목록이 표시됩니다. 복구하려는 백업의 번호를 선택하면 됩니다. 도구가 백업 매니페스트에서 플랫폼을 자동으로 감지하여 `~/.openclaw/` 경로로 복구를 진행합니다. 기존 데이터를 덮어쓰게 되므로 실행 전 확인 절차가 진행됩니다.
## 문제 해결
자세한 트러블슈팅 가이드는 [문제 해결 문서](docs/troubleshooting.ko.md)를 참고하세요.
## 성능
`openclaw status` 같은 명령어는 PC보다 느리게 느껴질 수 있습니다. 이는 명령어를 실행할 때마다 많은 파일을 읽어야 하는데, 폰의 저장장치가 PC보다 느리고 Android의 보안 처리가 추가되기 때문입니다.
단, **게이트웨이가 실행된 이후에는 차이가 없습니다**. 프로세스가 메모리에 상주하므로 파일을 다시 읽지 않고, AI 응답은 외부 서버에서 처리되므로 PC와 동일한 속도입니다.
## 로컬 LLM 실행
OpenClaw은 [node-llama-cpp](https://github.com/withcatai/node-llama-cpp)를 통해 로컬 LLM 추론을 지원합니다. 프리빌트 네이티브 바이너리(`@node-llama-cpp/linux-arm64`)가 설치에 포함되어 있으며, glibc 환경에서 정상적으로 로딩됩니다 — **폰에서 로컬 LLM 구동이 기술적으로 가능합니다**.
다만 현실적인 제약이 있습니다:
| 제약 | 상세 |
|------|------|
| RAM | GGUF 모델은 최소 2-4GB 여유 메모리 필요 (7B 모델, Q4 양자화 기준). 폰 RAM은 Android와 다른 앱이 공유 |
| 저장공간 | 모델 파일 크기 4GB~70GB+. 폰 저장공간이 빠르게 소진됨 |
| 속도 | ARM CPU에서 추론은 매우 느림. Android에서는 llama.cpp GPU 오프로딩을 지원하지 않음 |
| 용도 | OpenClaw는 주로 클라우드 LLM API(OpenAI, Gemini 등)로 라우팅하며, PC와 동일한 속도로 응답. 로컬 추론은 보조 기능 |
실험 목적이라면 TinyLlama 1.1B (Q4, ~670MB) 같은 소형 모델은 폰에서 실행할 수 있습니다. 실제 사용에는 클라우드 LLM 제공자를 권장합니다.
> **왜 `--ignore-scripts`인가?** 설치 스크립트는 `npm install -g openclaw@latest --ignore-scripts`를 사용합니다. node-llama-cpp의 postinstall 스크립트가 cmake로 llama.cpp 소스를 빌드하려고 시도하는데, 폰에서 30분 이상 소요되며 툴체인 호환성 문제로 실패합니다. 프리빌트 바이너리는 이 빌드 과정 없이 작동하므로, postinstall을 안전하게 건너뜁니다.
<details>
<summary>개발자용 기술 문서</summary>
## 설치 컴포넌트
설치 스크립트는 여러 패키지 매니저를 통해 인프라, 플랫폼 패키지, 선택적 도구를 설치합니다. 핵심 인프라와 플랫폼 의존성은 자동으로 설치되고, 선택적 도구는 설치 중 개별적으로 선택할 수 있습니다.
### 핵심 인프라 (항상 설치)
| 컴포넌트 | 역할 | 설치 방식 |
|----------|------|-----------|
| git | 버전 관리, npm git 의존성 | `pkg install` |
### 에이전트 플랫폼 런타임 의존성
플랫폼의 `config.env` 플래그로 제어됩니다. OpenClaw의 경우 모두 설치됩니다:
| 컴포넌트 | 역할 | 설치 방식 |
|----------|------|-----------|
| [pacman](https://wiki.archlinux.org/title/Pacman) | glibc 패키지 관리자 | `pkg install` |
| [glibc-runner](https://github.com/termux-pacman/glibc-packages) | glibc 동적 링커 — 표준 Linux 바이너리를 Android에서 실행 | `pacman -Sy` |
| [Node.js](https://nodejs.org/) v22 LTS (linux-arm64) | OpenClaw용 JavaScript 런타임 | nodejs.org에서 직접 다운로드 |
| python | 네이티브 C/C++ 애드온 빌드 스크립트 (node-gyp) | `pkg install` |
| make | 네이티브 모듈 Makefile 실행 | `pkg install` |
| cmake | CMake 기반 네이티브 모듈 빌드 | `pkg install` |
| clang | 네이티브 모듈용 C/C++ 컴파일러 | `pkg install` |
| binutils | 네이티브 빌드용 바이너리 유틸리티 (llvm-ar) | `pkg install` |
### OpenClaw 플랫폼
| 컴포넌트 | 역할 | 설치 방식 |
|----------|------|-----------|
| [OpenClaw](https://github.com/openclaw/openclaw) | AI 에이전트 플랫폼 (핵심) | `npm install -g` |
| [clawdhub](https://github.com/AidanPark/clawdhub) | OpenClaw 스킬 매니저 | `npm install -g` |
| [PyYAML](https://pyyaml.org/) | `.skill` 패키징용 YAML 파서 | `pip install` |
| libvips | sharp 빌드용 이미지 처리 헤더 | `pkg install` (업데이트 시) |
### 선택적 도구 (설치 중 선택)
각 도구는 개별 Y/n 프롬프트로 제공됩니다. 원하는 도구만 선택하여 설치할 수 있습니다.
| 컴포넌트 | 역할 | 설치 방식 |
|----------|------|-----------|
| [tmux](https://github.com/tmux/tmux) | 백그라운드 세션용 터미널 멀티플렉서 | `pkg install` |
| [ttyd](https://github.com/tsl0922/ttyd) | 웹 터미널 — 브라우저에서 Termux 접속 | `pkg install` |
| [dufs](https://github.com/sigoden/dufs) | HTTP/WebDAV 파일 서버 | `pkg install` |
| [android-tools](https://developer.android.com/tools/adb) | Phantom Process Killer 비활성화용 ADB | `pkg install` |
| [code-server](https://github.com/coder/code-server) | 브라우저 기반 VS Code IDE | GitHub에서 직접 다운로드 |
| [OpenCode](https://opencode.ai/) | AI 코딩 어시스턴트 (TUI). [Bun](https://bun.sh/)과 [proot](https://proot-me.github.io/)을 의존성으로 자동 설치 | `bun install -g` |
| [Chromium](https://www.chromium.org/) | OpenClaw 브라우저 자동화 (~400MB) | 전용 설치 스크립트 |
| [Playwright](https://playwright.dev/) | 브라우저 자동화 라이브러리 (Chromium 필요). `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` 자동 설정 | 전용 설치 스크립트 |
| [Claude Code](https://github.com/anthropics/claude-code) (Anthropic) | AI CLI 도구 | `npm install -g` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) (Google) | AI CLI 도구 | `npm install -g` |
| [Codex CLI](https://github.com/DioNanos/codex-termux) (OpenAI Codex의 Termux 포크) | AI CLI 도구 | `npm install -g` |
## 프로젝트 구조
```
openclaw-android/
├── bootstrap.sh # curl | bash 원라이너 설치 (다운로더)
├── install.sh # 플랫폼 인식 설치 스크립트 (진입점)
├── oa.sh # 통합 CLI (설치 시 $PREFIX/bin/oa로 설치)
├── post-setup.sh # Claw 앱 부트스트랩 후 설치 (OTA 배포)
├── update.sh # Thin wrapper (update-core.sh 다운로드 후 실행)
├── update-core.sh # 기존 설치 환경 경량 업데이터
├── uninstall.sh # 깔끔한 제거 (오케스트레이터)
├── patches/
│ ├── glibc-compat.js # Node.js 런타임 패치 (os.cpus, networkInterfaces)
│ ├── argon2-stub.js # argon2 네이티브 모듈용 JS 스텅 (code-server)
│ ├── termux-compat.h # Bionic 네이티브 빌드용 C 헤더 (sharp)
│ ├── spawn.h # POSIX spawn 스텅 헤더
│ ├── systemctl # Termux용 systemd 스텅
│ ├── apply-patches.sh # 레거시 패치 오케스트레이터 (v1.0.2 호환)
│ └── patch-paths.sh # 레거시 경로 수정 (v1.0.2 호환)
├── scripts/
│ ├── lib.sh # 공유 함수 라이브러리 (색상, 플랫폼 감지, 프롬프트)
│ ├── check-env.sh # 사전 환경 점검
│ ├── install-infra-deps.sh # 핵심 인프라 패키지 (L1)
│ ├── install-glibc.sh # glibc-runner 설치 (L2 조건부)
│ ├── install-nodejs.sh # Node.js glibc 래퍼 설치 (L2 조건부)
│ ├── install-build-tools.sh # 네이티브 모듈용 빌드 도구 (L2 조건부)
│ ├── backup.sh # OpenClaw 데이터 백업/복구 (oa --backup/--restore)
│ ├── build-sharp.sh # sharp 네이티브 모듈 빌드 (이미지 처리)
│ ├── install-chromium.sh # 브라우저 자동화용 Chromium 설치
│ ├── install-playwright.sh # Playwright 브라우저 자동화 라이브러리 설치
│ ├── install-code-server.sh # code-server 설치/업데이트 (브라우저 IDE)
│ ├── install-opencode.sh # OpenCode 설치
│ ├── setup-env.sh # 환경변수 설정
│ └── setup-paths.sh # 디렉토리 및 심볼릭 링크 생성
├── platforms/
│ ├── openclaw/ # OpenClaw 플랫폼 플러그인
│ │ ├── config.env # 플랫폼 메타데이터 및 의존성 선언
│ │ ├── env.sh # 플랫폼별 환경변수
│ │ ├── install.sh # 플랫폼 패키지 설치 (npm, 패치, clawdhub)
│ │ ├── update.sh # 플랫폼 패키지 업데이트
│ │ ├── uninstall.sh # 플랫폼 패키지 제거
│ │ ├── status.sh # 플랫폼 상태 표시
│ │ ├── verify.sh # 플랫폼 검증 체크
│ │ └── patches/ # 플랫폼 전용 패치
│ │ ├── openclaw-apply-patches.sh
│ │ ├── openclaw-patch-paths.sh
│ │ └── openclaw-build-sharp.sh
├── tests/
│ └── verify-install.sh # 설치 후 검증 (오케스트레이터 + 플랫폼)
└── docs/
├── disable-phantom-process-killer.md # 프로세스 라이브 상태 유지 가이드 (영문)
├── disable-phantom-process-killer.ko.md # 프로세스 라이브 상태 유지 가이드 (한국어)
├── termux-ssh-guide.md # Termux SSH 접속 가이드 (영문)
├── termux-ssh-guide.ko.md # Termux SSH 접속 가이드 (한국어)
├── troubleshooting.md # 트러블슈팅 가이드 (영문)
├── troubleshooting.ko.md # 트러블슈팅 가이드 (한국어)
└── images/ # 스크린샷 및 이미지
```
## 아키텍처
이 프로젝트는 **플랫폼 플러그인 아키텍처**를 사용하여 플랫폼 비종속 인프라와 플랫폼별 코드를 분리합니다:
```
┌─────────────────────────────────────────────────────────────┐
│ 오케스트레이터 (install.sh, update-core.sh, uninstall.sh) │
│ ── 플랫폼 비종속. config.env를 읽고 위임. │
├─────────────────────────────────────────────────────────────┤
│ 공유 스크립트 (scripts/) │
│ ── L1: install-infra-deps.sh (항상 실행) │
│ ── L2: install-glibc.sh, install-nodejs.sh, │
│ install-build-tools.sh (config.env 조건부) │
│ ── L3: 선택적 도구 (사용자 선택) │
├─────────────────────────────────────────────────────────────┤
│ 플랫폼 플러그인 (platforms/<name>/) │
│ ── config.env: 의존성 선언 (PLATFORM_NEEDS_*) │
│ ── install.sh / update.sh / uninstall.sh / ... │
└─────────────────────────────────────────────────────────────┘
```
**의존성 계층:**
| 계층 | 범위 | 예시 | 제어 주체 |
|------|------|------|-----------|
| L1 | 인프라 (항상 설치) | git, `pkg update` | 오케스트레이터 |
| L2 | 플랫폼 런타임 (조건부) | glibc, Node.js, 빌드 도구 | `config.env` 플래그 |
| L3 | 선택적 도구 (사용자 선택) | tmux, code-server, AI CLI | 사용자 프롬프트 |
각 플랫폼은 `config.env`에서 L2 의존성을 선언합니다:
```bash
# platforms/openclaw/config.env
PLATFORM_NEEDS_GLIBC=true
PLATFORM_NEEDS_NODEJS=true
PLATFORM_NEEDS_BUILD_TOOLS=true
```
오케스트레이터는 이 플래그를 읽고 해당하는 설치 스크립트를 조건부로 실행합니다. 특정 의존성이 필요 없는 플랫폼은 해당 플래그를 `false`로 설정하면 무거운 의존성이 전부 스킵됩니다.
## 설치 흐름 상세
`bash install.sh`를 실행하면 아래 8단계가 순서대로 실행됩니다.
### [1/8] 환경 체크 — `scripts/check-env.sh`
설치를 시작하기 전에 현재 환경이 적합한지 검증합니다.
- **Termux 감지**: `$PREFIX` 환경변수 존재 여부로 Termux 환경인지 확인. 없으면 즉시 종료
- **아키텍처 확인**: `uname -m`으로 CPU 아키텍처 확인 (aarch64 권장, armv7l 지원, x86_64은 에뮬레이터로 판단)
- **디스크 여유 공간**: `$PREFIX` 파티션에 최소 1000MB 이상 여유 공간이 있는지 확인. 부족하면 오류
- **기존 설치 감지**: `openclaw` 명령어가 이미 존재하면 현재 버전을 표시하고 재설치/업데이트임을 안내
- **Node.js 사전 확인**: 이미 설치된 Node.js가 있으면 버전을 표시하고, 22 미만이면 업그레이드 예고
- **Phantom Process Killer** (Android 12+): Phantom Process Killer에 대한 안내 메시지와 [비활성화 가이드](docs/disable-phantom-process-killer.ko.md) 링크를 표시
### [2/8] 플랫폼 선택
설치할 플랫폼을 선택합니다. 현재는 `openclaw`으로 하드코딩되어 있습니다. 향후 여러 플랫폼이 제공되면 선택 UI가 추가될 예정입니다.
`scripts/lib.sh``load_platform_config()`를 통해 플랫폼의 `config.env`를 로드하여, 이후 단계에서 사용할 모든 `PLATFORM_*` 변수를 내보냅니다.
### [3/8] 선택적 도구 선택 (L3)
11개의 개별 Y/n 프롬프트(`/dev/tty` 사용)로 선택적 도구를 선택합니다:
- tmux, ttyd, dufs, android-tools
- Chromium, Playwright
- code-server, OpenCode
- Claude Code, Gemini CLI, Codex CLI (Termux)
모든 선택은 설치 시작 전에 한 번에 수집됩니다. 사용자가 모든 결정을 마치면 설치 중 자리를 비울 수 있습니다.
### [4/8] 핵심 인프라 (L1) — `scripts/install-infra-deps.sh` + `scripts/setup-paths.sh`
플랫폼 선택과 무관하게 항상 실행됩니다.
**install-infra-deps.sh:**
- `pkg update -y && pkg upgrade -y`로 패키지 저장소 갱신 및 업그레이드
- `git` 설치 (npm git 의존성 및 저장소 클론에 필요)
**setup-paths.sh:**
- `$PREFIX/tmp``$HOME/.openclaw-android/patches` 디렉토리 생성
- 표준 Linux 경로(`/bin/sh`, `/usr/bin/env`, `/tmp`)의 Termux 매핑 표시
### [5/8] 플랫폼 런타임 의존성 (L2)
플랫폼의 `config.env` 플래그에 따라 런타임 의존성을 조건부로 설치합니다:
| 플래그 | 스크립트 | 설치 내용 |
|--------|----------|----------|
| `PLATFORM_NEEDS_GLIBC=true` | `scripts/install-glibc.sh` | pacman, glibc-runner (`ld-linux-aarch64.so.1` 제공) |
| `PLATFORM_NEEDS_NODEJS=true` | `scripts/install-nodejs.sh` | Node.js v22 LTS linux-arm64, grun 스타일 래퍼 스크립트 |
| `PLATFORM_NEEDS_BUILD_TOOLS=true` | `scripts/install-build-tools.sh` | python, make, cmake, clang, binutils |
각 스크립트는 사전 체크와 멱등성(이미 설치된 경우 스킵)을 갖춘 독립 실행형입니다.
### [6/8] 플랫폼 패키지 설치 (L2) — `platforms/<platform>/install.sh`
플랫폼 고유의 설치 스크립트에 위임합니다. OpenClaw의 경우:
1. `CPATH`를 glib-2.0 헤더용으로 설정 (네이티브 모듈 빌드에 필요)
2. pip으로 PyYAML 설치 (`.skill` 패키징용)
3. `glibc-compat.js``~/.openclaw-android/patches/`에 복사
4. `systemctl` 스텅을 `$PREFIX/bin/`에 설치
5. `npm install -g openclaw@latest --ignore-scripts` 실행
6. `openclaw-apply-patches.sh`로 플랫폼별 패치 적용
7. `clawdhub` (스킬 매니저) 및 필요 시 `undici` 의존성 설치
8. `openclaw update` 실행 (sharp 등 네이티브 모듈 빌드 포함)
**[6.5] 환경변수 + CLI + 마커:**
플랫폼 설치 후 오케스트레이터가:
- `setup-env.sh`를 실행하여 `.bashrc` 환경변수 블록 작성
- 플랫폼의 `env.sh`를 평가하여 플랫폼별 변수 설정
- 플랫폼 마커 파일(`~/.openclaw-android/.platform`) 기록
- `oa` CLI와 `oaupdate` 래퍼를 `$PREFIX/bin/`에 설치
- `lib.sh`, `setup-env.sh`, 플랫폼 디렉토리를 `~/.openclaw-android/`에 복사 (업데이터와 언인스톨러가 사용)
### [7/8] 선택적 도구 설치 (L3)
3단계에서 선택한 도구를 설치합니다:
- **Termux 패키지**: tmux, ttyd, dufs, android-tools — `pkg install`로 설치
- **code-server**: 브라우저 기반 VS Code IDE. Termux 전용 워커라운드 포함 (번들 node 교체, argon2 패치, 하드 링크 실패 처리)
- **OpenCode**: AI 코딩 어시스턴트. proot + ld.so 결합 방식으로 Bun 독립 실행 바이너리 지원
- **Chromium**: OpenClaw 브라우저 자동화 지원 (~400MB)
- **Playwright**: 브라우저 자동화 라이브러리 (`playwright-core` npm 설치). `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH``PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` 환경변수 자동 설정. Chromium 미설치 시 자동 설치
- **AI CLI 도구**: Claude Code, Gemini CLI, Codex CLI (Termux) — `npm install -g`로 설치
### [8/8] 검증 — `tests/verify-install.sh`
2단계 검증을 실행합니다:
**오케스트레이터 검증 (FAIL 레벨):**
| 검증 항목 | PASS 조건 |
|-----------|----------|
| Node.js 버전 | `node -v` >= 22 |
| npm | `npm` 명령어 존재 |
| TMPDIR | 환경변수 설정됨 |
| OA_GLIBC | `1`로 설정됨 |
| glibc-compat.js | `~/.openclaw-android/patches/`에 파일 존재 |
| .glibc-arch | 마커 파일 존재 |
| glibc 동적 링커 | `ld-linux-aarch64.so.1` 존재 |
| glibc node 래퍼 | `~/.openclaw-android/bin/node`에 래퍼 스크립트 존재 |
| 디렉토리 | `~/.openclaw-android`, `$PREFIX/tmp` 존재 |
| .bashrc | 환경변수 블록 포함 |
**오케스트레이터 검증 (WARN 레벨, 비필수):**
| 검증 항목 | PASS 조건 |
|-----------|----------|
| code-server | `code-server --version` 성공 |
| opencode | `opencode` 명령어 존재 |
**플랫폼 검증**`platforms/<platform>/verify.sh`에 위임:
| 검증 항목 | PASS 조건 |
|-----------|----------|
| openclaw | `openclaw --version` 성공 |
| CONTAINER | `1`로 설정됨 |
| clawdhub | 명령어 존재 |
| ~/.openclaw | 디렉토리 존재 |
모든 FAIL 레벨 항목 통과 시 PASSED. FAIL 발생 시 재설치 안내를 표시합니다. WARN 항목은 실패로 처리되지 않습니다.
## 경량 업데이터 흐름 — `oa --update`
`oa --update` (또는 하위 호환을 위한 `oaupdate`)를 실행하면 GitHub에서 최신 릴리스 tarball을 다운로드하고 아래 5단계를 순서대로 실행합니다.
### [1/5] 사전 점검
업데이트를 위한 최소 조건을 확인합니다.
- `$PREFIX` 존재 확인 (Termux 환경)
- `curl` 사용 가능 여부 확인
- `~/.openclaw-android/.platform` 마커 파일에서 플랫폼 감지
- 아키텍처 감지: glibc (`.glibc-arch` 마커) 또는 Bionic (레거시)
- 구버전 디렉토리 마이그레이션 (`.openclaw-lite``.openclaw-android` — 레거시 호환)
- **Phantom Process Killer** (Android 12+): [비활성화 가이드](docs/disable-phantom-process-killer.ko.md) 링크와 함께 안내 메시지를 표시
### [2/5] 최신 릴리스 다운로드
GitHub에서 전체 저장소 tarball을 다운로드하고 임시 디렉토리에 추출합니다. 필수 파일의 존재를 확인합니다:
- `scripts/lib.sh`
- `scripts/setup-env.sh`
- `platforms/<platform>/config.env`
- `platforms/<platform>/update.sh`
### [3/5] 핵심 인프라 업데이트
업데이터, 언인스톨러, CLI가 사용하는 공유 파일을 갱신합니다:
- 최신 플랫폼 디렉토리를 `~/.openclaw-android/platforms/`에 복사
- `~/.openclaw-android/scripts/``lib.sh``setup-env.sh` 갱신
- 패치 파일 갱신 (`glibc-compat.js`, `argon2-stub.js`, `spawn.h`, `systemctl`)
- `$PREFIX/bin/``oa` CLI와 `oaupdate` 래퍼 갱신
- `~/.openclaw-android/``uninstall.sh` 갱신
- Bionic 아키텍처가 감지되면 자동 glibc 마이그레이션 수행
- `setup-env.sh`를 실행하여 `.bashrc` 환경변수 블록 갱신
### [4/5] 플랫폼 업데이트
`platforms/<platform>/update.sh`에 위임합니다. OpenClaw의 경우:
- 빌드 의존성 설치 (`libvips`, `binutils`)
- `openclaw` npm 패키지를 최신 버전으로 업데이트
- 플랫폼별 패치 재적용
- openclaw이 업데이트된 경우 sharp 네이티브 모듈 재빌드
- `clawdhub` (스킬 매니저) 업데이트/설치
- 필요 시 clawdhub용 `undici` 설치 (Node.js v24+)
- 필요 시 `~/skills/`에서 `~/.openclaw/workspace/skills/`로 스킬 마이그레이션
- PyYAML 누락 시 설치
### [5/5] 선택적 도구 업데이트
이미 설치된 도구만 업데이트합니다:
- **code-server**: `install-code-server.sh`를 update 모드로 실행. 미설치 시 스킵
- **OpenCode**: 설치된 경우 업데이트, 미설치 시 설치 여부 문의. glibc 아키텍처 필요
- **Chromium**: 설치된 경우 업데이트. 미설치 시 스킵
- **AI CLI 도구** (Claude Code, Gemini CLI, Codex CLI (Termux)): 설치된 버전과 최신 npm 버전을 비교하여 필요 시 업데이트. 미설치 도구는 설치를 제안하지 않음
</details>
## 라이선스
MIT
+601
View File
@@ -0,0 +1,601 @@
# OpenClaw on Android
[한국어](README.ko.md) | [中文](README.zh.md)
<img src="docs/images/openclaw_android.jpg" alt="OpenClaw on Android">
![Android 7.0+](https://img.shields.io/badge/Android-7.0%2B-brightgreen)
![Termux](https://img.shields.io/badge/Termux-Required-orange)
![No proot](https://img.shields.io/badge/proot--distro-Not%20Required-blue)
![License MIT](https://img.shields.io/github/license/AidanPark/openclaw-android)
![GitHub Stars](https://img.shields.io/github/stars/AidanPark/openclaw-android)
Because Android deserves a shell.
## No Linux install required
The standard approach to running OpenClaw on Android requires installing proot-distro with Linux, adding 700MB-1GB of overhead. OpenClaw on Android eliminates this by installing just the glibc dynamic linker (ld.so), letting you run OpenClaw without a full Linux distribution.
**Standard approach**: Install a full Linux distribution in Termux via proot-distro.
```
┌───────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌───────────────────────────────────────────────┐ │
│ │ Android · Bionic libc · Termux │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ proot-distro · Debian/Ubuntu │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ GNU glibc │ │ │ │
│ │ │ │ Node.js → OpenClaw │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
**This project**: No proot-distro — just the glibc dynamic linker.
```
┌───────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌───────────────────────────────────────────────┐ │
│ │ Android · Bionic libc · Termux │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ glibc ld.so (linker only) │ │ │
│ │ │ ld.so → Node.js → OpenClaw │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
| | Standard (proot-distro) | This project |
|---|---|---|
| Storage overhead | 1-2GB (Linux + packages) | ~200MB |
| Setup time | 20-30 min | 3-10 min |
| Performance | Slower (proot layer) | Native speed |
| Setup steps | Install distro, configure Linux, install Node.js, fix paths... | Run one command |
## <img src="docs/images/claw-icon.svg" width="28" alt="Claw icon"> Claw App
A standalone Android app is also available. It bundles a terminal emulator and a WebView-based UI into a single APK — no Termux required.
- One-tap setup: bootstrap, Node.js, and OpenClaw installed from within the app
- Built-in dashboard for gateway control, runtime info, and tool management
- Works independently of Termux — installing the app does not affect an existing Termux + `oa` setup
Download the APK from the [Releases](https://github.com/AidanPark/openclaw-android/releases) page.
## Requirements
- Android 7.0 or higher (Android 10+ recommended)
- ~1GB free storage
- Wi-Fi or mobile data connection
## What It Does
The installer automatically resolves the differences between Termux and standard Linux. There's nothing you need to do manually — the single install command handles all of these:
1. **glibc environment** — Installs the glibc dynamic linker (via pacman's glibc-runner) so standard Linux binaries run without modification
2. **Node.js (glibc)** — Downloads official Node.js linux-arm64 and wraps it with an ld.so loader script (no patchelf, which causes segfault on Android)
3. **Path conversion** — Automatically converts standard Linux paths (`/tmp`, `/bin/sh`, `/usr/bin/env`) to Termux paths
4. **Temp folder setup** — Configures an accessible temp folder for Android
5. **Service manager bypass** — Configures normal operation without systemd
6. **OpenCode integration** — If selected, installs OpenCode using proot + ld.so concatenation for Bun standalone binaries
## Step-by-Step Setup (from a fresh phone)
1. [Prepare Your Phone](#step-1-prepare-your-phone)
2. [Install Termux](#step-2-install-termux)
3. [Initial Termux Setup](#step-3-initial-termux-setup)
4. [Install OpenClaw](#step-4-install-openclaw) — one command
5. [Start OpenClaw Setup](#step-5-start-openclaw-setup)
6. [Start OpenClaw (Gateway)](#step-6-start-openclaw-gateway)
### Step 1: Prepare Your Phone
Configure Developer Options, Stay Awake, charge limit, and battery optimization. See the [Keeping Processes Alive guide](docs/disable-phantom-process-killer.md) for step-by-step instructions.
### Step 2: Install Termux
> **Important**: The Play Store version of Termux is discontinued and will not work. You must install from F-Droid.
1. Open your phone's browser and go to [f-droid.org](https://f-droid.org)
2. Search for `Termux`, then tap **Download APK** to download and install
- Allow "Install from unknown sources" when prompted
### Step 3: Initial Termux Setup
Open the Termux app and paste the following command to install curl (needed for the next step).
```bash
pkg update -y && pkg install -y curl
```
> You may be asked to choose a mirror on first run. Pick any — a geographically closer mirror will be faster.
### Step 4: Install OpenClaw
> **Tip: Use SSH for easier typing**
> From this step on, you can type commands from your computer keyboard instead of the phone screen. See the [Termux SSH Setup Guide](docs/termux-ssh-guide.md) for details.
Paste the following command in Termux.
```bash
curl -sL myopenclawhub.com/install | bash && source ~/.bashrc
```
Everything is installed automatically with a single command. This takes 310 minutes depending on network speed and device. Wi-Fi is recommended.
Once complete, the OpenClaw version is displayed along with instructions to run `openclaw onboard`.
### Step 5: Start OpenClaw Setup
As instructed in the installation output, run:
```bash
openclaw onboard
```
Follow the on-screen instructions to complete the initial setup.
![openclaw onboard](docs/images/openclaw-onboard.png)
### Step 6: Start OpenClaw (Gateway)
Once setup is complete, start the gateway:
> **Important**: Run `openclaw gateway` directly in the Termux app on your phone, not via SSH. If you run it over SSH, the gateway will stop when the SSH session disconnects.
The gateway occupies the terminal while running, so open a new tab for it. Tap the **hamburger icon (☰)** on the bottom menu bar, or swipe right from the left edge of the screen (above the bottom menu bar) to open the side menu. Then tap **NEW SESSION**.
<img src="docs/images/termux_menu.png" width="300" alt="Termux side menu">
In the new tab, run:
```bash
openclaw gateway
```
<img src="docs/images/termux_tab_1.png" width="300" alt="openclaw gateway running">
> To stop the gateway, press `Ctrl+C`. Do not use `Ctrl+Z` — it only suspends the process without terminating it.
## Keeping Processes Alive
Android may kill background processes or throttle them when the screen is off. See the [Keeping Processes Alive guide](docs/disable-phantom-process-killer.md) for all recommended settings (Developer Options, Stay Awake, charge limit, battery optimization, and Phantom Process Killer).
## Access the Dashboard from Your PC
See the [Termux SSH Setup Guide](docs/termux-ssh-guide.md) for SSH access and dashboard tunnel setup.
## Managing Multiple Devices
If you run OpenClaw on multiple devices on the same network, use the <a href="https://myopenclawhub.com" target="_blank">Dashboard Connect</a> tool to manage them from your PC.
- Save connection settings (IP, token, ports) for each device with a nickname
- Generates the SSH tunnel command and dashboard URL automatically
- **Your data stays local** — Connection settings (IP, token, ports) are saved only in your browser's localStorage and are never sent to any server.
## CLI Reference
After installation, the `oa` command is available for managing your installation:
| Option | Description |
|--------|-------------|
| `oa --update` | Update OpenClaw and Android patches |
| `oa --install` | Install optional tools (tmux, code-server, AI CLIs, etc.) |
| `oa --uninstall` | Remove OpenClaw on Android |
| `oa --backup` | Create a full backup of OpenClaw data |
| `oa --restore` | Restore from a backup |
| `oa --status` | Show installation status and all installed components |
| `oa --version` | Show version |
| `oa --help` | Show available options |
## Update
```bash
oa --update && source ~/.bashrc
```
This single command updates all installed components at once:
- **OpenClaw** — Core package (`openclaw@latest`)
- **code-server** — Browser IDE
- **OpenCode** — AI coding assistant
- **AI CLI tools** — Claude Code, Gemini CLI, Codex CLI (Termux)
- **Android patches** — Compatibility patches from this project
Already up-to-date components are skipped. Components you haven't installed are not touched — only what's already on your device gets updated. Safe to run multiple times.
> If the `oa` command is not available (older installations), run it with curl:
> ```bash
> curl -sL myopenclawhub.com/update | bash && source ~/.bashrc
> ```
## Backup & Restore
OpenClaw's built-in backup command (`openclaw backup create`) often fails on Android because it relies on hardlinks, which are blocked in Android's app-private storage. The `oa --backup` command works around this by using `tar` directly while maintaining full compatibility with the OpenClaw backup specification.
To create a backup:
```bash
oa --backup
```
Backups are stored in `~/.openclaw-android/backup/` with a timestamped filename (e.g., `2026-03-14T00-00-00.000Z-openclaw-backup.tar.gz`). You can also specify a custom path: `oa --backup ~/my-backups/`. Each backup includes your configuration, state, workspaces, and agents.
To restore from a backup:
```bash
oa --restore
```
This command lists all available backups in the default backup directory. Simply select the number of the backup you wish to restore. The tool automatically detects the platform from the backup manifest and handles the restoration to `~/.openclaw/`. Note that this will overwrite existing data, so a confirmation is required.
## Troubleshooting
See the [Troubleshooting Guide](docs/troubleshooting.md) for detailed solutions.
## Performance
CLI commands like `openclaw status` may feel slower than on a PC. This is because each command needs to read many files, and the phone's storage is slower than a PC's, with Android's security processing adding overhead.
However, **once the gateway is running, there's no difference**. The process stays in memory so files don't need to be re-read, and AI responses are processed on external servers — the same speed as on a PC.
## Local LLM on Android
OpenClaw supports local LLM inference via [node-llama-cpp](https://github.com/withcatai/node-llama-cpp). The prebuilt native binary (`@node-llama-cpp/linux-arm64`) is included with the installation and loads successfully under the glibc environment — **local LLM is technically functional on the phone**.
However, there are practical constraints:
| Constraint | Details |
|------------|---------|
| RAM | GGUF models need at least 2-4GB of free memory (7B model, Q4 quantization). Phone RAM is shared with Android and other apps |
| Storage | Model files range from 4GB to 70GB+. Phone storage fills up fast |
| Speed | CPU-only inference on ARM is very slow. Android does not support GPU offloading for llama.cpp |
| Use case | OpenClaw primarily routes to cloud LLM APIs (OpenAI, Gemini, etc.) which respond at the same speed as on a PC. Local inference is a supplementary feature |
For experimentation, small models like TinyLlama 1.1B (Q4, ~670MB) can run on the phone. For production use, cloud LLM providers are recommended.
> **Why `--ignore-scripts`?** The installer uses `npm install -g openclaw@latest --ignore-scripts` because node-llama-cpp's postinstall script attempts to compile llama.cpp from source via cmake — a process that takes 30+ minutes on a phone and fails due to toolchain incompatibilities. The prebuilt binaries work without this compilation step, so the postinstall is safely skipped.
<details>
<summary>Technical Documentation for Developers</summary>
## Installed Components
The installer sets up infrastructure, platform packages, and optional tools across multiple package managers. Core infrastructure and platform dependencies are installed automatically; optional tools are individually prompted during install.
### Core Infrastructure
| Component | Role | Install Method |
|-----------|------|----------------|
| git | Version control, npm git dependencies | `pkg install` |
### Agent Platform Runtime Dependencies
These are controlled by the platform's `config.env` flags. For OpenClaw, all are installed:
| Component | Role | Install Method |
|-----------|------|----------------|
| [pacman](https://wiki.archlinux.org/title/Pacman) | Package manager for glibc packages | `pkg install` |
| [glibc-runner](https://github.com/termux-pacman/glibc-packages) | glibc dynamic linker — enables standard Linux binaries on Android | `pacman -Sy` |
| [Node.js](https://nodejs.org/) v22 LTS (linux-arm64) | JavaScript runtime for OpenClaw | Direct download from nodejs.org |
| python | Build scripts for native C/C++ addons (node-gyp) | `pkg install` |
| make | Makefile execution for native modules | `pkg install` |
| cmake | CMake-based native module builds | `pkg install` |
| clang | C/C++ compiler for native modules | `pkg install` |
| binutils | Binary utilities (llvm-ar) for native builds | `pkg install` |
### OpenClaw Platform
| Component | Role | Install Method |
|-----------|------|----------------|
| [OpenClaw](https://github.com/openclaw/openclaw) | AI agent platform (core) | `npm install -g` |
| [clawdhub](https://github.com/AidanPark/clawdhub) | Skill manager for OpenClaw | `npm install -g` |
| [PyYAML](https://pyyaml.org/) | YAML parser for `.skill` packaging | `pip install` |
| libvips | Image processing headers for sharp build | `pkg install` (on update) |
### Optional Tools (prompted during install)
Each tool is offered via an individual Y/n prompt. You choose which ones to install.
| Component | Role | Install Method |
|-----------|------|----------------|
| [tmux](https://github.com/tmux/tmux) | Terminal multiplexer for background sessions | `pkg install` |
| [ttyd](https://github.com/tsl0922/ttyd) | Web terminal — access Termux from a browser | `pkg install` |
| [dufs](https://github.com/sigoden/dufs) | HTTP/WebDAV file server for browser-based file transfer | `pkg install` |
| [android-tools](https://developer.android.com/tools/adb) | ADB for disabling Phantom Process Killer | `pkg install` |
| [code-server](https://github.com/coder/code-server) | Browser-based VS Code IDE | Direct download from GitHub |
| [OpenCode](https://opencode.ai/) | AI coding assistant (TUI). Auto-installs [Bun](https://bun.sh/) and [proot](https://proot-me.github.io/) as dependencies | `bun install -g` |
| [Chromium](https://www.chromium.org/) | Browser automation for OpenClaw (~400MB) | Custom install script |
| [Playwright](https://playwright.dev/) | Browser automation library (requires Chromium). Auto-configures `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` | Custom install script |
| [Claude Code](https://github.com/anthropics/claude-code) (Anthropic) | AI CLI tool | `npm install -g` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) (Google) | AI CLI tool | `npm install -g` |
| [Codex CLI](https://github.com/DioNanos/codex-termux) (Termux fork of OpenAI Codex) | AI CLI tool | `npm install -g` |
## Project Structure
```
openclaw-android/
├── bootstrap.sh # curl | bash one-liner installer (downloader)
├── install.sh # Platform-aware installer (entry point)
├── oa.sh # Unified CLI (installed as $PREFIX/bin/oa)
├── post-setup.sh # Claw App post-bootstrap setup (OTA delivery)
├── update.sh # Thin wrapper (downloads and runs update-core.sh)
├── update-core.sh # Lightweight updater for existing installations
├── uninstall.sh # Clean removal (orchestrator)
├── patches/
│ ├── glibc-compat.js # Node.js runtime patches (os.cpus, networkInterfaces)
│ ├── argon2-stub.js # JS stub for argon2 native module (code-server)
│ ├── termux-compat.h # C header for Bionic native builds (sharp)
│ ├── spawn.h # POSIX spawn stub header
│ ├── systemctl # systemd stub for Termux
│ ├── apply-patches.sh # Legacy patch orchestrator (v1.0.2 compat)
│ └── patch-paths.sh # Legacy path fixer (v1.0.2 compat)
├── scripts/
│ ├── lib.sh # Shared function library (colors, platform detection, prompts)
│ ├── check-env.sh # Pre-flight environment check
│ ├── install-infra-deps.sh # Core infrastructure packages (L1)
│ ├── install-glibc.sh # glibc-runner installation (L2 conditional)
│ ├── install-nodejs.sh # Node.js glibc wrapper installation (L2 conditional)
│ ├── install-build-tools.sh # Build tools for native modules (L2 conditional)
│ ├── backup.sh # Backup and restore OpenClaw data (oa --backup/--restore)
│ ├── build-sharp.sh # Build sharp native module (image processing)
│ ├── install-chromium.sh # Install Chromium for browser automation
│ ├── install-playwright.sh # Install Playwright browser automation library
│ ├── install-code-server.sh # Install/update code-server (browser IDE)
│ ├── install-opencode.sh # Install OpenCode
│ ├── setup-env.sh # Configure environment variables
│ └── setup-paths.sh # Create directories and symlinks
├── platforms/
│ ├── openclaw/ # OpenClaw platform plugin
│ │ ├── config.env # Platform metadata and dependency declarations
│ │ ├── env.sh # Platform-specific environment variables
│ │ ├── install.sh # Platform package install (npm, patches, clawdhub)
│ │ ├── update.sh # Platform package update
│ │ ├── uninstall.sh # Platform package removal
│ │ ├── status.sh # Platform status display
│ │ ├── verify.sh # Platform verification checks
│ │ └── patches/ # Platform-specific patches
│ │ ├── openclaw-apply-patches.sh
│ │ ├── openclaw-patch-paths.sh
│ │ └── openclaw-build-sharp.sh
├── tests/
│ └── verify-install.sh # Post-install verification (orchestrator + platform)
└── docs/
├── disable-phantom-process-killer.md # Keeping Processes Alive guide (EN)
├── disable-phantom-process-killer.ko.md # Keeping Processes Alive guide (KO)
├── termux-ssh-guide.md # Termux SSH setup guide (EN)
├── termux-ssh-guide.ko.md # Termux SSH setup guide (KO)
├── troubleshooting.md # Troubleshooting guide (EN)
├── troubleshooting.ko.md # Troubleshooting guide (KO)
└── images/ # Screenshots and images
```
## Architecture
The project uses a **platform-plugin architecture** that separates platform-agnostic infrastructure from platform-specific code:
```
┌─────────────────────────────────────────────────────────────┐
│ Orchestrators (install.sh, update-core.sh, uninstall.sh) │
│ ── Platform-agnostic. Read config.env and delegate. │
├─────────────────────────────────────────────────────────────┤
│ Shared Scripts (scripts/) │
│ ── L1: install-infra-deps.sh (always) │
│ ── L2: install-glibc.sh, install-nodejs.sh, │
│ install-build-tools.sh (conditional on config.env) │
│ ── L3: Optional tools (user-selected) │
├─────────────────────────────────────────────────────────────┤
│ Platform Plugins (platforms/<name>/) │
│ ── config.env: declares dependencies (PLATFORM_NEEDS_*) │
│ ── install.sh / update.sh / uninstall.sh / ... │
└─────────────────────────────────────────────────────────────┘
```
**Dependency layers:**
| Layer | Scope | Examples | Controlled by |
|-------|-------|----------|---------------|
| L1 | Infrastructure (always installed) | git, `pkg update` | Orchestrator |
| L2 | Platform runtime (conditional) | glibc, Node.js, build tools | `config.env` flags |
| L3 | Optional tools (user-selected) | tmux, code-server, AI CLIs | User prompts |
Each platform declares its L2 dependencies in `config.env`:
```bash
# platforms/openclaw/config.env
PLATFORM_NEEDS_GLIBC=true
PLATFORM_NEEDS_NODEJS=true
PLATFORM_NEEDS_BUILD_TOOLS=true
```
The orchestrator reads these flags and conditionally runs the corresponding install scripts. A platform that doesn't need certain dependencies simply sets the corresponding flags to `false` and those heavy dependencies are skipped entirely.
## Detailed Installation Flow
Running `bash install.sh` executes the following 8 steps in order.
### [1/8] Environment Check — `scripts/check-env.sh`
Validates that the current environment is suitable before starting installation.
- **Termux detection**: Checks for the `$PREFIX` environment variable. Exits immediately if not in Termux
- **Architecture check**: Runs `uname -m` to verify CPU architecture (aarch64 recommended, armv7l supported, x86_64 treated as emulator)
- **Disk space**: Ensures at least 1000MB free on the `$PREFIX` partition. Errors if insufficient
- **Existing installation**: If `openclaw` command already exists, shows current version and notes this is a reinstall/upgrade
- **Node.js pre-check**: If Node.js is already installed, shows version and warns if below 22
- **Phantom Process Killer** (Android 12+): Shows an informational note about the Phantom Process Killer with a link to the [disable guide](docs/disable-phantom-process-killer.md)
### [2/8] Platform Selection
Selects the platform to install. Currently hardcoded to `openclaw`. Future versions will present a selection UI when multiple platforms are available.
Loads the platform's `config.env` via `load_platform_config()` from `scripts/lib.sh`, which exports all `PLATFORM_*` variables for use by subsequent steps.
### [3/8] Optional Tools Selection (L3)
Presents 11 individual Y/n prompts (via `/dev/tty`) for optional tools:
- tmux, ttyd, dufs, android-tools
- Chromium, Playwright
- code-server, OpenCode
- Claude Code, Gemini CLI, Codex CLI (Termux)
All selections are collected upfront before any installation begins. This allows the user to make all decisions at once and walk away during the install.
### [4/8] Core Infrastructure (L1) — `scripts/install-infra-deps.sh` + `scripts/setup-paths.sh`
Always runs regardless of platform selection.
**install-infra-deps.sh:**
- Runs `pkg update -y && pkg upgrade -y` to refresh and upgrade packages
- Installs `git` (required for npm git dependencies and repo cloning)
**setup-paths.sh:**
- Creates `$PREFIX/tmp` and `$HOME/.openclaw-android/patches` directories
- Displays standard Linux path mappings (`/bin/sh`, `/usr/bin/env`, `/tmp`) to Termux equivalents
### [5/8] Platform Runtime Dependencies (L2)
Conditionally installs runtime dependencies based on the platform's `config.env` flags:
| Flag | Script | What it installs |
|------|--------|-----------------|
| `PLATFORM_NEEDS_GLIBC=true` | `scripts/install-glibc.sh` | pacman, glibc-runner (provides `ld-linux-aarch64.so.1`) |
| `PLATFORM_NEEDS_NODEJS=true` | `scripts/install-nodejs.sh` | Node.js v22 LTS linux-arm64, grun-style wrapper scripts |
| `PLATFORM_NEEDS_BUILD_TOOLS=true` | `scripts/install-build-tools.sh` | python, make, cmake, clang, binutils |
Each script is self-contained with pre-checks and idempotent behavior (skips if already installed).
### [6/8] Platform Package Install (L2) — `platforms/<platform>/install.sh`
Delegates to the platform's own install script. For OpenClaw, this:
1. Sets `CPATH` for glib-2.0 headers (needed for native module builds)
2. Installs PyYAML via pip (for `.skill` packaging)
3. Copies `glibc-compat.js` to `~/.openclaw-android/patches/`
4. Installs `systemctl` stub to `$PREFIX/bin/`
5. Runs `npm install -g openclaw@latest --ignore-scripts`
6. Applies platform-specific patches via `openclaw-apply-patches.sh`
7. Installs `clawdhub` (skill manager) and `undici` dependency if needed
8. Runs `openclaw update` (includes building native modules like sharp)
**[6.5] Environment Variables + CLI + Marker:**
After platform install, the orchestrator:
- Runs `setup-env.sh` to write the `.bashrc` environment block
- Evaluates the platform's `env.sh` for platform-specific variables
- Writes the platform marker file (`~/.openclaw-android/.platform`)
- Installs `oa` CLI and `oaupdate` wrapper to `$PREFIX/bin/`
- Copies `lib.sh`, `setup-env.sh`, and the platform directory to `~/.openclaw-android/` for use by the updater and uninstaller
### [7/8] Install Optional Tools (L3)
Installs the tools selected in Step 3:
- **Termux packages**: tmux, ttyd, dufs, android-tools — installed via `pkg install`
- **code-server**: Browser-based VS Code IDE with Termux-specific workarounds (replace bundled node, patch argon2, handle hard link failures)
- **OpenCode**: AI coding assistant using proot + ld.so concatenation for Bun standalone binaries
- **Chromium**: Browser automation support for OpenClaw (~400MB)
- **Playwright**: Browser automation library (`playwright-core` via npm). Auto-sets `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` and `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` environment variables. Installs Chromium automatically if not already present
- **AI CLI tools**: Claude Code, Gemini CLI, Codex CLI (Termux) — installed via `npm install -g`
### [8/8] Verification — `tests/verify-install.sh`
Runs a two-tier verification:
**Orchestrator checks (FAIL level):**
| Check Item | PASS Condition |
|------------|---------------|
| Node.js version | `node -v` >= 22 |
| npm | `npm` command exists |
| TMPDIR | Environment variable is set |
| OA_GLIBC | Set to `1` |
| glibc-compat.js | File exists in `~/.openclaw-android/patches/` |
| .glibc-arch | Marker file exists |
| glibc dynamic linker | `ld-linux-aarch64.so.1` exists |
| glibc node wrapper | Wrapper script at `~/.openclaw-android/bin/node` |
| Directories | `~/.openclaw-android`, `$PREFIX/tmp` exist |
| .bashrc | Contains environment variable block |
**Orchestrator checks (WARN level, non-critical):**
| Check Item | PASS Condition |
|------------|---------------|
| code-server | `code-server --version` succeeds |
| opencode | `opencode` command available |
**Platform verification** — delegates to `platforms/<platform>/verify.sh`:
| Check Item | PASS Condition |
|------------|---------------|
| openclaw | `openclaw --version` succeeds |
| CONTAINER | Set to `1` |
| clawdhub | Command available |
| ~/.openclaw | Directory exists |
All FAIL-level items pass → PASSED. Any FAIL → shows reinstall instructions. WARN items do not cause failure.
## Lightweight Updater Flow — `oa --update`
Running `oa --update` (or `oaupdate` for backward compatibility) downloads the latest release tarball from GitHub and executes the following 5 steps.
### [1/5] Pre-flight Check
Validates the minimum conditions for updating.
- Checks `$PREFIX` exists (Termux environment)
- Checks `curl` is available
- Detects platform from `~/.openclaw-android/.platform` marker file
- Detects architecture: glibc (`.glibc-arch` marker) or Bionic (legacy)
- Migrates old directory name if needed (`.openclaw-lite``.openclaw-android` — legacy compatibility)
- **Phantom Process Killer** (Android 12+): Shows an informational note with a link to the [disable guide](docs/disable-phantom-process-killer.md)
### [2/5] Download Latest Release
Downloads the full repository tarball from GitHub and extracts to a temp directory. Validates that all required files exist:
- `scripts/lib.sh`
- `scripts/setup-env.sh`
- `platforms/<platform>/config.env`
- `platforms/<platform>/update.sh`
### [3/5] Update Core Infrastructure
Updates shared files used by the updater, uninstaller, and CLI:
- Copies the latest platform directory to `~/.openclaw-android/platforms/`
- Updates `lib.sh` and `setup-env.sh` in `~/.openclaw-android/scripts/`
- Updates patch files (`glibc-compat.js`, `argon2-stub.js`, `spawn.h`, `systemctl`)
- Updates `oa` CLI and `oaupdate` wrapper in `$PREFIX/bin/`
- Updates `uninstall.sh` in `~/.openclaw-android/`
- If Bionic architecture detected, performs automatic glibc migration
- Runs `setup-env.sh` to refresh `.bashrc` environment block
### [4/5] Update Platform
Delegates to `platforms/<platform>/update.sh`. For OpenClaw, this:
- Installs build dependencies (`libvips`, `binutils`)
- Updates `openclaw` npm package to latest version
- Re-applies platform-specific patches
- Rebuilds sharp native module if openclaw was updated
- Updates/installs `clawdhub` (skill manager)
- Installs `undici` for clawdhub if needed (Node.js v24+)
- Migrates skills from `~/skills/` to `~/.openclaw/workspace/skills/` if needed
- Installs PyYAML if missing
### [5/5] Update Optional Tools
Updates tools that are already installed:
- **code-server**: Runs `install-code-server.sh` in update mode. Skipped if not installed
- **OpenCode**: Updates if installed; offers to install if not. Requires glibc architecture
- **Chromium**: Updates if installed. Skipped if not installed
- **AI CLI tools** (Claude Code, Gemini CLI, Codex CLI (Termux)): Compares installed vs latest npm version, updates if needed. Tools not installed are not offered for installation
</details>
## License
MIT
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`AidanPark/openclaw-android`
- 原始仓库:https://github.com/AidanPark/openclaw-android
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+606
View File
@@ -0,0 +1,606 @@
# OpenClaw on Android
[English](README.md) | [한국어](README.ko.md)
<img src="docs/images/openclaw_android.jpg" alt="OpenClaw on Android">
![Android 7.0+](https://img.shields.io/badge/Android-7.0%2B-brightgreen)
![Termux](https://img.shields.io/badge/Termux-Required-orange)
![No proot](https://img.shields.io/badge/proot--distro-Not%20Required-blue)
![License MIT](https://img.shields.io/github/license/AidanPark/openclaw-android)
![GitHub Stars](https://img.shields.io/github/stars/AidanPark/openclaw-android)
Android 也配拥有一个 Shell。
## 无需安装 Linux
在 Android 上运行 OpenClaw 的常规方法是通过 proot-distro 安装一个完整的 Linux 发行版,需要额外占用 700MB-1GB 的存储空间。OpenClaw on Android 只安装 glibc 动态链接器(ld.so),无需完整 Linux 发行版即可运行 OpenClaw。
**常规方法**:在 Termux 中通过 proot-distro 安装完整的 Linux 发行版。
```
┌───────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌───────────────────────────────────────────────┐ │
│ │ Android · Bionic libc · Termux │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ proot-distro · Debian/Ubuntu │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ GNU glibc │ │ │ │
│ │ │ │ Node.js → OpenClaw │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
**本项目**:无需 proot-distro,只安装 glibc 动态链接器。
```
┌───────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌───────────────────────────────────────────────┐ │
│ │ Android · Bionic libc · Termux │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ glibc ld.so (linker only) │ │ │
│ │ │ ld.so → Node.js → OpenClaw │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
| | 常规方法 (proot-distro) | 本项目 |
|---|---|---|
| 存储开销 | 1-2GBLinux + 软件包) | ~200MB |
| 安装时间 | 20-30 分钟 | 3-10 分钟 |
| 性能 | 较慢(proot 中间层) | 原生速度 |
| 安装步骤 | 安装发行版、配置 Linux、安装 Node.js、修复路径…… | 一条命令搞定 |
## <img src="docs/images/claw-icon.svg" width="28" alt="Claw icon"> Claw App
还提供了独立的 Android 应用。它将终端模拟器和基于 WebView 的界面打包成一个 APK,无需 Termux。
- 一键安装:在应用内完成 bootstrap、Node.js 和 OpenClaw 的安装
- 内置仪表盘:控制网关、查看运行状态、管理工具
- 独立于 Termux 运行 — 安装此应用不会影响已有的 Termux + `oa` 环境
前往 [Releases](https://github.com/AidanPark/openclaw-android/releases) 页面下载 APK。
> **中国用户**:如果无法直接从 GitHub 下载,可使用镜像链接:
> [ghfast.top 镜像下载](https://ghfast.top/https://github.com/AidanPark/openclaw-android/releases/latest/download/app-release.apk)
## 系统要求
- Android 7.0 或更高版本(推荐 Android 10+
- 约 1GB 可用存储空间
- Wi-Fi 或移动数据连接
## 安装内容
安装程序会自动处理 Termux 与标准 Linux 之间的差异。你无需手动操作——一条安装命令会完成以下所有工作:
1. **glibc 环境** — 安装 glibc 动态链接器(通过 pacman 的 glibc-runner),使标准 Linux 二进制文件无需修改即可运行
2. **Node.js (glibc)** — 下载官方 Node.js linux-arm64 版本,并通过 ld.so 加载脚本进行包装(不使用 patchelf,因为它会在 Android 上导致段错误)
3. **路径转换** — 自动将标准 Linux 路径(`/tmp``/bin/sh``/usr/bin/env`)转换为 Termux 路径
4. **临时目录配置** — 为 Android 配置可访问的临时文件夹
5. **服务管理绕过** — 在没有 systemd 的环境下配置正常运行
6. **OpenCode 集成** — 如果选择安装,会使用 proot + ld.so 拼接方式安装 OpenCode(用于 Bun 独立二进制文件)
## 从全新手机开始的详细步骤
1. [准备你的手机](#步骤一准备你的手机)
2. [安装 Termux](#步骤二安装-termux)
3. [Termux 初始设置](#步骤三termux-初始设置)
4. [安装 OpenClaw](#步骤四安装-openclaw) — 一条命令
5. [启动 OpenClaw 初始配置](#步骤五启动-openclaw-初始配置)
6. [启动 OpenClaw(网关)](#步骤六启动-openclaw网关)
### 步骤一:准备你的手机
配置开发者选项、保持唤醒、充电限制和电池优化。详细步骤请参阅 [保持进程存活指南](docs/disable-phantom-process-killer.md)。
### 步骤二:安装 Termux
> **重要提示**Play Store 版本的 Termux 已停止维护,无法正常使用。必须从 F-Droid 安装。
1. 用手机浏览器打开 [f-droid.org](https://f-droid.org)
2. 搜索 `Termux`,然后点击 **Download APK** 下载并安装
- 提示时请允许"安装来自未知来源的应用"
### 步骤三:Termux 初始设置
打开 Termux 应用,粘贴以下命令安装 curl(下一步需要用到)。
```bash
pkg update -y && pkg install -y curl
```
> 首次运行时可能会要求你选择镜像源。随便选一个就行,选地理位置较近的会更快。
### 步骤四:安装 OpenClaw
> **提示:使用 SSH 输入更方便**
> 从这一步开始,你可以用电脑键盘输入命令,而不必在手机屏幕上打字。详见 [Termux SSH 设置指南](docs/termux-ssh-guide.md)。
在 Termux 中粘贴以下命令。
```bash
curl -sL myopenclawhub.com/install | bash && source ~/.bashrc
```
一条命令自动完成所有安装。根据网络速度和设备性能,大约需要 3-10 分钟。建议使用 Wi-Fi。
> **中国网络优化**:脚本会自动检测网络环境。如果 GitHub 访问较慢,会自动切换到镜像站点下载;如果 npm 安装速度较慢,会自动切换到 npmmirror.com 镜像。无需手动配置。
安装完成后,会显示 OpenClaw 版本以及运行 `openclaw onboard` 的提示。
### 步骤五:启动 OpenClaw 初始配置
按照安装输出中的提示,运行:
```bash
openclaw onboard
```
按照屏幕上的指引完成初始设置。
![openclaw onboard](docs/images/openclaw-onboard.png)
### 步骤六:启动 OpenClaw(网关)
初始设置完成后,启动网关:
> **重要提示**:请直接在手机上的 Termux 应用中运行 `openclaw gateway`,不要通过 SSH。如果通过 SSH 运行,当 SSH 会话断开时网关也会停止。
网关运行时会占用当前终端,因此需要新开一个标签页。点击底部菜单栏的 **汉堡菜单图标(☰)**,或从屏幕左侧边缘向右滑动(在底部菜单栏上方区域)打开侧边菜单,然后点击 **NEW SESSION**
<img src="docs/images/termux_menu.png" width="300" alt="Termux 侧边菜单">
在新标签页中运行:
```bash
openclaw gateway
```
<img src="docs/images/termux_tab_1.png" width="300" alt="openclaw gateway 运行中">
> 要停止网关,按 `Ctrl+C`。不要使用 `Ctrl+Z` — 它只会挂起进程而不会终止它。
## 保持进程存活
Android 可能会在屏幕关闭时杀死后台进程或对其进行限制。详细的推荐设置请参阅 [保持进程存活指南](docs/disable-phantom-process-killer.md)(开发者选项、保持唤醒、充电限制、电池优化和 Phantom Process Killer)。
## 从电脑访问仪表盘
请参阅 [Termux SSH 设置指南](docs/termux-ssh-guide.md) 了解 SSH 访问和仪表盘隧道设置。
## 管理多台设备
如果你在同一网络中的多台设备上运行 OpenClaw,可以使用 <a href="https://myopenclawhub.com" target="_blank">Dashboard Connect</a> 工具从电脑统一管理。
- 为每台设备保存连接设置(IP、令牌、端口),并设置昵称
- 自动生成 SSH 隧道命令和仪表盘 URL
- **数据留在本地** — 连接设置(IP、令牌、端口)仅保存在浏览器的 localStorage 中,永远不会发送到任何服务器。
## CLI 参考
安装完成后,可以使用 `oa` 命令管理你的安装:
| 选项 | 说明 |
|--------|-------------|
| `oa --update` | 更新 OpenClaw 和 Android 补丁 |
| `oa --install` | 安装可选工具(tmux、code-server、AI CLI 等) |
| `oa --uninstall` | 卸载 OpenClaw on Android |
| `oa --backup` | 创建 OpenClaw 数据的完整备份 |
| `oa --restore` | 从备份恢复 |
| `oa --status` | 显示安装状态和所有已安装组件 |
| `oa --version` | 显示版本 |
| `oa --help` | 显示可用选项 |
## 更新
```bash
oa --update && source ~/.bashrc
```
一条命令更新所有已安装组件:
- **OpenClaw** — 核心包(`openclaw@latest`
- **code-server** — 浏览器 IDE
- **OpenCode** — AI 编程助手
- **AI CLI 工具** — Claude Code、Gemini CLI、Codex CLI (Termux)
- **Android 补丁** — 本项目的兼容性补丁
已是最新的组件会被跳过。未安装的组件不会被触及——只更新设备上已有的内容。可以多次安全运行。
> 如果 `oa` 命令不可用(旧版安装),请使用 curl 运行:
> ```bash
> curl -sL myopenclawhub.com/update | bash && source ~/.bashrc
> ```
## 备份与恢复
OpenClaw 内置的备份命令(`openclaw backup create`)在 Android 上经常失败,因为它依赖硬链接,而 Android 的应用私有存储会阻止硬链接操作。`oa --backup` 命令通过直接使用 `tar` 来解决这个问题,同时完全兼容 OpenClaw 的备份规范。
创建备份:
```bash
oa --backup
```
备份存储在 `~/.openclaw-android/backup/` 目录下,文件名带有时间戳(例如 `2026-03-14T00-00-00.000Z-openclaw-backup.tar.gz`)。你也可以指定自定义路径:`oa --backup ~/my-backups/`。每次备份包含你的配置、状态、工作区和代理。
从备份恢复:
```bash
oa --restore
```
此命令会列出默认备份目录中所有可用的备份。只需选择你要恢复的备份编号即可。工具会自动从备份清单中检测平台并将数据恢复到 `~/.openclaw/`。注意这会覆盖现有数据,因此需要确认。
## 故障排除
请参阅 [故障排除指南](docs/troubleshooting.md) 获取详细解决方案。
## 性能
`openclaw status` 等 CLI 命令可能比在 PC 上运行时感觉更慢。这是因为每条命令需要读取大量文件,而手机存储比 PC 慢,加上 Android 安全机制带来的额外开销。
不过,**一旦网关启动后,就没有区别了**。进程驻留在内存中,无需重新读取文件,而 AI 响应由外部服务器处理——速度与 PC 上相同。
## Android 本地 LLM
OpenClaw 通过 [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) 支持本地 LLM 推理。预构建的原生二进制文件(`@node-llama-cpp/linux-arm64`)已包含在安装中,并能在 glibc 环境下成功加载——**本地 LLM 在手机上技术上是可行的**。
但存在一些实际限制:
| 限制因素 | 详情 |
|------------|---------|
| 内存 | GGUF 模型至少需要 2-4GB 可用内存(7B 模型,Q4 量化)。手机内存与 Android 和其他应用共享 |
| 存储 | 模型文件大小从 4GB 到 70GB+ 不等。手机存储空间很快就会用完 |
| 速度 | ARM 上的纯 CPU 推理非常慢。Android 不支持 llama.cpp 的 GPU 加速 |
| 使用场景 | OpenClaw 主要调用云端 LLM APIOpenAI、Gemini 等),响应速度与 PC 上相同。本地推理是辅助功能 |
如需体验,可以在手机上运行 TinyLlama 1.1BQ4,约 670MB)等小型模型。生产环境建议使用云端 LLM 服务。
> **为什么使用 `--ignore-scripts`** 安装程序使用 `npm install -g openclaw@latest --ignore-scripts`,因为 node-llama-cpp 的 postinstall 脚本会尝试通过 cmake 从源码编译 llama.cpp——在手机上需要 30 分钟以上且会因工具链不兼容而失败。预构建的二进制文件无需此编译步骤即可工作,因此可以安全跳过 postinstall。
<details>
<summary>面向开发者的技术文档</summary>
## 已安装组件
安装程序会跨多个包管理器设置基础设施、平台包和可选工具。核心基础设施和平台依赖会自动安装;可选工具在安装过程中逐个提示。
### 核心基础设施
| 组件 | 作用 | 安装方式 |
|-----------|------|----------------|
| git | 版本控制,npm git 依赖 | `pkg install` |
### Agent 平台运行时依赖
这些由平台的 `config.env` 标志控制。对于 OpenClaw,全部安装:
| 组件 | 作用 | 安装方式 |
|-----------|------|----------------|
| [pacman](https://wiki.archlinux.org/title/Pacman) | glibc 包的包管理器 | `pkg install` |
| [glibc-runner](https://github.com/termux-pacman/glibc-packages) | glibc 动态链接器 — 使标准 Linux 二进制文件能在 Android 上运行 | `pacman -Sy` |
| [Node.js](https://nodejs.org/) v22 LTS (linux-arm64) | OpenClaw 的 JavaScript 运行时 | 从 nodejs.org 直接下载 |
| python | 原生 C/C++ 扩展的构建脚本 (node-gyp) | `pkg install` |
| make | 原生模块的 Makefile 执行 | `pkg install` |
| cmake | 基于 CMake 的原生模块构建 | `pkg install` |
| clang | 原生模块的 C/C++ 编译器 | `pkg install` |
| binutils | 原生构建的二进制工具 (llvm-ar) | `pkg install` |
### OpenClaw 平台
| 组件 | 作用 | 安装方式 |
|-----------|------|----------------|
| [OpenClaw](https://github.com/openclaw/openclaw) | AI Agent 平台(核心) | `npm install -g` |
| [clawdhub](https://github.com/AidanPark/clawdhub) | OpenClaw 的技能管理器 | `npm install -g` |
| [PyYAML](https://pyyaml.org/) | `.skill` 打包的 YAML 解析器 | `pip install` |
| libvips | sharp 构建所需的图像处理头文件 | `pkg install`(更新时) |
### 可选工具(安装时提示)
每个工具都会通过单独的 Y/n 提示。你可以选择安装哪些。
| 组件 | 作用 | 安装方式 |
|-----------|------|----------------|
| [tmux](https://github.com/tmux/tmux) | 终端复用器,用于后台会话 | `pkg install` |
| [ttyd](https://github.com/tsl0922/ttyd) | Web 终端 — 从浏览器访问 Termux | `pkg install` |
| [dufs](https://github.com/sigoden/dufs) | HTTP/WebDAV 文件服务器,用于浏览器文件传输 | `pkg install` |
| [android-tools](https://developer.android.com/tools/adb) | ADB,用于禁用 Phantom Process Killer | `pkg install` |
| [code-server](https://github.com/coder/code-server) | 基于浏览器的 VS Code IDE | 从 GitHub 直接下载 |
| [OpenCode](https://opencode.ai/) | AI 编程助手 (TUI)。自动安装 [Bun](https://bun.sh/) 和 [proot](https://proot-me.github.io/) 作为依赖 | `bun install -g` |
| [Chromium](https://www.chromium.org/) | OpenClaw 的浏览器自动化支持(约 400MB) | 自定义安装脚本 |
| [Playwright](https://playwright.dev/) | 浏览器自动化库(需要 Chromium)。自动配置 `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` | 自定义安装脚本 |
| [Claude Code](https://github.com/anthropics/claude-code) (Anthropic) | AI CLI 工具 | `npm install -g` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) (Google) | AI CLI 工具 | `npm install -g` |
| [Codex CLI](https://github.com/DioNanos/codex-termux)OpenAI Codex 的 Termux 分支) | AI CLI 工具 | `npm install -g` |
## 项目结构
```
openclaw-android/
├── bootstrap.sh # curl | bash 一行安装命令(下载器)
├── install.sh # 平台感知安装程序(入口点)
├── oa.sh # 统一 CLI(安装到 $PREFIX/bin/oa
├── post-setup.sh # Claw App 引导后设置(OTA 分发)
├── update.sh # 薄包装器(下载并运行 update-core.sh
├── update-core.sh # 轻量级更新程序(用于已有安装)
├── uninstall.sh # 完整卸载(编排器)
├── patches/
│ ├── glibc-compat.js # Node.js 运行时补丁(os.cpus、networkInterfaces
│ ├── argon2-stub.js # argon2 原生模块的 JS 桩(code-server
│ ├── termux-compat.h # Bionic 原生构建的 C 头文件(sharp)
│ ├── spawn.h # POSIX spawn 桩头文件
│ ├── systemctl # Termux 的 systemd 桩
│ ├── apply-patches.sh # 旧版补丁编排器(v1.0.2 兼容)
│ └── patch-paths.sh # 旧版路径修复器(v1.0.2 兼容)
├── scripts/
│ ├── lib.sh # 共享函数库(颜色、平台检测、提示)
│ ├── check-env.sh # 安装前环境检查
│ ├── install-infra-deps.sh # 核心基础设施包(L1)
│ ├── install-glibc.sh # glibc-runner 安装(L2 条件安装)
│ ├── install-nodejs.sh # Node.js glibc 包装器安装(L2 条件安装)
│ ├── install-build-tools.sh # 原生模块构建工具(L2 条件安装)
│ ├── backup.sh # 备份和恢复 OpenClaw 数据(oa --backup/--restore
│ ├── build-sharp.sh # 构建 sharp 原生模块(图像处理)
│ ├── install-chromium.sh # 安装 Chromium 用于浏览器自动化
│ ├── install-playwright.sh # 安装 Playwright 浏览器自动化库
│ ├── install-code-server.sh # 安装/更新 code-server(浏览器 IDE
│ ├── install-opencode.sh # 安装 OpenCode
│ ├── setup-env.sh # 配置环境变量
│ └── setup-paths.sh # 创建目录和符号链接
├── platforms/
│ ├── openclaw/ # OpenClaw 平台插件
│ │ ├── config.env # 平台元数据和依赖声明
│ │ ├── env.sh # 平台特定环境变量
│ │ ├── install.sh # 平台包安装(npm、补丁、clawdhub
│ │ ├── update.sh # 平台包更新
│ │ ├── uninstall.sh # 平台包卸载
│ │ ├── status.sh # 平台状态显示
│ │ ├── verify.sh # 平台验证检查
│ │ └── patches/ # 平台特定补丁
│ │ ├── openclaw-apply-patches.sh
│ │ ├── openclaw-patch-paths.sh
│ │ └── openclaw-build-sharp.sh
├── tests/
│ └── verify-install.sh # 安装后验证(编排器 + 平台)
└── docs/
├── disable-phantom-process-killer.md # 保持进程存活指南(EN)
├── disable-phantom-process-killer.ko.md # 保持进程存活指南(KO)
├── termux-ssh-guide.md # Termux SSH 设置指南(EN
├── termux-ssh-guide.ko.md # Termux SSH 设置指南(KO
├── troubleshooting.md # 故障排除指南(EN)
├── troubleshooting.ko.md # 故障排除指南(KO
└── images/ # 截图和图片
```
## 架构
本项目使用**平台插件架构**,将平台无关的基础设施与平台特定代码分离:
```
┌─────────────────────────────────────────────────────────────┐
│ 编排器 (install.sh, update-core.sh, uninstall.sh) │
│ ── 平台无关。读取 config.env 并委托执行。 │
├─────────────────────────────────────────────────────────────┤
│ 共享脚本 (scripts/) │
│ ── L1: install-infra-deps.sh(始终执行) │
│ ── L2: install-glibc.sh, install-nodejs.sh, │
│ install-build-tools.sh(根据 config.env 条件执行) │
│ ── L3: 可选工具(用户选择) │
├─────────────────────────────────────────────────────────────┤
│ 平台插件 (platforms/<name>/) │
│ ── config.env: 声明依赖 (PLATFORM_NEEDS_*) │
│ ── install.sh / update.sh / uninstall.sh / ... │
└─────────────────────────────────────────────────────────────┘
```
**依赖层级:**
| 层级 | 范围 | 示例 | 控制方式 |
|-------|-------|----------|---------------|
| L1 | 基础设施(始终安装) | git、`pkg update` | 编排器 |
| L2 | 平台运行时(条件安装) | glibc、Node.js、构建工具 | `config.env` 标志 |
| L3 | 可选工具(用户选择) | tmux、code-server、AI CLI | 用户提示 |
每个平台在 `config.env` 中声明其 L2 依赖:
```bash
# platforms/openclaw/config.env
PLATFORM_NEEDS_GLIBC=true
PLATFORM_NEEDS_NODEJS=true
PLATFORM_NEEDS_BUILD_TOOLS=true
```
编排器读取这些标志并有条件地运行相应的安装脚本。不需要某些依赖的平台只需将对应标志设为 `false`,那些重量级依赖就会被完全跳过。
## 详细安装流程
运行 `bash install.sh` 将按顺序执行以下 8 个步骤。
### [1/8] 环境检查 — `scripts/check-env.sh`
在开始安装前验证当前环境是否满足要求。
- **Termux 检测**:检查 `$PREFIX` 环境变量。如果不在 Termux 中则立即退出
- **架构检查**:运行 `uname -m` 验证 CPU 架构(推荐 aarch64,支持 armv7lx86_64 视为模拟器)
- **磁盘空间**:确保 `$PREFIX` 分区至少有 1000MB 可用空间。不足时报错
- **已有安装**:如果 `openclaw` 命令已存在,显示当前版本并提示这是重新安装/升级
- **Node.js 预检**:如果 Node.js 已安装,显示版本,低于 22 时发出警告
- **Phantom Process Killer**Android 12+):显示有关 Phantom Process Killer 的提示信息,附带 [禁用指南](docs/disable-phantom-process-killer.md) 链接
### [2/8] 平台选择
选择要安装的平台。目前硬编码为 `openclaw`。未来版本将在有多个平台可用时提供选择界面。
通过 `scripts/lib.sh` 中的 `load_platform_config()` 加载平台的 `config.env`,导出所有 `PLATFORM_*` 变量供后续步骤使用。
### [3/8] 可选工具选择(L3
通过 `/dev/tty` 提供 11 个单独的 Y/n 提示,用于选择可选工具:
- tmux、ttyd、dufs、android-tools
- Chromium、Playwright
- code-server、OpenCode
- Claude Code、Gemini CLI、Codex CLI (Termux)
所有选择在安装开始前一次性完成。这样用户可以一次做完所有决定,然后在安装期间放手不管。
### [4/8] 核心基础设施(L1)— `scripts/install-infra-deps.sh` + `scripts/setup-paths.sh`
无论选择哪个平台都会执行。
**install-infra-deps.sh**
- 运行 `pkg update -y && pkg upgrade -y` 刷新并升级软件包
- 安装 `git`(npm git 依赖和仓库克隆所需)
**setup-paths.sh**
- 创建 `$PREFIX/tmp``$HOME/.openclaw-android/patches` 目录
- 显示标准 Linux 路径(`/bin/sh``/usr/bin/env``/tmp`)到 Termux 等效路径的映射
### [5/8] 平台运行时依赖(L2
根据平台 `config.env` 标志有条件地安装运行时依赖:
| 标志 | 脚本 | 安装内容 |
|------|--------|-----------------|
| `PLATFORM_NEEDS_GLIBC=true` | `scripts/install-glibc.sh` | pacman、glibc-runner(提供 `ld-linux-aarch64.so.1` |
| `PLATFORM_NEEDS_NODEJS=true` | `scripts/install-nodejs.sh` | Node.js v22 LTS linux-arm64、grun 风格的包装脚本 |
| `PLATFORM_NEEDS_BUILD_TOOLS=true` | `scripts/install-build-tools.sh` | python、make、cmake、clang、binutils |
每个脚本都是自包含的,具有预检查和幂等行为(如果已安装则跳过)。
### [6/8] 平台包安装(L2)— `platforms/<platform>/install.sh`
委托给平台自身的安装脚本。对于 OpenClaw,此步骤:
1. 设置 `CPATH` 以获取 glib-2.0 头文件(原生模块构建所需)
2. 通过 pip 安装 PyYAML`.skill` 打包所需)
3.`glibc-compat.js` 复制到 `~/.openclaw-android/patches/`
4. 安装 `systemctl` 桩到 `$PREFIX/bin/`
5. 运行 `npm install -g openclaw@latest --ignore-scripts`
6. 通过 `openclaw-apply-patches.sh` 应用平台特定补丁
7. 安装 `clawdhub`(技能管理器)以及 `undici` 依赖(如需要)
8. 运行 `openclaw update`(包括构建 sharp 等原生模块)
**[6.5] 环境变量 + CLI + 标记文件:**
平台安装后,编排器会:
- 运行 `setup-env.sh` 写入 `.bashrc` 环境变量块
- 执行平台的 `env.sh` 获取平台特定变量
- 写入平台标记文件(`~/.openclaw-android/.platform`
- 安装 `oa` CLI 和 `oaupdate` 包装器到 `$PREFIX/bin/`
-`lib.sh``setup-env.sh` 和平台目录复制到 `~/.openclaw-android/` 供更新程序和卸载程序使用
### [7/8] 安装可选工具(L3
安装步骤 3 中选择的工具:
- **Termux 包**tmux、ttyd、dufs、android-tools — 通过 `pkg install` 安装
- **code-server**:基于浏览器的 VS Code IDE,带有 Termux 特定的解决方案(替换捆绑的 node、修补 argon2、处理硬链接失败)
- **OpenCode**AI 编程助手,使用 proot + ld.so 拼接方式运行 Bun 独立二进制文件
- **Chromium**OpenClaw 的浏览器自动化支持(约 400MB)
- **Playwright**:浏览器自动化库(通过 npm 安装 `playwright-core`)。自动设置 `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH``PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` 环境变量。如果未安装 Chromium 则自动安装
- **AI CLI 工具**Claude Code、Gemini CLI、Codex CLI (Termux) — 通过 `npm install -g` 安装
### [8/8] 验证 — `tests/verify-install.sh`
运行两级验证:
**编排器检查(FAIL 级别):**
| 检查项 | 通过条件 |
|------------|---------------|
| Node.js 版本 | `node -v` >= 22 |
| npm | `npm` 命令存在 |
| TMPDIR | 环境变量已设置 |
| OA_GLIBC | 设为 `1` |
| glibc-compat.js | 文件存在于 `~/.openclaw-android/patches/` |
| .glibc-arch | 标记文件存在 |
| glibc 动态链接器 | `ld-linux-aarch64.so.1` 存在 |
| glibc node 包装器 | 包装脚本位于 `~/.openclaw-android/bin/node` |
| 目录 | `~/.openclaw-android``$PREFIX/tmp` 存在 |
| .bashrc | 包含环境变量块 |
**编排器检查(WARN 级别,非关键):**
| 检查项 | 通过条件 |
|------------|---------------|
| code-server | `code-server --version` 成功 |
| opencode | `opencode` 命令可用 |
**平台验证** — 委托给 `platforms/<platform>/verify.sh`
| 检查项 | 通过条件 |
|------------|---------------|
| openclaw | `openclaw --version` 成功 |
| CONTAINER | 设为 `1` |
| clawdhub | 命令可用 |
| ~/.openclaw | 目录存在 |
所有 FAIL 级别项通过 → PASSED。任何 FAIL → 显示重新安装说明。WARN 项不会导致失败。
## 轻量级更新流程 — `oa --update`
运行 `oa --update`(或 `oaupdate`,用于向后兼容)会从 GitHub 下载最新的发行版 tarball,然后执行以下 5 个步骤。
### [1/5] 预检查
验证更新所需的最低条件。
- 检查 `$PREFIX` 存在(Termux 环境)
- 检查 `curl` 可用
-`~/.openclaw-android/.platform` 标记文件检测平台
- 检测架构:glibc`.glibc-arch` 标记)或 Bionic(旧版)
- 如需要,迁移旧目录名(`.openclaw-lite``.openclaw-android` — 旧版兼容)
- **Phantom Process Killer**Android 12+):显示提示信息,附带 [禁用指南](docs/disable-phantom-process-killer.md) 链接
### [2/5] 下载最新版本
从 GitHub 下载完整仓库 tarball 并解压到临时目录。验证所有必需文件存在:
- `scripts/lib.sh`
- `scripts/setup-env.sh`
- `platforms/<platform>/config.env`
- `platforms/<platform>/update.sh`
### [3/5] 更新核心基础设施
更新更新程序、卸载程序和 CLI 使用的共享文件:
- 将最新的平台目录复制到 `~/.openclaw-android/platforms/`
- 更新 `~/.openclaw-android/scripts/` 中的 `lib.sh``setup-env.sh`
- 更新补丁文件(`glibc-compat.js``argon2-stub.js``spawn.h``systemctl`
- 更新 `$PREFIX/bin/` 中的 `oa` CLI 和 `oaupdate` 包装器
- 更新 `~/.openclaw-android/` 中的 `uninstall.sh`
- 如果检测到 Bionic 架构,执行自动 glibc 迁移
- 运行 `setup-env.sh` 刷新 `.bashrc` 环境变量块
### [4/5] 更新平台
委托给 `platforms/<platform>/update.sh`。对于 OpenClaw,此步骤:
- 安装构建依赖(`libvips``binutils`
-`openclaw` npm 包更新到最新版本
- 重新应用平台特定补丁
- 如果 openclaw 已更新,重新构建 sharp 原生模块
- 更新/安装 `clawdhub`(技能管理器)
- 如需要,为 clawdhub 安装 `undici`Node.js v24+
- 如需要,将技能从 `~/skills/` 迁移到 `~/.openclaw/workspace/skills/`
- 如缺失则安装 PyYAML
### [5/5] 更新可选工具
更新已安装的工具:
- **code-server**:以更新模式运行 `install-code-server.sh`。未安装则跳过
- **OpenCode**:已安装则更新;未安装则提供安装选项。需要 glibc 架构
- **Chromium**:已安装则更新。未安装则跳过
- **AI CLI 工具**Claude Code、Gemini CLI、Codex CLI (Termux)):比较已安装版本与最新 npm 版本,需要时更新。未安装的工具不会提供安装选项
</details>
## 许可证
MIT
+68
View File
@@ -0,0 +1,68 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.0.x | :white_check_mark: |
## Reporting a Vulnerability
**Please do NOT open a public GitHub issue for security vulnerabilities.**
Instead, please report them responsibly:
1. **GitHub**: Use [GitHub Security Advisories](https://github.com/AidanPark/openclaw-android/security/advisories/new)
### What to Include
- Description of the vulnerability
- Steps to reproduce
- Impact assessment
- Suggested fix (if any)
### Response Timeline
- **Acknowledgment**: Within 48 hours
- **Assessment**: Within 1 week
- **Fix**: Within 2 weeks for critical issues
## Security Architecture
OpenClaw on Android runs standard Linux binaries on Android without proot-distro. The security model is shaped by this unique execution environment.
### Execution Isolation
```
┌─────────────────────────────────────────────┐
│ Android Kernel (SELinux enforced) │
│ ┌─────────────────────────────────────────┐ │
│ │ Termux sandbox (/data/data/com.termux) │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ glibc-runner (ld.so userspace only) │ │ │
│ │ │ Node.js → OpenClaw │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### Isolation Layers
1. **Android app sandbox** — Termux runs in its own Linux user namespace; no access to other app data
2. **SELinux** — Android's mandatory access control applies to all Termux processes
3. **No root required** — The entire stack runs as a regular unprivileged user
4. **No proot** — No filesystem translation layer; glibc-runner provides only the dynamic linker
5. **Path conversion** — Standard Linux paths (`/tmp`, `/bin/sh`) are mapped to Termux equivalents at install time, not at runtime via syscall interception
### What We Protect Against
- Unauthorized access to Android system or other app data (enforced by Android sandbox)
- Arbitrary code execution outside Termux (prevented by SELinux + app sandbox)
- Path traversal from Termux into Android system paths (Termux prefix isolation)
### What Is Out of Scope
- Vulnerabilities in OpenClaw core (report to [OpenClaw upstream](https://github.com/openclaw/openclaw))
- Vulnerabilities in Termux (report to [Termux](https://github.com/termux/termux-app))
- Vulnerabilities in glibc-runner (report to [termux-pacman](https://github.com/AidanPark/openclaw-android))
- Device-level security (rooted devices, unlocked bootloaders)
+27
View File
@@ -0,0 +1,27 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
**/build/
/captures
.externalNativeBuild
.cxx
*.apk
*.aab
*.ap_
*.dex
# Eclipse/Buildship IDE files
.classpath
.project
.settings/
# WebView UI build output
www/node_modules/
www/dist/
# Release keystore
*.jks
*.keystore
+94
View File
@@ -0,0 +1,94 @@
# OpenClaw Android App
Standalone APK for running OpenClaw on Android. Thin APK (~5MB) with WebView UI, native PTY terminal, Termux bootstrap runtime, and OTA updates.
## Architecture
```
APK (~5MB)
├── Native: TerminalView (PTY terminal via libtermux.so)
├── WebView: React SPA (setup, dashboard, settings)
├── JsBridge: WebView ↔ Kotlin communication (31 methods, 7 domains)
├── EventBridge: Kotlin → WebView event dispatch
└── OTA: www.zip download + atomic replace
```
## Build
### Prerequisites
- JDK 21
- Android SDK (API 28+)
- NDK 28+
- Node.js 22+ (for WebView UI)
### Build APK
```bash
cd android
./gradlew assembleDebug
# Output: app/build/outputs/apk/debug/app-debug.apk
```
### Build WebView UI
```bash
cd android/www
npm install
npm run build # Output: dist/
npm run build:zip # Output: www.zip (for OTA)
```
## Project Structure
```
android/
├── app/src/main/
│ ├── java/com/openclaw/android/
│ │ ├── MainActivity.kt # WebView + TerminalView container
│ │ ├── OpenClawService.kt # Foreground Service (START_STICKY)
│ │ ├── BootstrapManager.kt # Bootstrap download/extract/configure
│ │ ├── JsBridge.kt # 31 @JavascriptInterface methods
│ │ ├── EventBridge.kt # Kotlin → WebView CustomEvent
│ │ ├── CommandRunner.kt # Shell command execution
│ │ ├── EnvironmentBuilder.kt # Termux environment variables
│ │ ├── UrlResolver.kt # BuildConfig + config.json URL resolution
│ │ └── TerminalSessionManager.kt # Multi-session terminal management
│ ├── assets/www/ # Bundled fallback UI (vanilla JS)
│ └── res/ # Android resources
├── www/ # React SPA (production WebView UI)
│ ├── src/
│ │ ├── lib/bridge.ts # JsBridge typed wrapper
│ │ ├── lib/useNativeEvent.ts # EventBridge React hook
│ │ ├── lib/router.tsx # Hash-based router
│ │ └── screens/ # All UI screens
│ └── dist/ # Build output
├── terminal-emulator/ # PTY emulator (from ReTerminal)
└── terminal-view/ # Terminal rendering (from ReTerminal)
```
## Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| `targetSdk 28` | W^X bypass — allows exec in /data/data/ |
| `minSdk 24` | apt-android-7 bootstrap requirement |
| Hash routing | `file://` protocol doesn't support History API |
| No CSS framework | Minimal bundle size for OTA delivery |
| System font stack | Android WebView, no custom font loading needed |
## JsBridge API Domains
| Domain | Methods | Description |
|--------|---------|-------------|
| Terminal | 7 | show/hide, create/switch/close sessions |
| Setup | 3 | bootstrap status, start setup |
| Platform | 6 | install/uninstall/switch platforms |
| Tools | 5 | install/uninstall CLI tools |
| Commands | 2 | sync/async shell execution |
| Updates | 2 | check/apply OTA updates |
| System | 6 | app info, battery, settings, storage |
## License
GPL v3
+178
View File
@@ -0,0 +1,178 @@
import java.util.Properties
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.detekt)
alias(libs.plugins.ktlint)
}
android {
namespace = "com.openclaw.android"
compileSdk = 36
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
defaultConfig {
applicationId = "com.openclaw.android"
minSdk = 24
//noinspection ExpiredTargetSdkVersion
targetSdk = 28
versionCode = 9
versionName = "0.4.0"
ndk { abiFilters += listOf("arm64-v8a") }
// Initial download URLs (§2.9) — BuildConfig hardcoded fallbacks
buildConfigField(
"String",
"BOOTSTRAP_URL",
"\"https://github.com/termux/termux-packages/releases/download/bootstrap-2026.02.12-r1%2Bapt.android-7/bootstrap-aarch64.zip\"",
)
buildConfigField(
"String",
"WWW_URL",
"\"https://github.com/AidanPark/openclaw-android-app/releases/download/v1.0.0/www.zip\"",
)
buildConfigField(
"String",
"CONFIG_URL",
"\"https://raw.githubusercontent.com/AidanPark/openclaw-android-app/main/config.json\"",
)
}
signingConfigs {
create("release") {
val props = project.rootProject.file("local.properties")
if (props.exists()) {
val localProps = Properties().apply { props.inputStream().use { load(it) } }
val storePath = localProps.getProperty("RELEASE_STORE_FILE", "")
if (storePath.isNotEmpty()) {
storeFile = file(storePath)
storePassword = localProps.getProperty("RELEASE_STORE_PASSWORD", "")
keyAlias = localProps.getProperty("RELEASE_KEY_ALIAS", "")
keyPassword = localProps.getProperty("RELEASE_KEY_PASSWORD", "")
}
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@Suppress("UnstableApiUsage")
testOptions {
unitTests.all { it.useJUnitPlatform() }
unitTests.isReturnDefaultValues = true
}
buildFeatures {
viewBinding = true
buildConfig = true
}
packaging {
jniLibs { useLegacyPackaging = true }
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
}
dependencies {
implementation(project(":terminal-emulator"))
implementation(project(":terminal-view"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.gson)
// WebView + @JavascriptInterface — Android SDK built-in, no extra dependency
// Test dependencies
testImplementation(libs.junit5)
testRuntimeOnly(libs.junit5.platform.launcher)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}
// --- www build automation ---
// Builds the React UI (android/www) and copies dist/ into assets/www before every APK build.
val wwwProjectDir = file("$rootDir/www")
val assetsWwwDir = file("$projectDir/src/main/assets/www")
val buildWww by tasks.registering(Exec::class) {
description = "Build React UI (npm run build)"
group = "build"
workingDir = wwwProjectDir
commandLine("npm", "run", "build")
inputs.dir(wwwProjectDir.resolve("src"))
inputs.files(
wwwProjectDir.resolve("package.json"),
wwwProjectDir.resolve("tsconfig.json"),
wwwProjectDir.resolve("vite.config.ts"),
)
outputs.dir(wwwProjectDir.resolve("dist"))
}
val syncWwwAssets by tasks.registering(Sync::class) {
description = "Copy React dist/ into assets/www/"
group = "build"
dependsOn(buildWww)
from(wwwProjectDir.resolve("dist"))
into(assetsWwwDir)
}
tasks.named("preBuild") {
dependsOn(syncWwwAssets)
}
detekt {
buildUponDefaultConfig = true
allRules = false
config.setFrom("$rootDir/detekt.yml")
}
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
jvmTarget = "17"
reports {
html.required.set(true)
sarif.required.set(true)
xml.required.set(false)
txt.required.set(false)
}
}
tasks.withType<io.gitlab.arturbosch.detekt.DetektCreateBaselineTask>().configureEach {
jvmTarget = "17"
}
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
android.set(true)
outputToConsole.set(true)
ignoreFailures.set(false)
filter {
exclude("**/generated/**")
}
}
+8
View File
@@ -0,0 +1,8 @@
# Keep JsBridge methods accessible from JavaScript
-keepclassmembers class com.openclaw.android.JsBridge {
@android.webkit.JavascriptInterface <methods>;
}
# Keep terminal library classes
-keep class com.termux.terminal.** { *; }
-keep class com.termux.view.** { *; }
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:hardwareAccelerated="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:configChanges="keyboard|keyboardHidden|navigation|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".OpenClawService"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
+583
View File
@@ -0,0 +1,583 @@
/**
* glibc-compat.js - Minimal compatibility shim for glibc Node.js on Android
*
* This is the successor to bionic-compat.js, drastically reduced for glibc.
*
* What's NOT needed anymore (glibc handles these):
* - process.platform override (glibc Node.js reports 'linux' natively)
* - renameat2 / spawn.h stubs (glibc includes them)
* - CXXFLAGS / GYP_DEFINES overrides (glibc is standard Linux)
*
* What's still needed (kernel/Android-level restrictions, not libc):
* - os.cpus() fallback: SELinux blocks /proc/stat on Android 8+
* - os.networkInterfaces() safety: EACCES on some Android configurations
* - /bin/sh path shim: Android 7-8 lacks /bin/sh (Android 9+ has it)
*
* Loaded via node wrapper script: node --require <path>/glibc-compat.js
*/
'use strict';
const os = require('os');
const fs = require('fs');
const path = require('path');
// ─── process.execPath fix ────────────────────────────────────
// When node runs via grun (ld.so node.real), process.execPath points to
// ld.so instead of the node wrapper. Apps that spawn child node processes
// using process.execPath (e.g., openclaw) will call ld.so directly,
// bypassing the wrapper's LD_PRELOAD unset and compat loading.
// Fix: point process.execPath to the wrapper script.
const _wrapperPath = process.env._OA_WRAPPER_PATH || path.join(
process.env.HOME || '/data/data/com.termux/files/home',
'.openclaw-android', 'bin', 'node'
);
try {
if (fs.existsSync(_wrapperPath)) {
Object.defineProperty(process, 'execPath', {
value: _wrapperPath,
writable: true,
configurable: true,
});
}
} catch {}
// ─── LD_PRELOAD cleanup ─────────────────────────────────────
// The node wrapper unsets LD_PRELOAD to prevent bionic libtermux-exec.so
// from loading into the glibc node.real process.
//
// Previously, we restored LD_PRELOAD here so bionic child processes
// (like /bin/sh) would get libtermux-exec.so for path translation
// (e.g., /usr/bin/env → $PREFIX/bin/env in shebang resolution).
//
// However, libtermux-exec.so re-injects LD_PRELOAD into execve() calls
// even after the shell unsets it. This causes glibc processes (ld.so)
// spawned from node to crash with "Could not find a PHDR" errors.
//
// Fix: Do NOT restore LD_PRELOAD. Instead, the spawn/spawnSync wrapper
// below handles shebang resolution (#!/usr/bin/env → PATH lookup) in
// JavaScript, eliminating the need for libtermux-exec.so in child processes.
delete process.env.LD_PRELOAD;
delete process.env._OA_ORIG_LD_PRELOAD;
// ─── os.cpus() fallback ─────────────────────────────────────
// Android 8+ (API 26+) blocks /proc/stat via SELinux + hidepid=2.
// libuv reads /proc/stat for CPU info → returns empty array.
// Tools using os.cpus().length for parallelism (e.g., make -j) break with 0.
const _originalCpus = os.cpus;
os.cpus = function cpus() {
const result = _originalCpus.call(os);
if (result.length > 0) {
return result;
}
// Return a single fake CPU entry so .length is at least 1
return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }];
};
// ─── os.networkInterfaces() safety ──────────────────────────
// Some Android configurations throw EACCES when reading network
// interface information. Wrap with try-catch to prevent crashes.
//
// Additionally, Android/Termux typically only exposes the loopback
// interface (`lo`) to Node.js. In that situation, OpenClaw's Bonjour
// advertiser can't send multicast announcements and logs noisy
// "Announcement failed as of socket errors!" repeatedly.
// Auto-disable Bonjour via OPENCLAW_DISABLE_BONJOUR when only
// loopback interfaces are visible.
const _originalNetworkInterfaces = os.networkInterfaces;
function _createLoopbackInterfaces() {
return {
lo: [
{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '127.0.0.1/8',
},
],
};
}
function _hasNonLoopbackInterface(interfaces) {
try {
return Object.values(interfaces).some(entries =>
Array.isArray(entries) && entries.some(entry => entry && entry.internal === false)
);
} catch {
return false;
}
}
os.networkInterfaces = function networkInterfaces() {
let interfaces;
try {
interfaces = _originalNetworkInterfaces.call(os);
} catch {
interfaces = _createLoopbackInterfaces();
}
if (!process.env.OPENCLAW_DISABLE_BONJOUR && !_hasNonLoopbackInterface(interfaces)) {
process.env.OPENCLAW_DISABLE_BONJOUR = '1';
}
return interfaces;
};
// ─── Shell override for exec/execSync ────────────────────────
// Node.js child_process hardcodes /bin/sh as the default shell on Linux.
// On Android:
// - Android 7-8: /bin/sh doesn't exist at all
// - Android 9+: /bin/sh exists (/system/bin/sh) but is minimal (toybox/mksh)
// and lacks Termux PATH, environment, and proper command support
// Always use Termux's shell for exec/execSync to ensure consistent behavior.
{
const child_process = require('child_process');
const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh';
if (fs.existsSync(termuxSh)) {
const _originalExec = child_process.exec;
const _originalExecSync = child_process.execSync;
child_process.exec = function exec(command, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
if (!options.shell) {
options.shell = termuxSh;
}
return _originalExec.call(child_process, command, options, callback);
};
child_process.execSync = function execSync(command, options) {
options = options || {};
if (!options.shell) {
options.shell = termuxSh;
}
return _originalExecSync.call(child_process, command, options);
};
}
}
// ─── DNS resolver fix ────────────────────────────────────────
// glibc's getaddrinfo() reads /data/data/com.termux/files/usr/glibc/etc/resolv.conf
// for DNS servers. This file may be missing or inaccessible:
// - Standalone APK: runs under com.openclaw.android, can't access com.termux paths
// - Termux: resolv-conf package may not be installed
// Without a valid resolv.conf, dns.lookup() fails with EAI_AGAIN errors.
//
// Fix: Override both dns.lookup (callback) and dns.promises.lookup (promise)
// to use c-ares resolver (dns.resolve) which respects dns.setServers(),
// then fall back to getaddrinfo.
try {
const dns = require('dns');
// Read DNS servers from our resolv.conf or use Google DNS as fallback
let dnsServers = ['8.8.8.8', '8.8.4.4'];
try {
const resolvConf = fs.readFileSync(
(process.env.PREFIX || '/data/data/com.termux/files/usr') + '/etc/resolv.conf',
'utf8'
);
const parsed = resolvConf.match(/^nameserver\s+(.+)$/gm);
if (parsed && parsed.length > 0) {
dnsServers = parsed.map(l => l.replace(/^nameserver\s+/, '').trim());
}
} catch {}
// Set DNS servers for c-ares resolver
try { dns.setServers(dnsServers); } catch {}
// Override dns.lookup (callback API) to use c-ares resolver
const _originalLookup = dns.lookup;
// Localhost must never go to external DNS. Android/glibc may lack /etc/hosts,
// causing getaddrinfo to fail or return 0.0.0.0. Short-circuit it here.
const _localhostNames = new Set(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']);
dns.lookup = function lookup(hostname, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
const originalOptions = options;
const opts = typeof options === 'number' ? { family: options } : (options || {});
const wantAll = opts.all === true;
const family = opts.family || 0;
// Short-circuit localhost — never send to external DNS
if (_localhostNames.has(hostname)) {
if (family === 6) {
if (wantAll) return callback(null, [{ address: '::1', family: 6 }]);
return callback(null, '::1', 6);
}
if (wantAll) return callback(null, [{ address: '127.0.0.1', family: 4 }]);
return callback(null, '127.0.0.1', 4);
}
const resolve = (fam, cb) => {
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
fn(hostname, cb);
};
const tryResolve = (fam) => {
resolve(fam, (err, addresses) => {
if (!err && addresses && addresses.length > 0) {
const resFam = fam === 6 ? 6 : 4;
if (wantAll) {
callback(null, addresses.map(a => ({ address: a, family: resFam })));
} else {
callback(null, addresses[0], resFam);
}
} else if (family === 0 && fam === 4) {
tryResolve(6);
} else {
_originalLookup.call(dns, hostname, originalOptions, callback);
}
});
};
tryResolve(family === 6 ? 6 : 4);
};
// Override dns.promises.lookup (promise API) to use c-ares resolver.
// OpenClaw's SSRF guard uses this API for web_search DNS resolution.
const _originalPromiseLookup = dns.promises.lookup;
dns.promises.lookup = async function lookup(hostname, options) {
const opts = typeof options === 'number' ? { family: options } : (options || {});
const wantAll = opts.all === true;
const family = opts.family || 0;
// Short-circuit localhost
if (_localhostNames.has(hostname)) {
if (family === 6) {
return wantAll ? [{ address: '::1', family: 6 }] : { address: '::1', family: 6 };
}
return wantAll ? [{ address: '127.0.0.1', family: 4 }] : { address: '127.0.0.1', family: 4 };
}
const resolve = (fam) => {
return new Promise((res, rej) => {
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
fn(hostname, (err, addresses) => err ? rej(err) : res(addresses));
});
};
const tryResolve = async (fam) => {
try {
const addresses = await resolve(fam);
if (addresses && addresses.length > 0) {
const resFam = fam === 6 ? 6 : 4;
if (wantAll) {
return addresses.map(a => ({ address: a, family: resFam }));
}
return { address: addresses[0], family: resFam };
}
} catch {}
if (family === 0 && fam === 4) return tryResolve(6);
return _originalPromiseLookup.call(dns.promises, hostname, options);
};
return tryResolve(family === 6 ? 6 : 4);
};
} catch {}
// ─── ELF binary auto-wrapping for spawn/spawnSync ──────────
// npm/npx-installed native binaries (e.g., @zed-industries/codex-acp)
// are standard Linux ELF files whose interpreter is /lib/ld-linux-aarch64.so.1.
// Android lacks this path, so the kernel returns ENOENT on direct execution.
// Intercept child_process spawn APIs to detect ELF binaries and automatically
// route them through the glibc dynamic linker (ld.so).
const _glibcLdso = (process.env.PREFIX || '/data/data/com.termux/files/usr')
+ '/glibc/lib/ld-linux-aarch64.so.1';
const _glibcLibPath = (process.env.PREFIX || '/data/data/com.termux/files/usr')
+ '/glibc/lib';
function _needsGlibcWrap(filePath) {
// Read ELF header and check PT_INTERP to distinguish glibc binaries
// from bionic (Android/Termux) binaries. Only glibc binaries need wrapping.
// Bionic binaries use /system/bin/linker64; glibc use /lib/ld-linux-*.so.1
try {
const fd = fs.openSync(filePath, 'r');
try {
const ehdr = Buffer.alloc(64);
if (fs.readSync(fd, ehdr, 0, 64, 0) < 64) return false;
// Check ELF magic
if (ehdr[0] !== 0x7f || ehdr[1] !== 0x45 || ehdr[2] !== 0x4c || ehdr[3] !== 0x46) return false;
// ELF64: e_phoff at offset 32 (8 bytes), e_phentsize at 54 (2 bytes), e_phnum at 56 (2 bytes)
const phoff = Number(ehdr.readBigUInt64LE(32));
const phentsize = ehdr.readUInt16LE(54);
const phnum = ehdr.readUInt16LE(56);
// Scan program headers for PT_INTERP (type = 3)
for (let i = 0; i < phnum; i++) {
const phBuf = Buffer.alloc(phentsize);
if (fs.readSync(fd, phBuf, 0, phentsize, phoff + i * phentsize) < phentsize) continue;
const pType = phBuf.readUInt32LE(0);
if (pType === 3) { // PT_INTERP
const interpOff = Number(phBuf.readBigUInt64LE(8));
const interpSize = Number(phBuf.readBigUInt64LE(32));
const interpBuf = Buffer.alloc(Math.min(interpSize, 256));
fs.readSync(fd, interpBuf, 0, interpBuf.length, interpOff);
const interp = interpBuf.toString('utf8').replace(/\0+$/, '');
return interp.includes('ld-linux');
}
}
return false;
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
}
function _resolveCommand(command, env) {
if (command.includes('/')) {
try { return fs.realpathSync(command); } catch { return command; }
}
const searchPath = (env && env.PATH) || process.env.PATH || '';
const dirs = searchPath.split(':');
for (const dir of dirs) {
const full = path.join(dir, command);
try {
fs.accessSync(full, fs.constants.X_OK);
return full;
} catch {}
}
return null;
}
function _readShebang(filePath) {
// Read first 256 bytes to extract shebang line from script files.
// Returns null if not a script, or { interpreter, args } if shebang found.
try {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(256);
const n = fs.readSync(fd, buf, 0, 256, 0);
if (n < 2 || buf[0] !== 0x23 || buf[1] !== 0x21) return null; // not #!
const line = buf.toString('utf8', 2, n).split('\n')[0].trim();
const parts = line.split(/\s+/);
return { interpreter: parts[0], args: parts.slice(1) };
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
}
function _resolveShebang(resolved, spawnEnv) {
// For a resolved file path, check if it has a shebang pointing to a
// non-existent interpreter (e.g. #!/usr/bin/env node on Android).
// Returns { interpPath, interpArgs } or null if no fixup needed.
const shebang = _readShebang(resolved);
if (!shebang) return null;
let interpPath = shebang.interpreter;
let interpArgs = shebang.args;
if (interpPath === '/usr/bin/env' && interpArgs.length > 0) {
// #!/usr/bin/env <cmd> — resolve <cmd> from PATH
let cmdIdx = 0;
while (cmdIdx < interpArgs.length && interpArgs[cmdIdx].startsWith('-')) cmdIdx++;
if (cmdIdx >= interpArgs.length) return null;
const cmd = interpArgs[cmdIdx];
const resolvedCmd = _resolveCommand(cmd, spawnEnv);
if (!resolvedCmd) return null;
interpPath = resolvedCmd;
interpArgs = interpArgs.slice(0, cmdIdx).concat(interpArgs.slice(cmdIdx + 1));
} else if (!fs.existsSync(interpPath)) {
// Interpreter at non-existent absolute path — try resolving by basename from PATH
const basename = path.basename(interpPath);
const resolvedInterp = _resolveCommand(basename, spawnEnv);
if (!resolvedInterp) return null;
interpPath = resolvedInterp;
} else {
// Shebang interpreter exists, kernel can handle it
return null;
}
return { interpPath, interpArgs };
}
// Detect shell invocation patterns: spawn('/path/to/sh', ['-c', 'cmd args...'])
// or spawn('cmd', args, { shell: true })
function _isShellInvocation(file, args) {
if (!args || args.length < 2 || args[0] !== '-c') return null;
const base = path.basename(file);
if (base !== 'sh' && base !== 'bash' && base !== 'dash' && base !== 'zsh') return null;
return args[1]; // the shell command string
}
function _tryFixShellCommand(cmdStr, spawnEnv) {
// Extract the command name from a simple shell command string.
// Only handle simple cases: "cmd arg1 arg2..." — no pipes, redirects, etc.
const shellChars = /[|><&;`$(){}]/;
if (shellChars.test(cmdStr)) return null;
const parts = cmdStr.trim().split(/\s+/);
if (parts.length === 0) return null;
const cmd = parts[0];
const cmdArgs = parts.slice(1);
const resolved = _resolveCommand(cmd, spawnEnv);
if (!resolved) return null;
// glibc ELF binary
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(cmdArgs),
env: env,
};
}
// Script with broken shebang
const shebang = _resolveShebang(resolved, spawnEnv);
if (shebang) {
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(cmdArgs),
};
}
return null;
}
function _tryWrapSpawn(file, args, options) {
const spawnEnv = options && options.env ? options.env : process.env;
// Detect shell invocations: spawn('sh', ['-c', 'command...']) or spawn(cmd, args, { shell: true })
// npm/npx uses spawn('/path/to/sh', ['-c', 'cmd args...']) to execute package binaries.
// Without libtermux-exec.so, #!/usr/bin/env shebangs in these scripts fail.
if (options && options.shell) {
// shell: true — Node.js will convert to sh -c 'file args...'
if (!file.includes(' ') && !(/[|><&;`$()]/).test(file)) {
const resolved = _resolveCommand(file, spawnEnv);
if (resolved) {
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
options: Object.assign({}, options, { env: env, shell: false }),
};
}
const shebang = _resolveShebang(resolved, spawnEnv);
if (shebang) {
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(args || []),
options: Object.assign({}, options, { shell: false }),
};
}
}
}
return null;
}
// Direct shell invocation: spawn('/path/to/sh', ['-c', 'cmd args...'])
const shellCmd = _isShellInvocation(file, args);
if (shellCmd) {
const fix = _tryFixShellCommand(shellCmd, spawnEnv);
if (fix) {
return {
file: fix.file,
args: fix.args,
options: fix.env ? Object.assign({}, options, { env: fix.env }) : options,
};
}
return null;
}
const resolved = _resolveCommand(file, spawnEnv);
if (!resolved) return null;
if (resolved === _glibcLdso || resolved.endsWith('/ld-linux-aarch64.so.1')) return null;
// Case 1: glibc ELF binary — wrap with ld.so
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
options: Object.assign({}, options, { env: env }),
};
}
// Case 2: Script with shebang pointing to non-existent path
const shebang = _resolveShebang(resolved, spawnEnv);
if (!shebang) return null;
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(args || []),
options: options,
};
}
if (fs.existsSync(_glibcLdso)) {
const _cp = require('child_process');
// Normalize optional args parameter: spawn(cmd[, args][, opts])
function _normalizeArgs(args, options) {
if (args != null && !Array.isArray(args)) {
return { args: [], options: args };
}
return { args: args || [], options: options };
}
const _origSpawn = _cp.spawn;
_cp.spawn = function spawn(command, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(command, n.args, n.options);
if (w) return _origSpawn.call(_cp, w.file, w.args, w.options);
return _origSpawn.call(_cp, command, args, options);
};
const _origSpawnSync = _cp.spawnSync;
_cp.spawnSync = function spawnSync(command, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(command, n.args, n.options);
if (w) return _origSpawnSync.call(_cp, w.file, w.args, w.options);
return _origSpawnSync.call(_cp, command, args, options);
};
const _origExecFile = _cp.execFile;
_cp.execFile = function execFile(file, args, options, callback) {
// execFile(file[, args][, options], callback)
if (typeof args === 'function') {
callback = args; args = []; options = {};
} else if (typeof options === 'function') {
callback = options;
if (Array.isArray(args)) { options = {}; } else { options = args; args = []; }
}
const w = _tryWrapSpawn(file, args, options);
if (w) return _origExecFile.call(_cp, w.file, w.args, w.options, callback);
return _origExecFile.call(_cp, file, args, options, callback);
};
const _origExecFileSync = _cp.execFileSync;
_cp.execFileSync = function execFileSync(file, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(file, n.args, n.options);
if (w) return _origExecFileSync.call(_cp, w.file, w.args, w.options);
return _origExecFileSync.call(_cp, file, args, options);
};
}
+768
View File
@@ -0,0 +1,768 @@
#!/usr/bin/env bash
# OpenClaw Android — Post-Bootstrap Setup
# Runs in the terminal after Termux bootstrap extraction.
# Installs: git, glibc, Node.js, OpenClaw
#
# Strategy:
# - Termux .deb packages: dpkg-deb -x + relocate (bypasses dpkg hardcoded paths)
# - Pacman .pkg.tar.xz packages: tar -xJf + relocate (bypasses pacman entirely)
# - Both have files under data/data/com.termux/files/usr/ which we relocate to $PREFIX
#
# Why not apt-get/dpkg/pacman?
# All three have hardcoded /data/data/com.termux/... paths that libtermux-exec
# cannot rewrite (it only intercepts execve, not open/opendir).
set -eo pipefail
# ─── Paths ────────────────────────────────────
: "${PREFIX:?PREFIX not set}"
: "${HOME:?HOME not set}"
: "${TMPDIR:=$(dirname "$PREFIX")/tmp}"
OCA_DIR="$HOME/.openclaw-android"
NODE_DIR="$OCA_DIR/node"
BIN_DIR="$OCA_DIR/bin"
NODE_VERSION="22.22.0"
GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
MARKER="$OCA_DIR/.post-setup-done"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ─── GitHub mirror fallback (for China/restricted networks) ──
REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
REPO_BASE="$REPO_BASE_ORIGIN"
resolve_repo_base() {
if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then
REPO_BASE="$REPO_BASE_ORIGIN"; return 0
fi
local mirrors=(
"https://ghfast.top/$REPO_BASE_ORIGIN"
"https://ghproxy.net/$REPO_BASE_ORIGIN"
"https://mirror.ghproxy.com/$REPO_BASE_ORIGIN"
)
for m in "${mirrors[@]}"; do
if curl -sI --connect-timeout 3 "$m/oa.sh" >/dev/null 2>&1; then
echo -e " ${YELLOW}[MIRROR]${NC} Using mirror for GitHub downloads"
REPO_BASE="$m"; return 0
fi
done
return 1
}
# Kept in sync with scripts/lib.sh resolve_npm_registry()
NPM_REGISTRY_ORIGIN="https://registry.npmjs.org/"
NPM_REGISTRY_MIRROR="https://registry.npmmirror.com/"
resolve_npm_registry() {
local choice
local cache_file="$OCA_DIR/.npm-registry"
local reachable=0
if curl -sI --connect-timeout 5 "$NPM_REGISTRY_ORIGIN" >/dev/null 2>&1; then
choice="$NPM_REGISTRY_ORIGIN"
reachable=1
elif curl -sI --connect-timeout 5 "$NPM_REGISTRY_MIRROR" >/dev/null 2>&1; then
echo -e " ${YELLOW}[MIRROR]${NC} Using npm mirror: ${NPM_REGISTRY_MIRROR}"
choice="$NPM_REGISTRY_MIRROR"
reachable=1
else
choice="$NPM_REGISTRY_ORIGIN"
fi
mkdir -p "$(dirname "$cache_file")"
printf '%s' "$choice" > "$cache_file.tmp" && mv "$cache_file.tmp" "$cache_file"
export NPM_CONFIG_REGISTRY="$choice"
if [ "$reachable" -eq 1 ]; then
return 0
fi
return 1
}
# SSL cert for curl (bootstrap curl looks at hardcoded com.termux path)
export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem"
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem"
# Git system config has hardcoded com.termux path — skip it
export GIT_CONFIG_NOSYSTEM=1
# Git exec path (git looks for helpers like git-remote-https here)
export GIT_EXEC_PATH="$PREFIX/libexec/git-core"
# Git template dir (hardcoded /data/data/com.termux path workaround)
export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates"
if [ -f "$MARKER" ]; then
echo -e "${GREEN}Post-setup already completed.${NC}"
exit 0
fi
echo ""
echo "══════════════════════════════════════════════"
echo " OpenClaw Android — Installing components"
echo "══════════════════════════════════════════════"
echo ""
mkdir -p "$OCA_DIR" "$OCA_DIR/patches" "$TMPDIR"
TERMUX_DEB_REPO="https://packages-cf.termux.dev/apt/termux-main"
PACMAN_PKG_REPO="https://service.termux-pacman.dev/gpkg/aarch64"
TERMUX_INNER="data/data/com.termux/files/usr"
DEB_DIR="$TMPDIR/debs"
PKG_DIR="$TMPDIR/pkgs"
EXTRACT_DIR="$TMPDIR/pkg-extract"
# ─── Helper: install_deb ──────────────────────
# Downloads a .deb from Termux repo and extracts into $PREFIX
install_deb() {
local filename="$1"
local name
name=$(basename "$filename" | sed 's/_[0-9].*//')
local url="${TERMUX_DEB_REPO}/${filename}"
local deb_file="${DEB_DIR}/$(basename "$filename")"
if [ -f "$deb_file" ]; then
echo " (cached) $name"
else
echo " downloading $name..."
curl -fsSL --max-time 120 -o "$deb_file" "$url"
fi
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
dpkg-deb -x "$deb_file" "$EXTRACT_DIR" 2>/dev/null
# Relocate: data/data/com.termux/files/usr/* → $PREFIX/
if [ -d "$EXTRACT_DIR/$TERMUX_INNER" ]; then
cp -a "$EXTRACT_DIR/$TERMUX_INNER/"* "$PREFIX/" 2>/dev/null || true
fi
rm -rf "$EXTRACT_DIR"
}
# ─── Helper: install_pacman_pkg ───────────────
# Downloads a .pkg.tar.xz from pacman repo and extracts into target dir
install_pacman_pkg() {
local filename="$1"
local target="$2" # e.g., $PREFIX/glibc
local name
name=${filename%%-[0-9]*}
local url="${PACMAN_PKG_REPO}/${filename}"
local pkg_file="${PKG_DIR}/${filename}"
if [ -f "$pkg_file" ]; then
echo " (cached) $name"
else
echo " downloading $name..."
curl -fsSL --max-time 300 -o "$pkg_file" "$url"
fi
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
tar -xJf "$pkg_file" -C "$EXTRACT_DIR" 2>/dev/null
# Pacman packages also extract under data/data/com.termux/files/usr/...
local inner="$EXTRACT_DIR/$TERMUX_INNER"
if [ -d "$inner/glibc" ]; then
# glibc packages go under $PREFIX/glibc/
cp -a "$inner/glibc/"* "$target/" 2>/dev/null || true
elif [ -d "$inner" ]; then
cp -a "$inner/"* "$target/" 2>/dev/null || true
fi
rm -rf "$EXTRACT_DIR"
}
# ─── [1/7] Install essential packages ─────────
echo -e "${YELLOW}[1/7]${NC} Installing essential packages..."
mkdir -p "$DEB_DIR" "$PKG_DIR"
# Download Packages index to resolve .deb filenames
echo " Fetching package index..."
PACKAGES_FILE="$TMPDIR/Packages"
curl -fsSL --max-time 60 \
"${TERMUX_DEB_REPO}/dists/stable/main/binary-aarch64/Packages" \
-o "$PACKAGES_FILE"
# Resolve package filename from Packages index
get_deb_filename() {
local pkg="$1"
awk -v pkg="$pkg" '
/^Package: / { found = ($2 == pkg) }
found && /^Filename:/ { print $2; exit }
' "$PACKAGES_FILE"
}
# Packages to install via dpkg-deb (dependency order, only those missing from bootstrap)
DEB_PACKAGES=(
libexpat # git dep
pcre2 # git dep
git # for npm/openclaw
)
TOTAL=${#DEB_PACKAGES[@]}
COUNT=0
for pkg in "${DEB_PACKAGES[@]}"; do
COUNT=$((COUNT + 1))
filename=$(get_deb_filename "$pkg")
if [ -z "$filename" ]; then
echo -e " ${RED}${NC} Package '$pkg' not found in index"
continue
fi
echo " [$COUNT/$TOTAL] $pkg"
install_deb "$filename"
done
# Make sure newly extracted binaries are executable
chmod +x "$PREFIX/bin/"* 2>/dev/null || true
# Verify git
if [ -f "$PREFIX/bin/git" ]; then
echo -e " ${GREEN}${NC} git $(git --version 2>/dev/null | head -1)"
else
echo -e " ${RED}${NC} git not found after extraction"
exit 1
fi
# ─── [2/7] glibc runtime ─────────────────────
echo -e "${YELLOW}[2/7]${NC} Installing glibc runtime..."
if [ -x "$GLIBC_LDSO" ]; then
echo -e " ${GREEN}[SKIP]${NC} glibc already installed"
else
mkdir -p "$PREFIX/glibc"
# Download glibc package directly from pacman repo (no pacman needed)
# The gpkg.db tells us: glibc-2.42-0-aarch64.pkg.tar.xz (~9.7MB)
echo " Downloading glibc (~10MB)..."
install_pacman_pkg "glibc-2.42-0-aarch64.pkg.tar.xz" "$PREFIX/glibc"
# gcc-libs-glibc provides libstdc++.so.6 needed by Node.js (~24MB)
echo " Downloading gcc-libs (~24MB)..."
install_pacman_pkg "gcc-libs-glibc-14.2.1-1-aarch64.pkg.tar.xz" "$PREFIX/glibc"
# Verify linker
if [ ! -f "$GLIBC_LDSO" ]; then
echo -e " ${RED}${NC} glibc linker not found at $GLIBC_LDSO"
exit 1
fi
chmod +x "$GLIBC_LDSO"
mkdir -p "$OCA_DIR"
touch "$OCA_DIR/.glibc-arch"
echo -e " ${GREEN}${NC} glibc installed"
fi
# Install supplementary glibc libraries (libcap etc.)
_GLIBC_LIBS_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/patches/glibc-libs"
if [ -d "$PREFIX/glibc/lib" ] && [ -d "$_GLIBC_LIBS_SRC" ]; then
for _lib in "$_GLIBC_LIBS_SRC"/*.so.*; do
[ -f "$_lib" ] || continue
_fn=$(basename "$_lib")
if [ ! -f "$PREFIX/glibc/lib/$_fn" ]; then
cp "$_lib" "$PREFIX/glibc/lib/$_fn"
_sn=$(echo "$_fn" | sed -E 's/^(lib[^.]+\.so\.[0-9]+)\..*/\1/')
[ "$_sn" != "$_fn" ] && ln -sf "$_fn" "$PREFIX/glibc/lib/$_sn"
echo -e " ${GREEN}${NC} Installed $_sn"
fi
done
fi
# Ensure glibc /etc/hosts exists (localhost resolution)
if [ -d "$PREFIX/glibc/etc" ] && [ ! -f "$PREFIX/glibc/etc/hosts" ]; then
cat > "$PREFIX/glibc/etc/hosts" <<'HOSTS'
127.0.0.1 localhost localhost.localdomain
::1 localhost ip6-localhost ip6-loopback
HOSTS
echo -e " ${GREEN}${NC} Created glibc /etc/hosts"
fi
echo -e " Linker: $GLIBC_LDSO"
# ─── [3/7] Node.js ──────────────────────────
echo -e "${YELLOW}[3/7]${NC} Installing Node.js v${NODE_VERSION}..."
mkdir -p "$NODE_DIR/bin"
_NODE_CMD=""
if [ -x "$BIN_DIR/node" ]; then _NODE_CMD="$BIN_DIR/node"
elif [ -f "$NODE_DIR/bin/node.real" ] && [ -x "$NODE_DIR/bin/node" ]; then _NODE_CMD="$NODE_DIR/bin/node"
fi
if [ -n "$_NODE_CMD" ] && "$_NODE_CMD" --version &>/dev/null; then
INSTALLED_VER=$("$_NODE_CMD" --version 2>/dev/null || echo "")
echo -e " ${GREEN}[SKIP]${NC} Node.js already installed ($INSTALLED_VER)"
# Repair wrappers in BIN_DIR (safe from npm overwrites)
mkdir -p "$BIN_DIR"
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then
cat > "$BIN_DIR/npm" << NPMWRAP
#!$PREFIX/bin/bash
"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@"
_npm_exit=\$?
case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*)
_oc_bin="$PREFIX/bin/openclaw"
_oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs"
if [ -f "\$_oc_mjs" ]; then
[ -L "\$_oc_bin" ] && rm -f "\$_oc_bin"
printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin"
chmod +x "\$_oc_bin"
fi
;;
esac
# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix)
case "\$*" in *codex-cli-termux*)
_codex_bin="$PREFIX/bin/codex"
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
if [ -f "\$_codex_pkg/codex.bin" ]; then
[ -L "\$_codex_bin" ] && rm -f "\$_codex_bin"
printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin"
chmod +x "\$_codex_bin"
fi
;;
esac
# Fix shebangs in npm global CLI entry points after global install
case "\$*" in *-g*|*--global*)
for _js in $PREFIX/lib/node_modules/*/bin/*.js \
$PREFIX/lib/node_modules/@*/*/bin/*.js; do
[ -f "\$_js" ] || continue
head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js"
done
;;
esac
exit \$_npm_exit
NPMWRAP
chmod +x "$BIN_DIR/npm"
fi
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then
cat > "$BIN_DIR/npx" << NPXWRAP
#!$PREFIX/bin/bash
exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@"
NPXWRAP
chmod +x "$BIN_DIR/npx"
fi
if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack"
fi
else
NODE_TAR="node-v${NODE_VERSION}-linux-arm64"
echo " Downloading Node.js v${NODE_VERSION} (~25MB)..."
curl -fSL --max-time 300 \
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TAR}.tar.xz" \
-o "$TMPDIR/${NODE_TAR}.tar.xz"
echo " Extracting..."
tar -xJf "$TMPDIR/${NODE_TAR}.tar.xz" -C "$NODE_DIR" --strip-components=1
# Move original binary → node.real
if [ -f "$NODE_DIR/bin/node" ] && [ ! -L "$NODE_DIR/bin/node" ]; then
mv "$NODE_DIR/bin/node" "$NODE_DIR/bin/node.real"
fi
rm -f "$TMPDIR/${NODE_TAR}.tar.xz"
# Create grun-style node wrapper in BIN_DIR (safe from npm overwrites)
mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/node" << WRAPPER
#!${PREFIX}/bin/bash
[ -n "\$LD_PRELOAD" ] && export _OA_ORIG_LD_PRELOAD="\$LD_PRELOAD"
unset LD_PRELOAD
export _OA_WRAPPER_PATH="$BIN_DIR/node"
_OA_COMPAT="\$HOME/.openclaw-android/patches/glibc-compat.js"
if [ -f "\$_OA_COMPAT" ]; then
case "\${NODE_OPTIONS:-}" in
*"\$_OA_COMPAT"*) ;;
*) export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }-r \$_OA_COMPAT" ;;
esac
fi
_LEADING_OPTS=""
_COUNT=0
for _arg in "\$@"; do
case "\$_arg" in --*) _COUNT=\$((_COUNT + 1)) ;; *) break ;; esac
done
if [ \$_COUNT -gt 0 ] && [ \$_COUNT -lt \$# ]; then
while [ \$# -gt 0 ]; do
case "\$1" in
--*) _LEADING_OPTS="\${_LEADING_OPTS:+\$_LEADING_OPTS }\$1"; shift ;;
*) break ;;
esac
done
export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }\$_LEADING_OPTS"
fi
exec "$GLIBC_LDSO" --library-path "$PREFIX/glibc/lib" "$NODE_DIR/bin/node.real" "\$@"
WRAPPER
chmod +x "$BIN_DIR/node"
# Create npm/npx wrappers in BIN_DIR
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then
cat > "$BIN_DIR/npm" << NPMWRAP
#!$PREFIX/bin/bash
"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@"
_npm_exit=\$?
case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*)
_oc_bin="$PREFIX/bin/openclaw"
_oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs"
if [ -f "\$_oc_mjs" ]; then
[ -L "\$_oc_bin" ] && rm -f "\$_oc_bin"
printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin"
chmod +x "\$_oc_bin"
fi
;;
esac
# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix)
case "\$*" in *codex-cli-termux*)
_codex_bin="$PREFIX/bin/codex"
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
if [ -f "\$_codex_pkg/codex.bin" ]; then
[ -L "\$_codex_bin" ] && rm -f "\$_codex_bin"
printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin"
chmod +x "\$_codex_bin"
fi
;;
esac
# Fix shebangs in npm global CLI entry points after global install
case "\$*" in *-g*|*--global*)
for _js in $PREFIX/lib/node_modules/*/bin/*.js \
$PREFIX/lib/node_modules/@*/*/bin/*.js; do
[ -f "\$_js" ] || continue
head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js"
done
;;
esac
exit \$_npm_exit
NPMWRAP
chmod +x "$BIN_DIR/npm"
fi
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then
cat > "$BIN_DIR/npx" << NPXWRAP
#!$PREFIX/bin/bash
exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@"
NPXWRAP
chmod +x "$BIN_DIR/npx"
fi
# corepack: shebang patch only
if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack"
fi
# Configure npm
export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH"
"$BIN_DIR/npm" config set script-shell "$PREFIX/bin/sh" 2>/dev/null || true
# Verify
NODE_VER=$("$BIN_DIR/node" --version 2>/dev/null) || {
echo -e " ${RED}${NC} Node.js verification failed"
exit 1
}
echo -e " ${GREEN}${NC} Node.js $NODE_VER (glibc)"
fi
# ─── [4/7] OpenClaw ─────────────────────────
echo -e "${YELLOW}[4/7]${NC} Installing OpenClaw..."
export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH"
# Auto-detect GitHub mirror for restricted networks
resolve_repo_base
# Auto-detect npm registry (session-scoped via NPM_CONFIG_REGISTRY env var).
# Does NOT write to ~/.npmrc — see CHANGELOG v1.0.24.
resolve_npm_registry || true
# Force git to use HTTPS instead of SSH (no SSH client available).
# Preserve any existing user .gitconfig (name, email, aliases); only set our keys.
touch "$HOME/.gitconfig"
git config --global http.sslCAInfo "$PREFIX/etc/tls/cert.pem"
git config --global --unset-all url."https://github.com/".insteadOf 2>/dev/null || true
git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global --add url."https://github.com/".insteadOf "git@github.com:"
# Git wrapper: replace $PREFIX/bin/git with a wrapper that:
# 1. Strips --recurse-submodules (triggers open() on hardcoded com.termux path)
# 2. Cleans existing target dirs before clone (npm's withTempDir creates dir first)
# npm caches git path at module load via which.sync('git'), so we must replace the binary.
# $PREFIX/bin/git is a symlink -> ../libexec/git-core/git (the real ELF binary).
REAL_GIT="$PREFIX/libexec/git-core/git"
if [ -f "$REAL_GIT" ] && [ ! -f "$PREFIX/bin/git.wrapper-installed" ]; then
echo " Installing git wrapper (strips --recurse-submodules)..."
rm -f "$PREFIX/bin/git"
# Write shebang with absolute path (no LD_PRELOAD = no /bin/bash rewrite)
echo "#!${PREFIX}/bin/bash" > "$PREFIX/bin/git"
cat >> "$PREFIX/bin/git" << 'ENDWRAP'
filtered=()
is_clone=false
for a in "$@"; do
case "$a" in
--recurse-submodules) ;;
clone) is_clone=true; filtered+=("$a") ;;
*) filtered+=("$a") ;;
esac
done
if $is_clone; then
for a in "${filtered[@]}"; do
case "$a" in
clone|--*|-*|http*|ssh*|git*|[0-9]) ;;
*) [ -d "$a" ] && rm -rf "$a" ;;
esac
done
fi
ENDWRAP
echo "exec \"$REAL_GIT\" \"\${filtered[@]}\"" >> "$PREFIX/bin/git"
chmod +x "$PREFIX/bin/git"
touch "$PREFIX/bin/git.wrapper-installed"
echo -e " ${GREEN}\u2713${NC} git wrapper installed"
else
if [ -f "$PREFIX/bin/git.wrapper-installed" ]; then
echo -e " ${GREEN}[SKIP]${NC} git wrapper already installed"
else
echo -e " ${RED}\u2717${NC} Real git not found at $REAL_GIT"
exit 1
fi
fi
if command -v openclaw &>/dev/null 2>&1; then
OC_VER=$(openclaw --version 2>/dev/null || echo "unknown")
echo -e " ${GREEN}[SKIP]${NC} OpenClaw already installed ($OC_VER)"
else
# Clean npm cache tmp dir (leftover from previous failed installs)
rm -rf "$HOME/.npm/_cacache/tmp" 2>/dev/null || true
npm install -g openclaw@latest --ignore-scripts 2>&1
OC_VER=$(openclaw --version 2>/dev/null || echo "installed")
echo -e " ${GREEN}${NC} OpenClaw $OC_VER"
fi
# Restore optional/channel deps that --ignore-scripts skips.
# Uses npm_config_ignore_scripts=true so sharp's native build doesn't block.
OPENCLAW_DIR="$(npm root -g)/openclaw"
if [ -d "$OPENCLAW_DIR" ]; then
echo " Restoring optional dependencies..."
(cd "$OPENCLAW_DIR" && npm_config_ignore_scripts=true node scripts/postinstall-bundled-plugins.mjs 2>/dev/null) || true
fi
# Install clawdhub (skill manager)
echo " Installing clawdhub..."
if npm install -g clawdhub --no-fund --no-audit; then
echo -e " ${GREEN}${NC} clawdhub installed"
CLAWHUB_DIR="$(npm root -g)/clawdhub"
if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then
echo " Installing undici dependency for clawdhub..."
(cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit) || true
fi
else
echo -e " ${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)"
fi
# PyYAML (for .skill packaging)
command -v python &>/dev/null && { python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true; }
# Run openclaw update (builds native modules like sharp)
echo " Running: openclaw update (this may take 5-10 minutes)..."
openclaw update || true
# ─── [5/7] Patches ──────────────────────────
echo -e "${YELLOW}[5/7]${NC} Applying patches..."
# Copy glibc-compat.js from project (bundled alongside this script)
COMPAT_SRC="$(dirname "$0")/glibc-compat.js"
if [ -f "$COMPAT_SRC" ]; then
cp "$COMPAT_SRC" "$OCA_DIR/patches/glibc-compat.js"
else
# Fallback: download from repo
curl -fsSL "$REPO_BASE/patches/glibc-compat.js" \
-o "$OCA_DIR/patches/glibc-compat.js" 2>/dev/null || true
fi
# systemctl stub
printf '#!%s/bin/bash\nexit 0\n' "$PREFIX" > "$PREFIX/bin/systemctl"
chmod +x "$PREFIX/bin/systemctl"
# sharp WASM fallback (prebuilt native binaries don't load on Android)
if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then
if ! node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
echo " Installing sharp WebAssembly runtime..."
(cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3) || true
fi
fi
echo -e " ${GREEN}${NC} Patches applied"
# ─── [6/7] Environment ──────────────────────
echo -e "${YELLOW}[6/7]${NC} Configuring environment..."
cat > "$HOME/.bashrc" << BASHRC
# OpenClaw Android environment
export PREFIX="$PREFIX"
export HOME="$HOME"
export TMPDIR="$TMPDIR"
export PATH="$BIN_DIR:$NODE_DIR/bin:\$PREFIX/bin:\$PATH"
export LD_LIBRARY_PATH="$PREFIX/lib"
export LD_PRELOAD="$PREFIX/lib/libtermux-exec.so"
export TERMUX__PREFIX="$PREFIX"
export TERMUX_PREFIX="$PREFIX"
export LANG=en_US.UTF-8
export TERM=xterm-256color
export OA_GLIBC=1
export CONTAINER=1
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem"
export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem"
export GIT_CONFIG_NOSYSTEM=1
export GIT_EXEC_PATH="$PREFIX/libexec/git-core"
export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates"
export CLAWDHUB_WORKDIR="$HOME/.openclaw/workspace"
export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
# npm registry (auto-detected by OpenClaw Android, safe to override manually)
[ -z "\${NPM_CONFIG_REGISTRY:-}" ] && [ -s "\$HOME/.openclaw-android/.npm-registry" ] && \\
export NPM_CONFIG_REGISTRY="\$(cat "\$HOME/.openclaw-android/.npm-registry")"
BASHRC
echo -e " ${GREEN}${NC} ~/.bashrc configured"
# oa CLI (enables oa --update, oa --backup, etc.)
if curl -fsSL "$REPO_BASE/oa.sh" \
-o "$PREFIX/bin/oa" 2>/dev/null; then
chmod +x "$PREFIX/bin/oa"
echo -e " ${GREEN}${NC} oa CLI installed"
else
echo -e " ${YELLOW}[WARN]${NC} oa CLI installation failed (non-critical)"
fi
# ─── [7/7] Optional Tools ──────────────────
TOOL_CONF="$OCA_DIR/tool-selections.conf"
if [ -f "$TOOL_CONF" ]; then
# shellcheck source=/dev/null
source "$TOOL_CONF"
HAS_TOOLS=false
for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_CODE_SERVER INSTALL_PLAYWRIGHT INSTALL_CLAUDE_CODE INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do
eval "val=\${$var:-false}"
# shellcheck disable=SC2154
[ "$val" = "true" ] && HAS_TOOLS=true && break
done
if $HAS_TOOLS; then
echo -e "${YELLOW}[7/7]${NC} Installing optional tools..."
# Helper: install .deb with direct dependencies
install_with_deps() {
local pkg="$1"
local deps
deps=$(awk -v pkg="$pkg" '
/^Package: / { found = ($2 == pkg) }
found && /^Depends:/ {
gsub(/^Depends: /, "")
gsub(/ *\([^)]*\)/, "")
gsub(/, /, "\n")
print; exit
}
' "$PACKAGES_FILE")
while IFS= read -r dep; do
dep=$(echo "$dep" | tr -d ' ')
[ -z "$dep" ] && continue
local dep_file
dep_file=$(get_deb_filename "$dep")
if [ -n "$dep_file" ]; then install_deb "$dep_file" 2>/dev/null || true; fi
done <<< "$deps"
local filename
filename=$(get_deb_filename "$pkg")
[ -n "$filename" ] && install_deb "$filename"
}
# Termux packages
[ "${INSTALL_TMUX:-false}" = "true" ] && {
echo " Installing tmux..."
install_with_deps tmux
echo -e " ${GREEN}${NC} tmux"
}
[ "${INSTALL_TTYD:-false}" = "true" ] && {
echo " Installing ttyd..."
install_with_deps ttyd
echo -e " ${GREEN}${NC} ttyd"
}
[ "${INSTALL_DUFS:-false}" = "true" ] && {
echo " Installing dufs..."
install_with_deps dufs
echo -e " ${GREEN}${NC} dufs"
}
# npm packages
[ "${INSTALL_CODE_SERVER:-false}" = "true" ] && {
echo " Installing code-server (this may take a while)..."
npm install -g code-server 2>&1 || true
echo -e " ${GREEN}${NC} code-server"
}
[ "${INSTALL_PLAYWRIGHT:-false}" = "true" ] && {
echo " Installing Playwright (playwright-core)..."
npm install -g playwright-core 2>&1 || true
# Set Playwright environment variables if Chromium is available
CHROMIUM_BIN=""
for bin in "$PREFIX/bin/chromium-browser" "$PREFIX/bin/chromium"; do
[ -x "$bin" ] && CHROMIUM_BIN="$bin" && break
done
if [ -n "$CHROMIUM_BIN" ]; then
PW_MARKER_START="# >>> Playwright >>>"
PW_MARKER_END="# <<< Playwright <<<"
if ! grep -qF "$PW_MARKER_START" "$HOME/.bashrc"; then
cat >> "$HOME/.bashrc" << PWENV
${PW_MARKER_START}
export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM_BIN"
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
${PW_MARKER_END}
PWENV
fi
echo -e " ${GREEN}${NC} Playwright (env: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$CHROMIUM_BIN)"
else
echo -e " ${GREEN}${NC} Playwright (install Chromium later via 'oa --install' for full setup)"
fi
}
[ "${INSTALL_CLAUDE_CODE:-false}" = "true" ] && {
echo " Installing Claude Code..."
npm install -g @anthropic-ai/claude-code 2>&1 || true
echo -e " ${GREEN}${NC} Claude Code"
}
[ "${INSTALL_GEMINI_CLI:-false}" = "true" ] && {
echo " Installing Gemini CLI..."
npm install -g @google/gemini-cli 2>&1 || true
echo -e " ${GREEN}${NC} Gemini CLI"
}
[ "${INSTALL_CODEX_CLI:-false}" = "true" ] && {
echo " Installing Codex CLI (Termux)..."
npm install -g @mmmbuto/codex-cli-termux 2>&1 || true
# Create codex CLI wrapper (DioNanos fork launcher fix)
_codex_bin="$PREFIX/bin/codex"
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
if [ -f "$_codex_pkg/codex.bin" ]; then
[ -L "$_codex_bin" ] && rm -f "$_codex_bin"
printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \
"$PREFIX" "$_codex_pkg" > "$_codex_bin"
chmod +x "$_codex_bin"
fi
echo -e " ${GREEN}${NC} Codex CLI (Termux)"
}
# Fix shebangs in npm global CLIs (kept in sync with scripts/lib.sh fix_npm_global_shebangs())
for _js in "$PREFIX/lib/node_modules"/*/bin/*.js \
"$PREFIX/lib/node_modules"/@*/*/bin/*.js; do
[ -f "$_js" ] || continue
head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$_js"
done
else
echo -e "${YELLOW}[7/7]${NC} No optional tools selected"
fi
else
echo -e "${YELLOW}[7/7]${NC} No optional tools selected"
fi
# ─── Cleanup ────────────────────────────────
rm -rf "$DEB_DIR" "$PKG_DIR" "$PACKAGES_FILE" "$TMPDIR/gpkg.db" 2>/dev/null || true
# ─── Done ────────────────────────────────────
touch "$MARKER"
echo ""
echo "══════════════════════════════════════════════"
echo -e " ${GREEN}✓ Installation complete!${NC}"
echo "══════════════════════════════════════════════"
echo ""
echo " Loading environment..."
source "$HOME/.bashrc"
echo ""
echo " Starting OpenClaw onboard..."
echo ""
openclaw onboard
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108">
<!-- Background circle -->
<circle cx="54" cy="54" r="54" fill="#111827"/>
<!-- 4 claw scratch marks, rotated diagonally -->
<g transform="rotate(-25 54 54)">
<!-- Scratch 1 (outer left, shorter) -->
<path fill="#FFFFFF" d="M31,22 C28,44,28,64,31,86 C34,64,34,44,31,22Z"/>
<!-- Scratch 2 (inner left, longer) -->
<path fill="#FFFFFF" d="M43,16 C40,40,40,64,43,92 C46,64,46,40,43,16Z"/>
<!-- Scratch 3 (inner right, longer) -->
<path fill="#FFFFFF" d="M55,16 C52,40,52,64,55,92 C58,64,58,40,55,16Z"/>
<!-- Scratch 4 (outer right, shorter) -->
<path fill="#FFFFFF" d="M67,22 C64,44,64,64,67,86 C70,64,70,44,67,22Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 732 B

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>OpenClaw</title>
<script type="module" crossorigin src="./assets/index-D_agBQQF.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BrlC3beh.css">
</head>
<body>
<div id="root"></div>
<!-- Native event bridge receiver (§2.8) — must load before React -->
<script>
window.__oc = {
emit(type, data) {
window.dispatchEvent(new CustomEvent('native:' + type, { detail: data }));
}
};
</script>
</body>
</html>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 120 120"><defs><linearGradient id="a" x1="0%" x2="100%" y1="0%" y2="100%"><stop offset="0%" stop-color="#ff4d4d"/><stop offset="100%" stop-color="#991b1b"/></linearGradient></defs><path fill="url(#a)" d="M60 10c-30 0-45 25-45 45s15 40 30 45v10h10v-10s5 2 10 0v10h10v-10c15-5 30-25 30-45S90 10 60 10"/><path fill="url(#a)" d="M20 45C5 40 0 50 5 60s15 5 20-5c3-7 0-10-5-10M100 45c15-5 20 5 15 15s-15 5-20-5c-3-7 0-10 5-10"/><path stroke="#ff4d4d" stroke-linecap="round" stroke-width="3" d="M45 15Q35 5 30 8M75 15Q85 5 90 8"/><circle cx="45" cy="35" r="6" fill="#050810"/><circle cx="75" cy="35" r="6" fill="#050810"/><circle cx="46" cy="34" r="2.5" fill="#00e5cc"/><circle cx="76" cy="34" r="2.5" fill="#00e5cc"/></svg>

After

Width:  |  Height:  |  Size: 782 B

@@ -0,0 +1,48 @@
package com.openclaw.android
import android.util.Log
/**
* Centralized logging wrapper.
* All app code should use AppLogger instead of android.util.Log directly.
* detekt ForbiddenMethodCall enforces this rule for new code.
*/
@Suppress("ForbiddenMethodCall")
object AppLogger {
fun v(
tag: String,
message: String,
) = Log.v(tag, message)
fun d(
tag: String,
message: String,
) = Log.d(tag, message)
fun i(
tag: String,
message: String,
) = Log.i(tag, message)
fun w(
tag: String,
message: String,
) = Log.w(tag, message)
fun w(
tag: String,
message: String,
throwable: Throwable,
) = Log.w(tag, message, throwable)
fun e(
tag: String,
message: String,
) = Log.e(tag, message)
fun e(
tag: String,
message: String,
throwable: Throwable,
) = Log.e(tag, message, throwable)
}
@@ -0,0 +1,30 @@
package com.openclaw.android
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Starts MainActivity on device boot so terminal sessions
* and openclaw gateway can auto-launch.
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BootReceiver"
}
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
AppLogger.i(TAG, "Boot completed — launching OpenClaw")
val launchIntent =
Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("from_boot", true)
}
context.startActivity(launchIntent)
}
}
}
@@ -0,0 +1,468 @@
package com.openclaw.android
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.net.URL
import java.util.zip.ZipInputStream
/**
* Manages Termux bootstrap download, extraction, and configuration.
* Phase 0: extracts from assets. Phase 1+: downloads from network.
* Based on AnyClaw BootstrapInstaller.kt pattern (§2.2.1).
*/
class BootstrapManager(
private val context: Context,
) {
companion object {
private const val TAG = "BootstrapManager"
private const val PROGRESS_PREPARING = 0.05f
private const val PROGRESS_DOWNLOADING = 0.10f
private const val PROGRESS_EXTRACTING = 0.30f
private const val PROGRESS_CONFIGURING = 0.60f
private const val ELF_MAGIC_SIZE = 4
private val ELF_SIGNATURE = byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())
private const val SYMLINK_SEPARATOR = ""
private const val SYMLINK_PARTS_COUNT = 2
}
val prefixDir = File(context.filesDir, "usr")
val homeDir = File(context.filesDir, "home")
val tmpDir = File(context.filesDir, "tmp")
val wwwDir = File(prefixDir, "share/openclaw-app/www")
private val stagingDir = File(context.filesDir, "usr-staging")
fun isInstalled(): Boolean = prefixDir.resolve("bin/sh").exists()
fun needsPostSetup(): Boolean {
val marker = File(homeDir, ".openclaw-android/.post-setup-done")
return isInstalled() && !marker.exists()
}
val postSetupScript: File
get() = File(homeDir, ".openclaw-android/post-setup.sh")
data class SetupStatus(
val bootstrapInstalled: Boolean,
val runtimeInstalled: Boolean,
val wwwInstalled: Boolean,
val platformInstalled: Boolean,
)
fun getStatus(): SetupStatus =
SetupStatus(
bootstrapInstalled = isInstalled(),
runtimeInstalled = prefixDir.resolve("bin/node").exists(),
wwwInstalled = wwwDir.resolve("index.html").exists(),
platformInstalled = File(homeDir, ".openclaw-android/.post-setup-done").exists(),
)
/**
* Full setup flow. Reports progress via callback (0.01.0).
*/
suspend fun startSetup(onProgress: (Float, String) -> Unit) =
withContext(Dispatchers.IO) {
// Clean up any incomplete previous attempt before starting
if (stagingDir.exists()) {
AppLogger.i(TAG, "Removing incomplete staging dir from previous attempt")
stagingDir.deleteRecursively()
}
if (isInstalled()) {
// Bootstrap exists but setup is incomplete — wipe and reinstall
AppLogger.i(TAG, "Incomplete bootstrap detected, reinstalling...")
prefixDir.deleteRecursively()
}
// Step 1: Download or extract bootstrap
onProgress(PROGRESS_PREPARING, "Preparing bootstrap...")
val zipStream = getBootstrapStream(onProgress)
// Step 2: Extract bootstrap
onProgress(PROGRESS_EXTRACTING, "Extracting bootstrap...")
extractBootstrap(zipStream)
// Step 3: Fix paths and configure
onProgress(PROGRESS_CONFIGURING, "Configuring environment...")
fixTermuxPaths(stagingDir)
configureApt(stagingDir)
// Step 4: Atomic rename
stagingDir.renameTo(prefixDir)
setupDirectories()
copyAssetScripts()
syncWwwFromAssets()
setupTermuxExec()
onProgress(1f, "Setup complete")
}
// --- Bootstrap source ---
private suspend fun getBootstrapStream(onProgress: (Float, String) -> Unit): InputStream {
// Phase 0: Try assets first
try {
return context.assets.open("bootstrap-aarch64.zip")
} catch (_: Exception) {
// Phase 1: Download from network
}
onProgress(PROGRESS_DOWNLOADING, "Downloading bootstrap...")
val url = UrlResolver(context).getBootstrapUrl()
return URL(url).openStream()
}
// --- Extraction ---
private fun extractBootstrap(inputStream: InputStream) {
stagingDir.deleteRecursively()
stagingDir.mkdirs()
ZipInputStream(inputStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
processZipEntry(zip, entry)
zip.closeEntry()
entry = zip.nextEntry
}
}
}
private fun processZipEntry(
zip: ZipInputStream,
entry: java.util.zip.ZipEntry,
) {
if (entry.name == "SYMLINKS.txt") {
processSymlinks(zip, stagingDir)
} else if (!entry.isDirectory) {
val file = File(stagingDir, entry.name)
file.parentFile?.mkdirs()
file.outputStream().use { out -> zip.copyTo(out) }
markExecutableIfNeeded(file, entry.name)
}
}
private fun markExecutableIfNeeded(
file: File,
name: String,
) {
val knownExecutable =
name.startsWith("bin/") ||
name.startsWith("libexec/") ||
name.startsWith("lib/apt/") ||
name.startsWith("lib/bash/") ||
name.endsWith(".so") ||
name.contains(".so.")
if (knownExecutable) {
file.setExecutable(true)
} else if (file.length() > ELF_MAGIC_SIZE && isElfBinary(file)) {
file.setExecutable(true)
}
}
private fun isElfBinary(file: File): Boolean =
try {
file.inputStream().use { fis ->
val magic = ByteArray(ELF_MAGIC_SIZE)
fis.read(magic) == ELF_MAGIC_SIZE &&
magic.contentEquals(ELF_SIGNATURE)
}
} catch (_: Exception) {
false
}
/**
* Process SYMLINKS.txt: each line is "target←linkpath".
* Replace com.termux paths with our package name.
*/
private fun processSymlinks(
zip: ZipInputStream,
targetDir: File,
) {
val content = zip.bufferedReader().readText()
val ourPackage = context.packageName
content
.lines()
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(SYMLINK_SEPARATOR)
if (parts.size == SYMLINK_PARTS_COUNT) parts else null
}.forEach { parts ->
val symlinkTarget = parts[0].trim().replace("com.termux", ourPackage)
val symlinkPath = parts[1].trim()
val linkFile = File(targetDir, symlinkPath)
linkFile.parentFile?.mkdirs()
try {
Os.symlink(symlinkTarget, linkFile.absolutePath)
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to create symlink: $symlinkPath -> $symlinkTarget", e)
}
}
}
// --- Path fixing (§2.2.2) ---
private fun fixTermuxPaths(dir: File) {
val ourPackage = context.packageName
val oldPrefix = "/data/data/com.termux/files/usr"
val newPrefix = prefixDir.absolutePath
// Fix dpkg status database
fixTextFile(dir.resolve("var/lib/dpkg/status"), oldPrefix, newPrefix)
// Fix dpkg info files
val dpkgInfoDir = dir.resolve("var/lib/dpkg/info")
if (dpkgInfoDir.isDirectory) {
dpkgInfoDir.listFiles()?.filter { it.name.endsWith(".list") }?.forEach { file ->
fixTextFile(file, "com.termux", ourPackage)
}
}
// Fix git scripts shebangs
val gitCoreDir = dir.resolve("libexec/git-core")
if (gitCoreDir.isDirectory) {
gitCoreDir.listFiles()?.forEach { file ->
if (file.isFile && !file.name.contains(".")) {
fixTextFile(file, oldPrefix, newPrefix)
}
}
}
}
private fun fixTextFile(
file: File,
oldText: String,
newText: String,
) {
if (!file.exists() || !file.isFile) return
try {
val content = file.readText()
if (content.contains(oldText)) {
file.writeText(content.replace(oldText, newText))
}
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to fix paths in ${file.name}", e)
}
}
// --- apt configuration (§2.2.3) ---
private fun configureApt(dir: File) {
val prefix = prefixDir.absolutePath
val ourPackage = context.packageName
// sources.list: HTTPS→HTTP downgrade + package name fix
val sourcesList = dir.resolve("etc/apt/sources.list")
if (sourcesList.exists()) {
sourcesList.writeText(
sourcesList
.readText()
.replace("https://", "http://")
.replace("com.termux", ourPackage),
)
}
// apt.conf: full rewrite with correct paths
val aptConf = dir.resolve("etc/apt/apt.conf")
aptConf.parentFile?.mkdirs()
// Create directories needed by apt and dpkg
dir.resolve("etc/apt/apt.conf.d").mkdirs()
dir.resolve("etc/apt/preferences.d").mkdirs()
dir.resolve("etc/dpkg/dpkg.cfg.d").mkdirs()
dir.resolve("var/cache/apt").mkdirs()
dir.resolve("var/log/apt").mkdirs()
aptConf.writeText(
"""
Dir "/";
Dir::State "$prefix/var/lib/apt/";
Dir::State::status "$prefix/var/lib/dpkg/status";
Dir::Cache "$prefix/var/cache/apt/";
Dir::Log "$prefix/var/log/apt/";
Dir::Etc "$prefix/etc/apt/";
Dir::Etc::SourceList "$prefix/etc/apt/sources.list";
Dir::Etc::SourceParts "";
Dir::Bin::dpkg "$prefix/bin/dpkg";
Dir::Bin::Methods "$prefix/lib/apt/methods/";
Dir::Bin::apt-key "$prefix/bin/apt-key";
Dpkg::Options:: "--force-configure-any";
Dpkg::Options:: "--force-bad-path";
Dpkg::Options:: "--instdir=$prefix";
Dpkg::Options:: "--admindir=$prefix/var/lib/dpkg";
Acquire::AllowInsecureRepositories "true";
APT::Get::AllowUnauthenticated "true";
""".trimIndent(),
)
}
// --- Setup helpers ---
private fun setupDirectories() {
homeDir.mkdirs()
tmpDir.mkdirs()
wwwDir.mkdirs()
File(homeDir, ".openclaw-android/patches").mkdirs()
}
private fun setupTermuxExec() {
// libtermux-exec.so is included in bootstrap.
// It intercepts execve() to rewrite /data/data/com.termux paths (§2.2.4).
// However, it does NOT intercept open()/opendir() calls, so binaries with
// hardcoded config paths (dpkg, bash) need wrapper scripts.
AppLogger.i(TAG, "Bootstrap installed at ${prefixDir.absolutePath}")
// Create dpkg wrapper that handles confdir permission errors.
// The bootstrap dpkg has /data/data/com.termux/.../etc/dpkg/ hardcoded.
// Since libtermux-exec only rewrites execve() paths, not open() paths,
// dpkg fails on opendir() of the old com.termux config directory.
// The wrapper captures stderr and returns success if confdir is the only error.
val dpkgBin = File(prefixDir, "bin/dpkg")
val dpkgReal = File(prefixDir, "bin/dpkg.real")
if (dpkgBin.exists() && (!dpkgReal.exists() || !dpkgBin.readText().contains("export PATH"))) {
if (!dpkgReal.exists()) dpkgBin.renameTo(dpkgReal)
val d = "$" // dollar sign for shell script
val realPath = dpkgReal.absolutePath
val wrapperContent = """#!/bin/bash
# dpkg wrapper: set PATH so dpkg can find sh, tar, rm, dpkg-deb etc.
# Also suppresses confdir errors from hardcoded com.termux paths.
export PATH="$realPath/../:$realPath/../applets:${d}PATH"
"$realPath" "$d@"
_rc=$d?
if [ ${d}_rc -eq 2 ]; then exit 0; fi
exit ${d}_rc
"""
dpkgBin.writeText(wrapperContent)
dpkgBin.setExecutable(true)
}
}
/**
* Copy post-setup.sh and glibc-compat.js to home dir.
* post-setup.sh: try GitHub first, fall back to bundled asset.
* glibc-compat.js: always use bundled asset.
*/
private fun copyAssetScripts() {
val ocaDir = File(homeDir, ".openclaw-android")
ocaDir.mkdirs()
File(ocaDir, "patches").mkdirs()
val postSetup = File(ocaDir, "post-setup.sh")
copyPostSetupScript(postSetup)
copyBundledAsset("glibc-compat.js", File(ocaDir, "patches/glibc-compat.js"))
}
private fun copyPostSetupScript(target: File) {
val url = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/post-setup.sh"
try {
java.net.URL(url).openStream().use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
target.setExecutable(true)
AppLogger.i(TAG, "post-setup.sh downloaded from GitHub")
return
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to download post-setup.sh, using bundled fallback", e)
}
try {
context.assets.open("post-setup.sh").use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
target.setExecutable(true)
AppLogger.i(TAG, "post-setup.sh copied from bundled assets")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to copy bundled post-setup.sh", e)
}
}
private fun copyBundledAsset(
assetName: String,
target: File,
) {
try {
context.assets.open(assetName).use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
AppLogger.i(TAG, "$assetName copied from bundled assets")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to copy $assetName", e)
}
}
// Runtime packages are installed by post-setup.sh in the terminal
/**
* Apply script update on APK version upgrade:
* - Overwrites post-setup.sh and glibc-compat.js from latest assets
* - Installs/updates oa CLI from GitHub so users can run `oa --update`
*/
fun applyScriptUpdate() {
if (!isInstalled()) return
copyAssetScripts()
syncWwwFromAssets()
installOaCli()
AppLogger.i(TAG, "Script update applied")
}
/**
* Copy bundled assets/www into wwwDir, overwriting any existing files.
* Called on first install and on APK version upgrade to ensure the UI is always current.
*/
fun syncWwwFromAssets() {
try {
wwwDir.mkdirs()
copyAssetDir("www", wwwDir)
AppLogger.i(TAG, "www synced from assets to ${wwwDir.absolutePath}")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to sync www from assets", e)
}
}
private fun copyAssetDir(
assetPath: String,
targetDir: File,
) {
val entries = context.assets.list(assetPath) ?: return
targetDir.mkdirs()
for (entry in entries) {
copyAssetEntry("$assetPath/$entry", File(targetDir, entry))
}
}
private fun copyAssetEntry(
assetPath: String,
targetFile: File,
) {
val children = context.assets.list(assetPath)
if (!children.isNullOrEmpty()) {
copyAssetDir(assetPath, targetFile)
} else {
context.assets.open(assetPath).use { input ->
targetFile.outputStream().use { output -> input.copyTo(output) }
}
}
}
fun installOaCli() {
val oaBin = File(prefixDir, "bin/oa")
val oaUrl = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/oa.sh"
try {
java.net.URL(oaUrl).openStream().use { input ->
oaBin.outputStream().use { output -> input.copyTo(output) }
}
oaBin.setExecutable(true)
AppLogger.i(TAG, "oa CLI installed at ${oaBin.absolutePath}")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to install oa CLI", e)
}
}
}
private object Os {
@JvmStatic
fun symlink(
target: String,
path: String,
) {
android.system.Os.symlink(target, path)
}
}
@@ -0,0 +1,78 @@
package com.openclaw.android
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Shell command execution via ProcessBuilder (§2.2.5).
* Uses Termux bootstrap environment for all commands.
*/
object CommandRunner {
data class CommandResult(
val exitCode: Int,
val stdout: String,
val stderr: String,
)
/**
* Run a command synchronously with timeout.
* Returns stdout/stderr and exit code.
*/
fun runSync(
command: String,
env: Map<String, String>,
workDir: File,
timeoutMs: Long = 5_000,
): CommandResult =
try {
val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh"
val pb = ProcessBuilder(shell, "-c", command)
pb.environment().clear()
pb.environment().putAll(env)
pb.directory(workDir)
pb.redirectErrorStream(false)
val process = pb.start()
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
val exited = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
if (!exited) {
process.destroyForcibly()
CommandResult(-1, stdout, "Command timed out after ${timeoutMs}ms")
} else {
CommandResult(process.exitValue(), stdout, stderr)
}
} catch (e: Exception) {
CommandResult(-1, "", e.message ?: "Unknown error")
}
/**
* Run a command asynchronously, streaming output line-by-line via callback.
*/
suspend fun runStreaming(
command: String,
env: Map<String, String>,
workDir: File,
onOutput: (String) -> Unit,
) = withContext(Dispatchers.IO) {
try {
val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh"
val pb = ProcessBuilder(shell, "-c", command)
pb.environment().clear()
pb.environment().putAll(env)
pb.directory(workDir)
pb.redirectErrorStream(true)
val process = pb.start()
process.inputStream.bufferedReader().forEachLine { line ->
onOutput(line)
}
process.waitFor()
} catch (e: Exception) {
onOutput("Error: ${e.message}")
}
}
}
@@ -0,0 +1,83 @@
package com.openclaw.android
import android.content.Context
import java.io.File
/**
* Builds the complete process environment for Termux bootstrap (§2.2.5).
* Based on AnyClaw CodexServerManager.kt pattern.
*/
object EnvironmentBuilder {
fun build(context: Context): Map<String, String> = build(context.filesDir)
fun build(filesDir: File): Map<String, String> {
val prefix = File(filesDir, "usr")
val home = File(filesDir, "home")
val tmp = File(filesDir, "tmp")
return buildMap {
// Core paths
put("PREFIX", prefix.absolutePath)
put("HOME", home.absolutePath)
put("TMPDIR", tmp.absolutePath)
val nodeBin = "${home.absolutePath}/.openclaw-android/node/bin"
val localBin = "${home.absolutePath}/.local/bin"
val prefixBin = "${prefix.absolutePath}/bin"
put("PATH", "$nodeBin:$localBin:$prefixBin:$prefixBin/applets")
put("LD_LIBRARY_PATH", "${prefix.absolutePath}/lib")
// libtermux-exec path conversion (§2.2.4)
// The bootstrap binaries have hardcoded /data/data/com.termux/... paths.
// libtermux-exec intercepts file operations and rewrites them.
// Only set LD_PRELOAD if the library actually exists — otherwise
// the dynamic linker refuses to start any process.
val termuxExecLib = File(prefix, "lib/libtermux-exec.so")
if (termuxExecLib.exists()) {
put("LD_PRELOAD", termuxExecLib.absolutePath)
}
put("TERMUX__PREFIX", prefix.absolutePath)
put("TERMUX_PREFIX", prefix.absolutePath)
put("TERMUX__ROOTFS", filesDir.absolutePath)
// Tell libtermux-exec where the current app data dir is
val appDataDir = filesDir.parentFile?.absolutePath ?: filesDir.absolutePath
put("TERMUX_APP__DATA_DIR", appDataDir)
// Tell libtermux-exec the OLD path to match against and rewrite
put("TERMUX_APP__LEGACY_DATA_DIR", "/data/data/com.termux")
// put("TERMUX_EXEC__LOG_LEVEL", "2") // Uncomment to debug libtermux-exec
// apt/dpkg (§2.2.3)
put("APT_CONFIG", "${prefix.absolutePath}/etc/apt/apt.conf")
put("DPKG_ADMINDIR", "${prefix.absolutePath}/var/lib/dpkg")
put("DPKG_ROOT", prefix.absolutePath)
// SSL (libgnutls.so hardcoded path workaround)
put("SSL_CERT_FILE", "${prefix.absolutePath}/etc/tls/cert.pem")
put("CURL_CA_BUNDLE", "${prefix.absolutePath}/etc/tls/cert.pem")
put("GIT_SSL_CAINFO", "${prefix.absolutePath}/etc/tls/cert.pem")
// Git (system gitconfig has hardcoded com.termux path)
put("GIT_CONFIG_NOSYSTEM", "1")
// Git exec path (git looks for helpers like git-remote-https here)
put("GIT_EXEC_PATH", "${prefix.absolutePath}/libexec/git-core")
// Git template dir (hardcoded /data/data/com.termux path workaround)
put("GIT_TEMPLATE_DIR", "${prefix.absolutePath}/share/git-core/templates")
// Locale and terminal
put("LANG", "en_US.UTF-8")
put("TERM", "xterm-256color")
// Android-specific
put("ANDROID_DATA", "/data")
put("ANDROID_ROOT", "/system")
// OpenClaw platform
put("OA_GLIBC", "1")
put("CONTAINER", "1")
put("CLAWDHUB_WORKDIR", "${home.absolutePath}/.openclaw/workspace")
put("CPATH", "${prefix.absolutePath}/include/glib-2.0:${prefix.absolutePath}/lib/glib-2.0/include")
}
}
}
@@ -0,0 +1,34 @@
package com.openclaw.android
import android.webkit.WebView
import com.google.gson.Gson
/**
* Kotlin → WebView event dispatch (§2.8).
* Uses evaluateJavascript + CustomEvent pattern.
*
* WebView side (index.html) must include:
* window.__oc = {
* emit(type, data) {
* window.dispatchEvent(new CustomEvent(`native:${type}`, { detail: data }));
* }
* };
*/
class EventBridge(
private val webView: WebView,
) {
private val gson = Gson()
/**
* Emit a named event to the WebView.
* React side listens via: useNativeEvent('type', handler)
*/
fun emit(
type: String,
data: Any?,
) {
val json = gson.toJson(data ?: emptyMap<String, Any>())
val script = "window.__oc&&window.__oc.emit('$type',$json)"
webView.post { webView.evaluateJavascript(script, null) }
}
}
@@ -0,0 +1,739 @@
package com.openclaw.android
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
import android.webkit.JavascriptInterface
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* WebView → Kotlin bridge via @JavascriptInterface (§2.6).
* All methods callable from JavaScript as window.OpenClaw.<method>().
* All return values are JSON strings. Async operations use EventBridge (§2.8).
*/
@Suppress("TooManyFunctions", "LargeClass") // WebView bridge — single facade by design
class JsBridge(
private val activity: MainActivity,
private val sessionManager: TerminalSessionManager,
private val bootstrapManager: BootstrapManager,
private val eventBridge: EventBridge,
) {
private val gson = Gson()
companion object {
private const val TAG = "JsBridge"
private const val SHELL_INIT_DELAY_MS = 500L
private const val COMMAND_TIMEOUT_MS = 5_000L
private const val PLATFORM_LIST_TIMEOUT_MS = 10_000L
private const val API_TIMEOUT_MS = 5000
private const val PROGRESS_START = 0f
private const val PROGRESS_HALF = 0.5f
private const val PROGRESS_DONE = 1f
private const val PROGRESS_DOWNLOAD = 0.2f
private const val PROGRESS_EXTRACT = 0.6f
private const val PROGRESS_APPLY = 0.9f
private const val PROGRESS_BOOTSTRAP_START = 0.1f
}
/**
* Launch a coroutine on Dispatchers.IO with error handling.
* Catches all exceptions to prevent app crashes from unhandled coroutine failures.
* Errors are logged and emitted to the WebView via EventBridge.
*/
private fun launchWithErrorHandling(
errorEventType: String = "error",
errorContext: Map<String, Any?> = emptyMap(),
block: suspend CoroutineScope.() -> Unit,
) {
val handler =
CoroutineExceptionHandler { _, throwable ->
AppLogger.e(TAG, "Coroutine error [$errorEventType]: ${throwable.message}", throwable)
eventBridge.emit(
errorEventType,
errorContext +
mapOf(
"error" to (throwable.message ?: "Unknown error"),
"progress" to PROGRESS_START,
"message" to "Error: ${throwable.message}",
),
)
}
CoroutineScope(Dispatchers.IO + handler).launch(block = block)
}
// ═══════════════════════════════════════════
// Terminal domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun showTerminal() {
// Create session if none exists (e.g., after first-time setup)
if (sessionManager.activeSession == null) {
val session = sessionManager.createSession()
if (bootstrapManager.needsPostSetup()) {
val script = bootstrapManager.postSetupScript.absolutePath
// Delay write until after attachSession() initializes the shell process.
// createSession() posts attachSession() via runOnUiThread; writing before
// that runs silently drops the data (mShellPid is still 0).
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
session.write("bash $script\n")
}, SHELL_INIT_DELAY_MS)
}
}
activity.showTerminal()
}
@JavascriptInterface
fun showWebView() = activity.showWebView()
@JavascriptInterface
fun createSession(): String {
val session = sessionManager.createSession()
return gson.toJson(mapOf("id" to session.mHandle, "name" to (session.title ?: "Terminal")))
}
@JavascriptInterface
fun switchSession(id: String) =
activity.runOnUiThread {
sessionManager.switchSession(id)
}
@JavascriptInterface
fun closeSession(id: String) {
sessionManager.closeSession(id)
}
@JavascriptInterface
fun getTerminalSessions(): String = gson.toJson(sessionManager.getSessionsInfo())
@JavascriptInterface
fun writeToTerminal(
id: String,
data: String,
) {
val session =
if (id.isBlank()) {
sessionManager.activeSession
} else {
sessionManager.getSessionById(id) ?: sessionManager.activeSession
}
session?.write(data)
}
@JavascriptInterface
fun runInNewSession(command: String) {
val session = sessionManager.createSession()
activity.showTerminal()
// Delay write until shell process initializes (same pattern as showTerminal post-setup)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
session.write(command)
}, SHELL_INIT_DELAY_MS)
}
// ═══════════════════════════════════════════
// Setup domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getSetupStatus(): String = gson.toJson(bootstrapManager.getStatus())
@JavascriptInterface
fun getBootstrapStatus(): String =
gson.toJson(
mapOf(
"installed" to bootstrapManager.isInstalled(),
"prefixPath" to bootstrapManager.prefixDir.absolutePath,
),
)
@JavascriptInterface
fun startSetup() {
launchWithErrorHandling(
errorEventType = "setup_progress",
errorContext = mapOf("progress" to PROGRESS_START),
) {
bootstrapManager.startSetup { progress, message ->
eventBridge.emit(
"setup_progress",
mapOf("progress" to progress, "message" to message),
)
}
}
}
@JavascriptInterface
fun saveToolSelections(json: String) {
val configFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/tool-selections.conf")
configFile.parentFile?.mkdirs()
val selections = gson.fromJson(json, Map::class.java) as? Map<*, *> ?: return
val lines =
selections.entries.joinToString("\n") { (key, value) ->
val envKey = "INSTALL_${(key as String).uppercase().replace("-", "_")}"
"$envKey=$value"
}
configFile.writeText(lines + "\n")
}
// ═══════════════════════════════════════════
// Platform domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getAvailablePlatforms(): String {
// Read from cached config.json or return defaults
return gson.toJson(
listOf(
mapOf(
"id" to "openclaw",
"name" to "OpenClaw",
"icon" to "/openclaw.svg",
"desc" to "AI agent platform",
),
),
)
}
@JavascriptInterface
fun getInstalledPlatforms(): String {
// Check which platforms are installed via npm/filesystem
val env = EnvironmentBuilder.build(activity)
val result =
CommandRunner.runSync(
"npm list -g --depth=0 --json 2>/dev/null",
env,
bootstrapManager.prefixDir,
timeoutMs = PLATFORM_LIST_TIMEOUT_MS,
)
return result.stdout.ifBlank { "[]" }
}
@JavascriptInterface
fun installPlatform(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."),
)
val env = EnvironmentBuilder.build(activity)
CommandRunner.runStreaming(
"npm install -g $id@latest --ignore-scripts",
env,
bootstrapManager.homeDir,
) { output ->
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output),
)
}
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_DONE, "message" to "$id installed"),
)
}
}
@JavascriptInterface
fun uninstallPlatform(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
CommandRunner.runSync("npm uninstall -g $id", env, bootstrapManager.homeDir)
}
}
@JavascriptInterface
fun switchPlatform(id: String) {
// Write active platform marker
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
markerFile.parentFile?.mkdirs()
markerFile.writeText(id)
}
@JavascriptInterface
fun getActivePlatform(): String {
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
val id = if (markerFile.exists()) markerFile.readText().trim() else "openclaw"
return gson.toJson(mapOf("id" to id, "name" to id.replaceFirstChar { it.uppercase() }))
}
// ═══════════════════════════════════════════
// Tools domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getInstalledTools(): String {
val env = EnvironmentBuilder.build(activity)
val prefix = bootstrapManager.prefixDir.absolutePath
val tools = mutableListOf<Map<String, String>>()
// Termux packages - check binary path
val pkgChecks =
mapOf(
"tmux" to "$prefix/bin/tmux",
"ttyd" to "$prefix/bin/ttyd",
"dufs" to "$prefix/bin/dufs",
"openssh-server" to "$prefix/bin/sshd",
"android-tools" to "$prefix/bin/adb",
"code-server" to "$prefix/bin/code-server",
)
for ((id, path) in pkgChecks) {
if (java.io.File(path).exists()) {
tools.add(mapOf("id" to id, "name" to id, "version" to "installed"))
}
}
// Chromium - check multiple possible paths
if (java.io.File("$prefix/bin/chromium-browser").exists() || java.io.File("$prefix/bin/chromium").exists()) {
tools.add(mapOf("id" to "chromium", "name" to "chromium", "version" to "installed"))
}
// npm global packages - check binary file in node bin
val nodeBin = "${bootstrapManager.homeDir.absolutePath}/.openclaw-android/node/bin"
val npmBinChecks =
mapOf(
"claude-code" to "$nodeBin/claude",
"gemini-cli" to "$nodeBin/gemini",
"codex-cli" to "$nodeBin/codex",
"opencode" to "$nodeBin/opencode",
)
for ((id, path) in npmBinChecks) {
if (java.io.File(path).exists()) {
tools.add(mapOf("id" to id, "name" to id, "version" to "installed"))
}
}
return gson.toJson(tools)
}
@JavascriptInterface
fun installTool(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
val prefix = bootstrapManager.prefixDir.absolutePath
val aptGet =
"DEBIAN_FRONTEND=noninteractive $prefix/bin/apt-get" +
" -y -o Acquire::AllowInsecureRepositories=true" +
" -o APT::Get::AllowUnauthenticated=true"
val cmd =
when (id) {
// Termux packages (apt-get)
"tmux", "ttyd", "dufs", "openssh-server", "android-tools" ->
"$aptGet install ${if (id == "openssh-server") "openssh" else id}"
// Chromium (from x11-repo)
"chromium" ->
"$aptGet install chromium"
// code-server (custom)
"code-server" ->
"npm install -g code-server"
// npm-based AI CLI tools
"claude-code" ->
"npm install -g @anthropic-ai/claude-code"
"gemini-cli" ->
"npm install -g @google/gemini-cli"
"codex-cli" ->
"npm install -g @mmmbuto/codex-cli-termux"
// OpenCode (Bun-based) — requires proot + ld.so concatenation
"opencode" ->
"curl -fsSL https://raw.githubusercontent.com/" +
"AidanPark/openclaw-android/main/scripts/install-opencode.sh | bash"
else -> "echo 'Unknown tool: $id'"
}
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."),
)
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output),
)
}
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_DONE, "message" to "$id installed"),
)
}
}
@JavascriptInterface
fun uninstallTool(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
val cmd =
when (id) {
"tmux", "ttyd", "dufs", "openssh-server", "android-tools", "chromium" -> {
val pkg = if (id == "openssh-server") "openssh" else id
"${bootstrapManager.prefixDir.absolutePath}/bin/apt-get remove -y $pkg"
}
"code-server" ->
"npm uninstall -g code-server"
"claude-code" ->
"npm uninstall -g @anthropic-ai/claude-code"
"gemini-cli" ->
"npm uninstall -g @google/gemini-cli"
"codex-cli" ->
"npm uninstall -g @mmmbuto/codex-cli-termux"
"opencode" ->
"rm -f \$PREFIX/bin/opencode" +
" \$HOME/.openclaw-android/bin/ld.so.opencode" +
" \$PREFIX/tmp/ld.so.opencode" +
" && rm -rf \$HOME/.config/opencode"
else -> "echo 'Unknown tool: $id'"
}
CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
}
}
@JavascriptInterface
fun isToolInstalled(id: String): String {
val prefix = bootstrapManager.prefixDir.absolutePath
val env = EnvironmentBuilder.build(activity)
val exists =
when (id) {
"openssh-server" -> java.io.File("$prefix/bin/sshd").exists()
"tmux", "ttyd", "dufs", "android-tools" -> {
val bin = if (id == "android-tools") "adb" else id
java.io.File("$prefix/bin/$bin").exists()
}
"chromium" -> {
java.io.File("$prefix/bin/chromium-browser").exists() ||
java.io.File("$prefix/bin/chromium").exists()
}
"code-server" -> java.io.File("$prefix/bin/code-server").exists()
else -> {
// npm global packages: check via command -v
val result =
CommandRunner.runSync(
"command -v $id 2>/dev/null",
env,
bootstrapManager.prefixDir,
timeoutMs = COMMAND_TIMEOUT_MS,
)
result.stdout.trim().isNotEmpty()
}
}
return gson.toJson(mapOf("installed" to exists))
}
// ═══════════════════════════════════════════
// Commands domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun runCommand(cmd: String): String {
val env = EnvironmentBuilder.build(activity)
val result = CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
return gson.toJson(result)
}
@JavascriptInterface
fun runCommandAsync(
callbackId: String,
cmd: String,
) {
launchWithErrorHandling(
errorEventType = "command_output",
errorContext = mapOf("callbackId" to callbackId, "done" to true),
) {
val env = EnvironmentBuilder.build(activity)
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
eventBridge.emit(
"command_output",
mapOf("callbackId" to callbackId, "data" to output, "done" to false),
)
}
eventBridge.emit(
"command_output",
mapOf("callbackId" to callbackId, "data" to "", "done" to true),
)
}
}
// ═══════════════════════════════════════════
// Updates domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun checkForUpdates(): String {
// Compare local versions with config.json remote versions
val updates = mutableListOf<Map<String, String>>()
try {
val configFile =
java.io.File(
activity.filesDir,
"usr/share/openclaw-app/config.json",
)
if (configFile.exists()) {
val config = gson.fromJson(configFile.readText(), Map::class.java) as? Map<*, *>
val localWwwVersion =
activity
.getSharedPreferences("openclaw", 0)
.getString("www_version", "0.0.0")
val remoteWwwVersion = ((config?.get("www") as? Map<*, *>)?.get("version") as? String)
if (remoteWwwVersion != null && remoteWwwVersion != localWwwVersion) {
updates.add(
mapOf(
"component" to "www",
"currentVersion" to (localWwwVersion ?: "0.0.0"),
"newVersion" to remoteWwwVersion,
),
)
}
}
} catch (_: Exception) {
// ignore parse errors
}
return gson.toJson(updates)
}
@JavascriptInterface
fun getApkUpdateInfo(): String {
return try {
val url = java.net.URL("https://api.github.com/repos/AidanPark/openclaw-android/releases/latest")
val conn = url.openConnection() as java.net.HttpURLConnection
conn.connectTimeout = API_TIMEOUT_MS
conn.readTimeout = API_TIMEOUT_MS
conn.setRequestProperty("Accept", "application/vnd.github+json")
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val release = gson.fromJson(body, Map::class.java) as? Map<*, *>
val tagName =
release?.get("tagName") as? String
?: release?.get("tag_name") as? String
?: return gson.toJson(mapOf("error" to "no tag"))
val latestVersion = tagName.trimStart('v')
val currentVersion =
activity.packageManager
.getPackageInfo(activity.packageName, 0)
.versionName ?: "0.0.0"
gson.toJson(
mapOf(
"currentVersion" to currentVersion,
"latestVersion" to latestVersion,
"updateAvailable" to (compareVersions(latestVersion, currentVersion) > 0),
),
)
} catch (e: Exception) {
gson.toJson(mapOf("error" to e.message))
}
}
@JavascriptInterface
fun applyUpdate(component: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to component),
) {
emitProgress(component, PROGRESS_START, "Updating $component...")
when (component) {
"www" -> updateWww()
"bootstrap" -> updateBootstrap()
"scripts" -> emitProgress("scripts", PROGRESS_HALF, "Scripts are updated with bootstrap")
}
emitProgress(component, PROGRESS_DONE, "$component updated")
}
}
private fun emitProgress(
target: String,
progress: Float,
message: String,
) {
eventBridge.emit(
"install_progress",
mapOf(
"target" to target,
"progress" to progress,
"message" to message,
),
)
}
private suspend fun updateWww() {
try {
val url = UrlResolver(activity).getWwwUrl()
val stagingWww = java.io.File(activity.cacheDir, "www-staging")
stagingWww.deleteRecursively()
stagingWww.mkdirs()
emitProgress("www", PROGRESS_DOWNLOAD, "Downloading...")
val zipFile = java.io.File(activity.cacheDir, "www.zip")
java.net.URL(url).openStream().use { input ->
zipFile.outputStream().use { output -> input.copyTo(output) }
}
emitProgress("www", PROGRESS_EXTRACT, "Extracting...")
extractZipToDir(zipFile, stagingWww)
zipFile.delete()
emitProgress("www", PROGRESS_APPLY, "Applying...")
val wwwDir = bootstrapManager.wwwDir
wwwDir.deleteRecursively()
wwwDir.parentFile?.mkdirs()
stagingWww.renameTo(wwwDir)
activity.runOnUiThread { activity.reloadWebView() }
} catch (e: Exception) {
emitProgress("www", PROGRESS_START, "Update failed: ${e.message}")
}
}
private suspend fun updateBootstrap() {
try {
emitProgress("bootstrap", PROGRESS_BOOTSTRAP_START, "Downloading bootstrap...")
bootstrapManager.startSetup { progress, message ->
emitProgress("bootstrap", progress, message)
}
} catch (e: Exception) {
emitProgress("bootstrap", PROGRESS_START, "Update failed: ${e.message}")
}
}
private fun extractZipToDir(
zipFile: java.io.File,
targetDir: java.io.File,
) {
java.util.zip.ZipInputStream(zipFile.inputStream()).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
extractZipEntry(zis, entry, targetDir)
entry = zis.nextEntry
}
}
}
private fun extractZipEntry(
zis: java.util.zip.ZipInputStream,
entry: java.util.zip.ZipEntry,
targetDir: java.io.File,
) {
val destFile = java.io.File(targetDir, entry.name)
if (entry.isDirectory) {
destFile.mkdirs()
} else {
destFile.parentFile?.mkdirs()
destFile.outputStream().use { out -> zis.copyTo(out) }
}
}
// ═══════════════════════════════════════════
// System domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getAppInfo(): String {
val pInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
return gson.toJson(
mapOf(
"versionName" to (pInfo.versionName ?: "unknown"),
"versionCode" to pInfo.versionCode,
"packageName" to activity.packageName,
),
)
}
@JavascriptInterface
fun getBatteryOptimizationStatus(): String {
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
return gson.toJson(
mapOf("isIgnoring" to pm.isIgnoringBatteryOptimizations(activity.packageName)),
)
}
@JavascriptInterface
fun requestBatteryOptimizationExclusion() {
activity.runOnUiThread {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivity(intent)
}
}
@JavascriptInterface
fun openSystemSettings(page: String) {
activity.runOnUiThread {
val intent =
when (page) {
"battery" -> Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
"app_info" ->
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${activity.packageName}")
}
else -> Intent(Settings.ACTION_SETTINGS)
}
activity.startActivity(intent)
}
}
@JavascriptInterface
fun copyToClipboard(text: String) {
activity.runOnUiThread {
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text))
}
}
@JavascriptInterface
fun getStorageInfo(): String {
val filesDir = activity.filesDir
val totalSpace = filesDir.totalSpace
val freeSpace = filesDir.freeSpace
val bootstrapSize = bootstrapManager.prefixDir.walkTopDown().sumOf { it.length() }
val wwwSize = bootstrapManager.wwwDir.walkTopDown().sumOf { it.length() }
return gson.toJson(
mapOf(
"totalBytes" to totalSpace,
"freeBytes" to freeSpace,
"bootstrapBytes" to bootstrapSize,
"wwwBytes" to wwwSize,
),
)
}
@JavascriptInterface
fun clearCache() {
activity.cacheDir.deleteRecursively()
activity.cacheDir.mkdirs()
}
@JavascriptInterface
fun openUrl(url: String) {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
}
/** Returns positive if a > b, negative if a < b, 0 if equal (semver: major.minor.patch) */
private fun compareVersions(
a: String,
b: String,
): Int {
val aParts = a.split(".").map { it.toIntOrNull() ?: 0 }
val bParts = b.split(".").map { it.toIntOrNull() ?: 0 }
val len = maxOf(aParts.size, bParts.size)
for (i in 0 until len) {
val diff = (aParts.getOrElse(i) { 0 }) - (bParts.getOrElse(i) { 0 })
if (diff != 0) return diff
}
return 0
}
}
@@ -0,0 +1,719 @@
package com.openclaw.android
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Typeface
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.openclaw.android.databinding.ActivityMainBinding
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalViewClient
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
private const val DEFAULT_TEXT_SIZE = 32
private const val MIN_TEXT_SIZE = 8
private const val MAX_TEXT_SIZE = 32
private const val KEYBOARD_SHOW_DELAY_MS = 200L
private const val TAB_NAME_TEXT_SIZE = 12f
private const val TAB_CLOSE_TEXT_SIZE = 14f
private const val TAB_ADD_TEXT_SIZE = 18f
private const val TAB_MARGIN_DP = 2
private const val TAB_HPAD_DP = 10
private const val TAB_VPAD_DP = 4
private const val TAB_CLOSE_PAD_DP = 6
private const val TAB_ADD_PAD_DP = 12
private const val INDICATOR_HEIGHT_DP = 2
private const val INPUT_MODE_TYPE_NULL = 1
}
private lateinit var binding: ActivityMainBinding
lateinit var sessionManager: TerminalSessionManager
lateinit var bootstrapManager: BootstrapManager
lateinit var eventBridge: EventBridge
private lateinit var jsBridge: JsBridge
private var currentTextSize = DEFAULT_TEXT_SIZE
private var ctrlDown = false
private var altDown = false
private val terminalSessionClient = OpenClawSessionClient()
private val terminalViewClient = OpenClawViewClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bootstrapManager = BootstrapManager(this)
eventBridge = EventBridge(binding.webView)
sessionManager = TerminalSessionManager(this, terminalSessionClient, eventBridge)
jsBridge = JsBridge(this, sessionManager, bootstrapManager, eventBridge)
setupTerminalView()
setupWebView()
setupExtraKeys()
sessionManager.onSessionsChanged = { updateSessionTabs() }
startService(Intent(this, OpenClawService::class.java))
val isInstalled = bootstrapManager.isInstalled()
AppLogger.i(TAG, "Bootstrap installed: $isInstalled, needsPostSetup: ${bootstrapManager.needsPostSetup()}")
// Sync www assets and check for APK version upgrade
if (isInstalled) {
val prefs = getSharedPreferences("openclaw", 0)
val savedVersionCode = prefs.getInt("versionCode", 0)
val currentVersionCode = packageManager.getPackageInfo(packageName, 0).versionCode
// Always sync www from assets to pick up UI updates
bootstrapManager.syncWwwFromAssets()
// Ensure oa CLI is installed (network, run in background)
Thread { bootstrapManager.installOaCli() }.start()
if (currentVersionCode > savedVersionCode) {
AppLogger.i(TAG, "APK version upgrade detected: $savedVersionCode -> $currentVersionCode")
bootstrapManager.applyScriptUpdate()
prefs.edit().putInt("versionCode", currentVersionCode).apply()
}
}
if (isInstalled) {
showTerminal()
val session = sessionManager.createSession()
if (bootstrapManager.needsPostSetup()) {
AppLogger.i(TAG, "Running post-setup script in terminal")
val script = bootstrapManager.postSetupScript.absolutePath
binding.terminalView.post {
session.write("bash $script\n")
}
} else if (intent?.getBooleanExtra("from_boot", false) == true) {
val platformFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
val platformId = if (platformFile.exists()) platformFile.readText().trim() else "openclaw"
AppLogger.i(TAG, "Boot launch \u2014 auto-starting $platformId gateway")
binding.terminalView.post {
session.write("$platformId gateway\n")
}
}
}
// else: WebView shows setup UI, user triggers startSetup via JsBridge
}
// --- Terminal setup ---
private fun setupTerminalView() {
binding.terminalView.setTerminalViewClient(terminalViewClient)
binding.terminalView.setTextSize(currentTextSize)
}
// --- WebView setup ---
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
binding.webView.apply {
clearCache(true)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowFileAccess = true
@Suppress("DEPRECATION")
settings.allowFileAccessFromFileURLs = true
@Suppress("DEPRECATION")
settings.allowUniversalAccessFromFileURLs = true
settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE
addJavascriptInterface(jsBridge, "OpenClaw")
webViewClient =
object : WebViewClient() {
override fun onPageFinished(
view: WebView?,
url: String?,
) {
super.onPageFinished(view, url)
AppLogger.i(TAG, "WebView page loaded: $url")
// Page loaded successfully
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let {
AppLogger.d("WebViewJS", "${it.sourceId()}:${it.lineNumber()} ${it.message()}")
}
return true
}
}
}
val wwwDir = bootstrapManager.wwwDir
val url =
if (wwwDir.resolve("index.html").exists()) {
"file://${wwwDir.absolutePath}/index.html"
} else {
// Load bundled fallback setup page from assets
"file:///android_asset/www/index.html"
}
AppLogger.i(TAG, "Loading WebView URL: $url")
binding.webView.loadUrl(url)
}
fun reloadWebView() {
binding.webView.reload()
}
// --- View switching ---
fun showTerminal() {
runOnUiThread {
binding.webView.visibility = View.GONE
binding.terminalContainer.visibility = View.VISIBLE
binding.terminalView.requestFocus()
updateSessionTabs()
// Delay keyboard show — view must be focused and laid out first
binding.terminalView.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(binding.terminalView, InputMethodManager.SHOW_IMPLICIT)
}, KEYBOARD_SHOW_DELAY_MS)
}
}
fun showWebView() {
runOnUiThread {
binding.terminalContainer.visibility = View.GONE
binding.webView.visibility = View.VISIBLE
}
}
@Suppress("DEPRECATION")
override fun onBackPressed() {
if (binding.terminalContainer.visibility == View.VISIBLE) {
showWebView()
} else if (binding.webView.canGoBack()) {
binding.webView.goBack()
} else {
super.onBackPressed()
}
}
// --- Extra Keys ---
private val pressedAlpha = 0.5f
private val normalAlpha = 1.0f
@SuppressLint("ClickableViewAccessibility")
private fun setupExtraKeys() {
// Key code buttons — send key event on touch, never steal focus
val keyMap =
mapOf(
R.id.btnEsc to KeyEvent.KEYCODE_ESCAPE,
R.id.btnTab to KeyEvent.KEYCODE_TAB,
R.id.btnHome to KeyEvent.KEYCODE_MOVE_HOME,
R.id.btnEnd to KeyEvent.KEYCODE_MOVE_END,
R.id.btnUp to KeyEvent.KEYCODE_DPAD_UP,
R.id.btnDown to KeyEvent.KEYCODE_DPAD_DOWN,
R.id.btnLeft to KeyEvent.KEYCODE_DPAD_LEFT,
R.id.btnRight to KeyEvent.KEYCODE_DPAD_RIGHT,
)
for ((btnId, keyCode) in keyMap) {
setupExtraKeyTouch(findViewById(btnId)) { sendExtraKey(keyCode) }
}
// Character keys
setupExtraKeyTouch(findViewById(R.id.btnDash)) { sessionManager.activeSession?.write("-") }
setupExtraKeyTouch(findViewById(R.id.btnPipe)) { sessionManager.activeSession?.write("|") }
setupExtraKeyTouch(findViewById(R.id.btnPaste)) {
val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
val text =
clipboard.primaryClip
?.getItemAt(0)
?.coerceToText(this)
?.toString()
if (!text.isNullOrEmpty()) sessionManager.activeSession?.write(text)
}
// Modifier toggles — stay pressed until next key or toggled off
setupModifierTouch(findViewById(R.id.btnCtrl)) {
ctrlDown = !ctrlDown
ctrlDown
}
setupModifierTouch(findViewById(R.id.btnAlt)) {
altDown = !altDown
altDown
}
}
@SuppressLint("ClickableViewAccessibility")
private fun setupExtraKeyTouch(
btn: Button,
action: () -> Unit,
) {
btn.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
v.alpha = normalAlpha
if (event.action == MotionEvent.ACTION_UP) action()
}
}
true // consume — never let focus leave TerminalView
}
}
@SuppressLint("ClickableViewAccessibility")
private fun setupModifierTouch(
btn: Button,
toggle: () -> Boolean,
) {
btn.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_UP -> {
val active = toggle()
updateModifierButton(v as Button, active)
v.alpha = normalAlpha
}
MotionEvent.ACTION_CANCEL -> v.alpha = normalAlpha
}
true
}
}
private fun sendExtraKey(keyCode: Int) {
var metaState = 0
if (ctrlDown) metaState = metaState or (KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
if (altDown) metaState = metaState or (KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON)
val ev = KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState)
binding.terminalView.onKeyDown(keyCode, ev)
// Auto-deactivate modifiers after use
if (ctrlDown) {
ctrlDown = false
updateModifierButton(findViewById(R.id.btnCtrl), false)
}
if (altDown) {
altDown = false
updateModifierButton(findViewById(R.id.btnAlt), false)
}
}
private fun updateModifierButton(
button: Button,
active: Boolean,
) {
val bgColor = if (active) R.color.extraKeyActive else R.color.extraKeyDefault
val txtColor = if (active) R.color.extraKeyActiveText else R.color.extraKeyText
button.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, bgColor))
button.setTextColor(ContextCompat.getColor(this, txtColor))
}
// --- Session tab bar ---
private fun updateSessionTabs() {
val tabsLayout = binding.tabsLayout
tabsLayout.removeAllViews()
val sessions = sessionManager.getSessionsInfo()
val density = resources.displayMetrics.density
for (info in sessions) {
val tabWrapper = createSessionTab(info, density)
tabsLayout.addView(tabWrapper)
if (info["active"] as Boolean) {
binding.sessionTabBar.post {
binding.sessionTabBar.smoothScrollTo(tabWrapper.left, 0)
}
}
}
tabsLayout.addView(createAddButton(density))
}
private fun createSessionTab(
info: Map<String, Any>,
density: Float,
): LinearLayout {
val id = info["id"] as String
val name = info["name"] as String
val active = info["active"] as Boolean
val finished = info["finished"] as Boolean
val tabWrapper =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams =
LinearLayout
.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT,
).apply {
marginEnd = (TAB_MARGIN_DP * density).toInt()
}
val bgColor =
if (active) {
R.color.tabActiveBackground
} else {
R.color.tabInactiveBackground
}
setBackgroundColor(ContextCompat.getColor(this@MainActivity, bgColor))
isFocusable = false
isFocusableInTouchMode = false
}
val tabContent = createTabContent(name, active, finished, id, density)
val indicator = createTabIndicator(active, density)
tabWrapper.addView(tabContent)
tabWrapper.addView(indicator)
tabWrapper.setOnClickListener {
sessionManager.switchSession(id)
binding.terminalView.requestFocus()
}
return tabWrapper
}
private fun createTabContent(
name: String,
active: Boolean,
finished: Boolean,
id: String,
density: Float,
): LinearLayout {
val tabContent =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val hPad = (TAB_HPAD_DP * density).toInt()
val vPad = (TAB_VPAD_DP * density).toInt()
setPadding(hPad, vPad, (TAB_CLOSE_PAD_DP * density).toInt(), vPad)
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
0,
1f,
)
isFocusable = false
isFocusableInTouchMode = false
}
val nameView =
TextView(this).apply {
text = name
textSize = TAB_NAME_TEXT_SIZE
val textColor =
when {
finished -> R.color.tabTextFinished
active -> R.color.tabTextPrimary
else -> R.color.tabTextSecondary
}
setTextColor(ContextCompat.getColor(this@MainActivity, textColor))
if (finished) setTypeface(typeface, Typeface.ITALIC)
isSingleLine = true
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
val closeView =
TextView(this).apply {
text = "\u00D7"
textSize = TAB_CLOSE_TEXT_SIZE
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary))
setPadding((TAB_CLOSE_PAD_DP * density).toInt(), 0, 0, 0)
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
isFocusable = false
isFocusableInTouchMode = false
setOnClickListener { closeSessionFromTab(id) }
}
tabContent.addView(nameView)
tabContent.addView(closeView)
return tabContent
}
private fun createTabIndicator(
active: Boolean,
density: Float,
): View =
View(this).apply {
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(INDICATOR_HEIGHT_DP * density).toInt(),
)
val color = if (active) R.color.tabAccent else android.R.color.transparent
setBackgroundColor(ContextCompat.getColor(this@MainActivity, color))
}
private fun createAddButton(density: Float): TextView =
TextView(this).apply {
text = "+"
textSize = TAB_ADD_TEXT_SIZE
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary))
val pad = (TAB_ADD_PAD_DP * density).toInt()
setPadding(pad, 0, pad, 0)
gravity = Gravity.CENTER
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT,
)
isFocusable = false
isFocusableInTouchMode = false
setOnClickListener {
sessionManager.createSession()
binding.terminalView.requestFocus()
}
}
private fun closeSessionFromTab(handleId: String) {
if (sessionManager.sessionCount <= 1) {
// Create new session first, then close the old one
sessionManager.createSession()
}
sessionManager.closeSession(handleId)
binding.terminalView.requestFocus()
}
// --- Terminal session callbacks ---
private inner class OpenClawSessionClient : TerminalSessionClient {
override fun onTextChanged(changedSession: TerminalSession) {
binding.terminalView.onScreenUpdated()
}
override fun onTitleChanged(changedSession: TerminalSession) {
// Update tab bar when title changes
runOnUiThread { updateSessionTabs() }
// title changes propagated via EventBridge
}
override fun onSessionFinished(finishedSession: TerminalSession) {
sessionManager.onSessionFinished(finishedSession)
}
override fun onCopyTextToClipboard(
session: TerminalSession,
text: String,
) {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text))
}
override fun onPasteTextFromClipboard(session: TerminalSession?) {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = clipboard.primaryClip?.getItemAt(0)?.text ?: return
session?.write(text.toString())
}
override fun onBell(session: TerminalSession) = Unit
override fun onColorsChanged(session: TerminalSession) = Unit
override fun onTerminalCursorStateChange(state: Boolean) = Unit
override fun setTerminalShellPid(
session: TerminalSession,
pid: Int,
) = Unit
override fun getTerminalCursorStyle(): Int = 0
override fun logError(
tag: String,
message: String,
) {
AppLogger.e(tag, message)
}
override fun logWarn(
tag: String,
message: String,
) {
AppLogger.w(tag, message)
}
override fun logInfo(
tag: String,
message: String,
) {
AppLogger.i(tag, message)
}
override fun logDebug(
tag: String,
message: String,
) {
AppLogger.d(tag, message)
}
override fun logVerbose(
tag: String,
message: String,
) {
AppLogger.v(tag, message)
}
override fun logStackTraceWithMessage(
tag: String,
message: String,
e: Exception,
) {
AppLogger.e(tag, message, e)
}
override fun logStackTrace(
tag: String,
e: Exception,
) {
AppLogger.e(tag, "Exception", e)
}
}
// --- Terminal view callbacks ---
@Suppress("TooManyFunctions") // Interface implementation requires all methods
private inner class OpenClawViewClient : TerminalViewClient {
override fun onScale(scale: Float): Float {
val currentSize = currentTextSize
val newSize = if (scale > 1f) currentSize + 1 else currentSize - 1
val clamped = newSize.coerceIn(MIN_TEXT_SIZE, MAX_TEXT_SIZE)
currentTextSize = clamped
binding.terminalView.setTextSize(clamped)
return scale
}
override fun onSingleTapUp(e: MotionEvent) {
// Toggle soft keyboard on tap (same as Termux)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0)
}
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
override fun shouldEnforceCharBasedInput(): Boolean = true
override fun getInputMode(): Int = INPUT_MODE_TYPE_NULL
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
override fun isTerminalViewSelected(): Boolean = binding.terminalContainer.visibility == View.VISIBLE
override fun copyModeChanged(copyMode: Boolean) = Unit
override fun onKeyDown(
keyCode: Int,
e: KeyEvent,
session: TerminalSession,
): Boolean = false
override fun onKeyUp(
keyCode: Int,
e: KeyEvent,
): Boolean = false
override fun onLongPress(event: MotionEvent): Boolean = false
override fun readControlKey(): Boolean {
val v = ctrlDown
if (v) {
ctrlDown = false
runOnUiThread { updateModifierButton(findViewById(R.id.btnCtrl), false) }
}
return v
}
override fun readAltKey(): Boolean {
val v = altDown
if (v) {
altDown = false
runOnUiThread { updateModifierButton(findViewById(R.id.btnAlt), false) }
}
return v
}
override fun readShiftKey(): Boolean = false
override fun readFnKey(): Boolean = false
override fun onCodePoint(
codePoint: Int,
ctrlDown: Boolean,
session: TerminalSession,
): Boolean = false
override fun onEmulatorSet() = Unit
override fun logError(
tag: String,
message: String,
) {
AppLogger.e(tag, message)
}
override fun logWarn(
tag: String,
message: String,
) {
AppLogger.w(tag, message)
}
override fun logInfo(
tag: String,
message: String,
) {
AppLogger.i(tag, message)
}
override fun logDebug(
tag: String,
message: String,
) {
AppLogger.d(tag, message)
}
override fun logVerbose(
tag: String,
message: String,
) {
AppLogger.v(tag, message)
}
override fun logStackTraceWithMessage(
tag: String,
message: String,
e: Exception,
) {
AppLogger.e(tag, message, e)
}
override fun logStackTrace(
tag: String,
e: Exception,
) {
AppLogger.e(tag, "Exception", e)
}
}
}
@@ -0,0 +1,79 @@
package com.openclaw.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
/**
* Foreground Service that keeps terminal sessions alive when the app is in background.
* Uses START_STICKY to restart if killed. targetSdk 28 — no specialUse needed.
*/
class OpenClawService : Service() {
companion object {
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "openclaw_service"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIFICATION_ID, createNotification())
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Keeps OpenClaw terminal sessions running"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
val pendingIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val builder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,162 @@
package com.openclaw.android
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
/**
* Multi-terminal session management (§2.6, Phase 1 checklist).
* Uses TerminalView.attachSession() for session switching — one TerminalView, many sessions.
*/
class TerminalSessionManager(
private val activity: MainActivity,
private val sessionClient: TerminalSessionClient,
private val eventBridge: EventBridge,
) {
companion object {
private const val TAG = "SessionManager"
private const val TRANSCRIPT_ROWS = 2000
}
private val sessions = mutableListOf<TerminalSession>()
private var activeSessionIndex = -1
private val finishedSessionIds = mutableSetOf<String>()
var onSessionsChanged: (() -> Unit)? = null
val activeSession: TerminalSession?
get() = sessions.getOrNull(activeSessionIndex)
/**
* Create a new terminal session. Returns the session handle.
*/
fun createSession(): TerminalSession {
val env = EnvironmentBuilder.build(activity)
val prefix = env["PREFIX"] ?: ""
val homeDir = env["HOME"] ?: activity.filesDir.absolutePath
val tmpDir = env["TMPDIR"]
// Ensure HOME and TMP directories exist before starting the shell.
// Without this, chdir() fails if bootstrap hasn't been run yet.
java.io.File(homeDir).mkdirs()
tmpDir?.let { java.io.File(it).mkdirs() }
val shell =
if (java.io.File("$prefix/bin/bash").exists()) {
"$prefix/bin/bash"
} else if (java.io.File("$prefix/bin/sh").exists()) {
"$prefix/bin/sh"
} else {
"/system/bin/sh"
}
val session =
TerminalSession(
shell,
homeDir,
arrayOf<String>(),
env.entries.map { "${it.key}=${it.value}" }.toTypedArray(),
TRANSCRIPT_ROWS,
sessionClient,
)
sessions.add(session)
switchSession(sessions.size - 1)
eventBridge.emit(
"session_changed",
mapOf("id" to session.mHandle, "action" to "created"),
)
activity.runOnUiThread { onSessionsChanged?.invoke() }
AppLogger.i(TAG, "Created session ${session.mHandle} (total: ${sessions.size})")
return session
}
/**
* Switch to session by index.
*/
fun switchSession(index: Int) {
if (index < 0 || index >= sessions.size) return
activeSessionIndex = index
val session = sessions[index]
activity.runOnUiThread {
val terminalView = activity.findViewById<com.termux.view.TerminalView>(R.id.terminalView)
terminalView.attachSession(session)
terminalView.invalidate()
}
eventBridge.emit(
"session_changed",
mapOf("id" to session.mHandle, "action" to "switched"),
)
activity.runOnUiThread { onSessionsChanged?.invoke() }
}
/**
* Switch to session by handle ID.
*/
fun switchSession(handleId: String) {
val index = sessions.indexOfFirst { it.mHandle == handleId }
if (index >= 0) switchSession(index)
}
/**
* Find a session by handle ID.
*/
fun getSessionById(handleId: String): TerminalSession? = sessions.find { it.mHandle == handleId }
/**
* Close a session by handle ID.
*/
fun closeSession(handleId: String) {
val index = sessions.indexOfFirst { it.mHandle == handleId }
if (index < 0) return
finishedSessionIds.remove(handleId)
val session = sessions.removeAt(index)
session.finishIfRunning()
eventBridge.emit(
"session_changed",
mapOf("id" to handleId, "action" to "closed"),
)
// Switch to another session if available
if (sessions.isNotEmpty()) {
val newIndex = (index).coerceAtMost(sessions.size - 1)
switchSession(newIndex)
} else {
activeSessionIndex = -1
}
activity.runOnUiThread { onSessionsChanged?.invoke() }
AppLogger.i(TAG, "Closed session $handleId (remaining: ${sessions.size})")
}
/**
* Called when a session's process exits.
*/
fun onSessionFinished(session: TerminalSession) {
finishedSessionIds.add(session.mHandle)
eventBridge.emit(
"session_changed",
mapOf("id" to session.mHandle, "action" to "finished"),
)
activity.runOnUiThread { onSessionsChanged?.invoke() }
}
/**
* Get all sessions info for JsBridge.
*/
fun getSessionsInfo(): List<Map<String, Any>> =
sessions.mapIndexed { index, session ->
mapOf(
"id" to session.mHandle,
"name" to (session.title ?: "Session ${index + 1}"),
"active" to (index == activeSessionIndex),
"finished" to (session.mHandle in finishedSessionIds),
)
}
fun isSessionFinished(handleId: String): Boolean = handleId in finishedSessionIds
val sessionCount: Int get() = sessions.size
}
@@ -0,0 +1,84 @@
package com.openclaw.android
import android.content.Context
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.withTimeout
import java.io.File
import java.net.URL
/**
* Resolves download URLs with BuildConfig hardcoded fallback + config.json override (§2.9).
*
* Priority: cached config.json → remote config.json (5s timeout) → BuildConfig constants
*/
class UrlResolver(
private val context: Context,
) {
companion object {
private const val CONFIG_FETCH_TIMEOUT_MS = 5_000L
}
private val configFile =
File(
context.filesDir,
"usr/share/openclaw-app/config.json",
)
private val gson = Gson()
suspend fun getBootstrapUrl(): String {
val config = loadConfig()
return config?.bootstrap?.url ?: BuildConfig.BOOTSTRAP_URL
}
suspend fun getWwwUrl(): String {
val config = loadConfig()
return config?.www?.url ?: BuildConfig.WWW_URL
}
private suspend fun loadConfig(): RemoteConfig? {
// 1. Local cache
if (configFile.exists()) {
return try {
gson.fromJson(configFile.readText(), RemoteConfig::class.java)
} catch (_: Exception) {
null
}
}
// 2. Remote fetch (5s timeout)
return try {
withTimeout(CONFIG_FETCH_TIMEOUT_MS) {
val json = URL(BuildConfig.CONFIG_URL).readText()
configFile.parentFile?.mkdirs()
configFile.writeText(json)
gson.fromJson(json, RemoteConfig::class.java)
}
} catch (_: Exception) {
null // BuildConfig fallback
}
}
// --- Config data classes ---
data class RemoteConfig(
val version: Int?,
val bootstrap: ComponentConfig?,
val www: ComponentConfig?,
val platforms: List<PlatformConfig>?,
val features: Map<String, Boolean>?,
)
data class ComponentConfig(
val url: String,
val version: String?,
@SerializedName("sha256") val sha256: String?,
)
data class PlatformConfig(
val id: String,
val name: String,
val icon: String?,
val description: String?,
)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#55ffffff">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/extraKeyDefault" />
<corners android:radius="4dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#55ffffff">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/extraKeyActive" />
<corners android:radius="4dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#111827"
android:pathData="M54,54m-54,0a54,54,0,1,1,108,0a54,54,0,1,1,-108,0" />
<!-- 4 claw scratch marks, rotated diagonally -->
<group
android:pivotX="54"
android:pivotY="54"
android:rotation="-25">
<!-- Scratch 1 (outer left, shorter) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M31,22 C28,44,28,64,31,86 C34,64,34,44,31,22Z" />
<!-- Scratch 2 (inner left, longer) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M43,16 C40,40,40,64,43,92 C46,64,46,40,43,16Z" />
<!-- Scratch 3 (inner right, longer) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M55,16 C52,40,52,64,55,92 C58,64,58,40,55,16Z" />
<!-- Scratch 4 (outer right, shorter) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M67,22 C64,44,64,64,67,86 C70,64,70,44,67,22Z" />
</group>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,19V7H4V19H20ZM20,3C21.1,3 22,3.9 22,5V19C22,20.1 21.1,21 20,21H4C2.9,21 2,20.1 2,19V5C2,3.9 2.9,3 4,3H20ZM13,17V15H18V17H13ZM9.58,13L5.57,9L7,7.59L12.42,13L7,18.41L5.58,17L9.58,13Z" />
</vector>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/terminalBackground">
<!-- WebView — setup, dashboard, settings, platform selector -->
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible" />
<!-- Terminal container (TerminalView + Extra Keys bar) -->
<LinearLayout
android:id="@+id/terminalContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<!-- Session tab bar -->
<HorizontalScrollView
android:id="@+id/sessionTabBar"
android:layout_width="match_parent"
android:layout_height="36dp"
android:scrollbars="none"
android:background="@color/tabBarBackground"
android:focusable="false"
android:focusableInTouchMode="false">
<LinearLayout
android:id="@+id/tabsLayout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
</HorizontalScrollView>
<!-- Tab bar / terminal separator -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/tabBorder" />
<!-- TerminalView — native PTY terminal -->
<com.termux.view.TerminalView
android:id="@+id/terminalView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:focusable="true"
android:focusableInTouchMode="true" />
<!-- Extra Keys — two rows, no scrolling -->
<LinearLayout
android:id="@+id/extraKeysBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/extraKeysBackground">
<!-- Row 1: modifier & function keys -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="2dp"
android:paddingEnd="2dp">
<Button android:id="@+id/btnEsc" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="ESC" />
<Button android:id="@+id/btnCtrl" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="CTRL" />
<Button android:id="@+id/btnAlt" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="ALT" />
<Button android:id="@+id/btnTab" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="TAB" />
<Button android:id="@+id/btnHome" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="HOME" />
<Button android:id="@+id/btnEnd" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="END" />
</LinearLayout>
<!-- Row 2: arrow keys & common chars -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="2dp"
android:paddingEnd="2dp">
<Button android:id="@+id/btnLeft" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="◀" />
<Button android:id="@+id/btnUp" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▲" />
<Button android:id="@+id/btnDown" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▼" />
<Button android:id="@+id/btnRight" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▶" />
<Button android:id="@+id/btnDash" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="-" />
<Button android:id="@+id/btnPipe" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="|" />
<Button android:id="@+id/btnPaste" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="PST" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#1A1A2E</color>
<color name="colorPrimaryDark">#16213E</color>
<color name="colorAccent">#0F3460</color>
<color name="terminalBackground">#000000</color>
<color name="terminalForeground">#FFFFFF</color>
<color name="extraKeysBackground">#1a1a1a</color>
<color name="extraKeyDefault">#333333</color>
<color name="extraKeyActive">#58a6ff</color>
<color name="extraKeyText">#e0e0e0</color>
<color name="extraKeyActiveText">#ffffff</color>
<!-- Session tab bar -->
<color name="tabBarBackground">#161b22</color>
<color name="tabInactiveBackground">#21262d</color>
<color name="tabActiveBackground">#0d1117</color>
<color name="tabTextPrimary">#f0f6fc</color>
<color name="tabTextSecondary">#8b949e</color>
<color name="tabTextFinished">#484f58</color>
<color name="tabAccent">#58a6ff</color>
<color name="tabBorder">#30363d</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Claw</string>
<string name="notification_channel_id">openclaw_service</string>
<string name="notification_channel_name">OpenClaw Service</string>
<string name="notification_title">OpenClaw is running</string>
<string name="notification_text">Terminal session active</string>
</resources>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@color/terminalBackground</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
</style>
<style name="ExtraKeyButton" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">36dp</item>
<item name="android:minWidth">42dp</item>
<item name="android:paddingStart">8dp</item>
<item name="android:paddingEnd">8dp</item>
<item name="android:layout_marginStart">2dp</item>
<item name="android:layout_marginEnd">2dp</item>
<item name="android:textSize">12sp</item>
<item name="android:textColor">@color/extraKeyText</item>
<item name="backgroundTint">@color/extraKeyDefault</item>
<item name="android:textAllCaps">false</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="android:focusable">false</item>
<item name="android:focusableInTouchMode">false</item>
</style>
<style name="SessionTabClose">
<item name="android:layout_width">20dp</item>
<item name="android:layout_height">20dp</item>
<item name="android:textSize">14sp</item>
<item name="android:textColor">@color/tabTextSecondary</item>
<item name="android:background">?attr/selectableItemBackgroundBorderless</item>
<item name="android:gravity">center</item>
<item name="android:padding">0dp</item>
<item name="android:focusable">false</item>
<item name="android:focusableInTouchMode">false</item>
</style>
</resources>
@@ -0,0 +1,73 @@
package com.openclaw.android
import android.util.Log
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AppLoggerTest {
@BeforeEach
fun setup() {
mockkStatic(Log::class)
every { Log.v(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
every { Log.w(any(), any<String>(), any()) } returns 0
every { Log.e(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
}
@AfterEach
fun teardown() {
unmockkStatic(Log::class)
}
@Test
fun `v delegates to Log v`() {
AppLogger.v("TAG", "msg")
verify { Log.v("TAG", "msg") }
}
@Test
fun `d delegates to Log d`() {
AppLogger.d("TAG", "msg")
verify { Log.d("TAG", "msg") }
}
@Test
fun `i delegates to Log i`() {
AppLogger.i("TAG", "msg")
verify { Log.i("TAG", "msg") }
}
@Test
fun `w delegates to Log w`() {
AppLogger.w("TAG", "msg")
verify { Log.w("TAG", "msg") }
}
@Test
fun `w with throwable delegates to Log w`() {
val ex = RuntimeException("test")
AppLogger.w("TAG", "msg", ex)
verify { Log.w("TAG", "msg", ex) }
}
@Test
fun `e delegates to Log e`() {
AppLogger.e("TAG", "msg")
verify { Log.e("TAG", "msg") }
}
@Test
fun `e with throwable delegates to Log e`() {
val ex = RuntimeException("test")
AppLogger.e("TAG", "msg", ex)
verify { Log.e("TAG", "msg", ex) }
}
}
@@ -0,0 +1,49 @@
package com.openclaw.android
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
class CommandRunnerTest {
@TempDir
lateinit var tempDir: File
// PREFIX="" makes shell path "/bin/sh" — works on both macOS and Linux
private val env = mapOf("PREFIX" to "", "PATH" to "/usr/bin:/bin")
@Test
fun `runSync returns stdout for echo command`() {
val result = CommandRunner.runSync("echo hello", env, tempDir)
assertEquals(0, result.exitCode)
assertEquals("hello", result.stdout.trim())
}
@Test
fun `runSync returns non-zero exit code for failing command`() {
val result = CommandRunner.runSync("exit 42", env, tempDir)
assertEquals(42, result.exitCode)
}
@Test
fun `runSync captures stderr`() {
val result = CommandRunner.runSync("echo error >&2", env, tempDir)
assertEquals(0, result.exitCode)
assertEquals("error", result.stderr.trim())
}
@Test
fun `runSync handles invalid command`() {
val result = CommandRunner.runSync("nonexistent_command_xyz", env, tempDir)
assertTrue(result.exitCode != 0)
}
@Test
fun `CommandResult data class holds values correctly`() {
val result = CommandRunner.CommandResult(0, "out", "err")
assertEquals(0, result.exitCode)
assertEquals("out", result.stdout)
assertEquals("err", result.stderr)
}
}
@@ -0,0 +1,71 @@
package com.openclaw.android
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File
class EnvironmentBuilderTest {
private lateinit var env: Map<String, String>
private val filesDir = File("/data/data/com.openclaw.android/files")
@BeforeEach
fun setup() {
env = EnvironmentBuilder.build(filesDir)
}
@Test
fun `PREFIX points to usr directory`() {
assertEquals("${filesDir.absolutePath}/usr", env["PREFIX"])
}
@Test
fun `HOME points to home directory`() {
assertEquals("${filesDir.absolutePath}/home", env["HOME"])
}
@Test
fun `TMPDIR points to tmp directory`() {
assertEquals("${filesDir.absolutePath}/tmp", env["TMPDIR"])
}
@Test
fun `PATH contains node bin and prefix bin`() {
val path = env["PATH"]!!
assertTrue(path.contains(".openclaw-android/node/bin"))
assertTrue(path.contains("/usr/bin"))
}
@Test
fun `LD_LIBRARY_PATH is set`() {
assertNotNull(env["LD_LIBRARY_PATH"])
assertTrue(env["LD_LIBRARY_PATH"]!!.contains("/usr/lib"))
}
@Test
fun `APT_CONFIG points to apt conf`() {
assertTrue(env["APT_CONFIG"]!!.endsWith("/etc/apt/apt.conf"))
}
@Test
fun `GIT_CONFIG_NOSYSTEM is set to 1`() {
assertEquals("1", env["GIT_CONFIG_NOSYSTEM"])
}
@Test
fun `LANG is en_US UTF-8`() {
assertEquals("en_US.UTF-8", env["LANG"])
}
@Test
fun `TERM is xterm-256color`() {
assertEquals("xterm-256color", env["TERM"])
}
@Test
fun `OA_GLIBC is set`() {
assertEquals("1", env["OA_GLIBC"])
}
}
+8
View File
@@ -0,0 +1,8 @@
// Top-level build file — no plugins applied here
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.kotlinAndroid) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.detekt) apply false
alias(libs.plugins.ktlint) apply false
}
+75
View File
@@ -0,0 +1,75 @@
build:
maxIssues: 0
excludeCorrectable: false
complexity:
LongMethod:
threshold: 60
LargeClass:
threshold: 600
CyclomaticComplexMethod:
threshold: 15
TooManyFunctions:
thresholdInFiles: 20
thresholdInClasses: 20
ignorePrivate: true
style:
MagicNumber:
active: true
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreAnnotation: true
ignoreEnums: true
MaxLineLength:
maxLineLength: 120
WildcardImport:
active: false
ReturnCount:
max: 4
ForbiddenMethodCall:
active: true
methods:
# Log.* 직접 사용 금지 — 로깅 추상화(AppLogger) 경유 필수
- 'android.util.Log.v'
- 'android.util.Log.d'
- 'android.util.Log.i'
- 'android.util.Log.w'
- 'android.util.Log.e'
- 'android.util.Log.wtf'
# GlobalScope 직접 사용 금지 — 구조화된 코루틴 스코프 사용
- 'kotlinx.coroutines.GlobalScope.launch'
- 'kotlinx.coroutines.GlobalScope.async'
# Dispatchers 직접 사용 금지 — DI를 통한 디스패처 주입 사용
- 'kotlinx.coroutines.Dispatchers.IO'
- 'kotlinx.coroutines.Dispatchers.Main'
- 'kotlinx.coroutines.Dispatchers.Default'
- 'kotlinx.coroutines.Dispatchers.Unconfined'
naming:
FunctionNaming:
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
TopLevelPropertyNaming:
constantPattern: '[A-Z][A-Za-z0-9_]*'
VariableNaming:
variablePattern: '[a-z][A-Za-z0-9]*'
coroutines:
GlobalCoroutineUsage:
active: true
RedundantSuspendModifier:
active: true
exceptions:
TooGenericExceptionCaught:
active: true
exceptionNames:
- 'Error'
- 'Throwable'
SwallowedException:
active: true
+5
View File
@@ -0,0 +1,5 @@
# Project-wide Gradle settings
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+35
View File
@@ -0,0 +1,35 @@
[versions]
agp = "9.1.0"
kotlin = "2.2.21"
coreKtx = "1.17.0"
appcompat = "1.7.1"
material = "1.13.0"
constraintlayout = "2.2.1"
lifecycleRuntimeKtx = "2.10.0"
kotlinxCoroutines = "1.10.2"
junit5 = "6.0.3"
mockk = "1.14.9"
kotlinxCoroutinesTest = "1.10.2"
detekt = "1.23.8"
ktlintGradle = "14.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
gson = { module = "com.google.code.gson:gson", version = "2.12.1" }
junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" }
junit5-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "6.0.3" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "openclaw-android-app"
include(":app", ":terminal-emulator", ":terminal-view")
+53
View File
@@ -0,0 +1,53 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion 35
ndkVersion = "28.0.13004108"
namespace = "com.termux.terminal"
defaultConfig {
minSdkVersion 24
externalNativeBuild {
ndkBuild {
cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections"
}
}
ndk {
abiFilters 'arm64-v8a'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
externalNativeBuild {
ndkBuild {
path "src/main/jni/Android.mk"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
testOptions {
unitTests.returnDefaultValues = true
}
}
tasks.withType(Test) {
testLogging {
events "started", "passed", "skipped", "failed"
}
}
dependencies {
implementation "androidx.annotation:annotation:1.3.0"
testImplementation "junit:junit:4.13.2"
}
+25
View File
@@ -0,0 +1,25 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /Users/fornwall/lib/android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,108 @@
package com.termux.terminal;
/** A circular byte buffer allowing one producer and one consumer thread. */
final class ByteQueue {
private final byte[] mBuffer;
private int mHead;
private int mStoredBytes;
private boolean mOpen = true;
public ByteQueue(int size) {
mBuffer = new byte[size];
}
public synchronized void close() {
mOpen = false;
notify();
}
public synchronized int read(byte[] buffer, boolean block) {
while (mStoredBytes == 0 && mOpen) {
if (block) {
try {
wait();
} catch (InterruptedException e) {
// Ignore.
}
} else {
return 0;
}
}
if (!mOpen) return -1;
int totalRead = 0;
int bufferLength = mBuffer.length;
boolean wasFull = bufferLength == mStoredBytes;
int length = buffer.length;
int offset = 0;
while (length > 0 && mStoredBytes > 0) {
int oneRun = Math.min(bufferLength - mHead, mStoredBytes);
int bytesToCopy = Math.min(length, oneRun);
System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy);
mHead += bytesToCopy;
if (mHead >= bufferLength) mHead = 0;
mStoredBytes -= bytesToCopy;
length -= bytesToCopy;
offset += bytesToCopy;
totalRead += bytesToCopy;
}
if (wasFull) notify();
return totalRead;
}
/**
* Attempt to write the specified portion of the provided buffer to the queue.
* <p/>
* Returns whether the output was totally written, false if it was closed before.
*/
public boolean write(byte[] buffer, int offset, int lengthToWrite) {
if (lengthToWrite + offset > buffer.length) {
throw new IllegalArgumentException("length + offset > buffer.length");
} else if (lengthToWrite <= 0) {
throw new IllegalArgumentException("length <= 0");
}
final int bufferLength = mBuffer.length;
synchronized (this) {
while (lengthToWrite > 0) {
while (bufferLength == mStoredBytes && mOpen) {
try {
wait();
} catch (InterruptedException e) {
// Ignore.
}
}
if (!mOpen) return false;
final boolean wasEmpty = mStoredBytes == 0;
int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes);
lengthToWrite -= bytesToWriteBeforeWaiting;
while (bytesToWriteBeforeWaiting > 0) {
int tail = mHead + mStoredBytes;
int oneRun;
if (tail >= bufferLength) {
// Buffer: [.............]
// ________________H_______T
// =>
// Buffer: [.............]
// ___________T____H
// onRun= _____----_
tail = tail - bufferLength;
oneRun = mHead - tail;
} else {
oneRun = bufferLength - tail;
}
int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting);
System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy);
offset += bytesToCopy;
bytesToWriteBeforeWaiting -= bytesToCopy;
mStoredBytes += bytesToCopy;
}
if (wasEmpty) notify();
}
}
return true;
}
}
@@ -0,0 +1,41 @@
package com.termux.terminal;
/**
* Native methods for creating and managing pseudoterminal subprocesses. C code is in jni/termux.c.
*/
final class JNI {
static {
System.loadLibrary("termux");
}
/**
* Create a subprocess. Differs from {@link ProcessBuilder} in that a pseudoterminal is used to communicate with the
* subprocess.
* <p/>
* Callers are responsible for calling {@link #close(int)} on the returned file descriptor.
*
* @param cmd The command to execute
* @param cwd The current working directory for the executed command
* @param args An array of arguments to the command
* @param envVars An array of strings of the form "VAR=value" to be added to the environment of the process
* @param processId A one-element array to which the process ID of the started process will be written.
* @return the file descriptor resulting from opening /dev/ptmx master device. The sub process will have opened the
* slave device counterpart (/dev/pts/$N) and have it as stdint, stdout and stderr.
*/
public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns, int cellWidth, int cellHeight);
/** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */
public static native void setPtyWindowSize(int fd, int rows, int cols, int cellWidth, int cellHeight);
/**
* Causes the calling thread to wait for the process associated with the receiver to finish executing.
*
* @return if >= 0, the exit status of the process. If < 0, the signal causing the process to stop negated.
*/
public static native int waitFor(int processId);
/** Close a file descriptor through the close(2) system call. */
public static native void close(int fileDescriptor);
}
@@ -0,0 +1,373 @@
package com.termux.terminal;
import java.util.HashMap;
import java.util.Map;
import static android.view.KeyEvent.KEYCODE_BACK;
import static android.view.KeyEvent.KEYCODE_BREAK;
import static android.view.KeyEvent.KEYCODE_DEL;
import static android.view.KeyEvent.KEYCODE_DPAD_CENTER;
import static android.view.KeyEvent.KEYCODE_DPAD_DOWN;
import static android.view.KeyEvent.KEYCODE_DPAD_LEFT;
import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT;
import static android.view.KeyEvent.KEYCODE_DPAD_UP;
import static android.view.KeyEvent.KEYCODE_ENTER;
import static android.view.KeyEvent.KEYCODE_ESCAPE;
import static android.view.KeyEvent.KEYCODE_F1;
import static android.view.KeyEvent.KEYCODE_F10;
import static android.view.KeyEvent.KEYCODE_F11;
import static android.view.KeyEvent.KEYCODE_F12;
import static android.view.KeyEvent.KEYCODE_F2;
import static android.view.KeyEvent.KEYCODE_F3;
import static android.view.KeyEvent.KEYCODE_F4;
import static android.view.KeyEvent.KEYCODE_F5;
import static android.view.KeyEvent.KEYCODE_F6;
import static android.view.KeyEvent.KEYCODE_F7;
import static android.view.KeyEvent.KEYCODE_F8;
import static android.view.KeyEvent.KEYCODE_F9;
import static android.view.KeyEvent.KEYCODE_FORWARD_DEL;
import static android.view.KeyEvent.KEYCODE_INSERT;
import static android.view.KeyEvent.KEYCODE_MOVE_END;
import static android.view.KeyEvent.KEYCODE_MOVE_HOME;
import static android.view.KeyEvent.KEYCODE_NUMPAD_0;
import static android.view.KeyEvent.KEYCODE_NUMPAD_1;
import static android.view.KeyEvent.KEYCODE_NUMPAD_2;
import static android.view.KeyEvent.KEYCODE_NUMPAD_3;
import static android.view.KeyEvent.KEYCODE_NUMPAD_4;
import static android.view.KeyEvent.KEYCODE_NUMPAD_5;
import static android.view.KeyEvent.KEYCODE_NUMPAD_6;
import static android.view.KeyEvent.KEYCODE_NUMPAD_7;
import static android.view.KeyEvent.KEYCODE_NUMPAD_8;
import static android.view.KeyEvent.KEYCODE_NUMPAD_9;
import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD;
import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA;
import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE;
import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT;
import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER;
import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS;
import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY;
import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT;
import static android.view.KeyEvent.KEYCODE_NUM_LOCK;
import static android.view.KeyEvent.KEYCODE_PAGE_DOWN;
import static android.view.KeyEvent.KEYCODE_PAGE_UP;
import static android.view.KeyEvent.KEYCODE_SPACE;
import static android.view.KeyEvent.KEYCODE_SYSRQ;
import static android.view.KeyEvent.KEYCODE_TAB;
public final class KeyHandler {
public static final int KEYMOD_ALT = 0x80000000;
public static final int KEYMOD_CTRL = 0x40000000;
public static final int KEYMOD_SHIFT = 0x20000000;
public static final int KEYMOD_NUM_LOCK = 0x10000000;
private static final Map<String, Integer> TERMCAP_TO_KEYCODE = new HashMap<>();
static {
// terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html
// termcap: http://man7.org/linux/man-pages/man5/termcap.5.html
TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT);
TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home
TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT);
TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key
TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1);
TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2);
TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3);
TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4);
TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5);
TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6);
TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7);
TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8);
TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9);
TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10);
TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11);
TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12);
TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1);
TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2);
TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3);
TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4);
TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5);
TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6);
TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7);
TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8);
TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9);
TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10);
TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11);
TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12);
TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key
TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key
TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME);
TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT);
TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT);
// K1=Upper left of keypad:
// t_K1 <kHome> keypad home key
// t_K3 <kPageUp> keypad page-up key
// t_K4 <kEnd> keypad end key
// t_K5 <kPageDown> keypad page-down key
TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME);
TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP);
TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END);
TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN);
TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP);
TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab
TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key
TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down
TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key
TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT);
TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_UP);
TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_DOWN);
TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key
TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up
TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END);
TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER);
}
static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) {
Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap);
if (keyCodeAndMod == null) return null;
int keyCode = keyCodeAndMod;
int keyMod = 0;
if ((keyCode & KEYMOD_SHIFT) != 0) {
keyMod |= KEYMOD_SHIFT;
keyCode &= ~KEYMOD_SHIFT;
}
if ((keyCode & KEYMOD_CTRL) != 0) {
keyMod |= KEYMOD_CTRL;
keyCode &= ~KEYMOD_CTRL;
}
if ((keyCode & KEYMOD_ALT) != 0) {
keyMod |= KEYMOD_ALT;
keyCode &= ~KEYMOD_ALT;
}
if ((keyCode & KEYMOD_NUM_LOCK) != 0) {
keyMod |= KEYMOD_NUM_LOCK;
keyCode &= ~KEYMOD_NUM_LOCK;
}
return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication);
}
public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) {
boolean numLockOn = (keyMode & KEYMOD_NUM_LOCK) != 0;
keyMode &= ~KEYMOD_NUM_LOCK;
switch (keyCode) {
case KEYCODE_DPAD_CENTER:
return "\015";
case KEYCODE_DPAD_UP:
return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A');
case KEYCODE_DPAD_DOWN:
return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B');
case KEYCODE_DPAD_RIGHT:
return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C');
case KEYCODE_DPAD_LEFT:
return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D');
case KEYCODE_MOVE_HOME:
// Note that KEYCODE_HOME is handled by the system and never delivered to applications.
// On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow.
return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H');
case KEYCODE_MOVE_END:
return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F');
// An xterm can send function keys F1 to F4 in two modes: vt100 compatible or
// not. Because Vim may not know what the xterm is sending, both types of keys
// are recognized. The same happens for the <Home> and <End> keys.
// normal vt100 ~
// <F1> t_k1 <Esc>[11~ <xF1> <Esc>OP *<xF1>-xterm*
// <F2> t_k2 <Esc>[12~ <xF2> <Esc>OQ *<xF2>-xterm*
// <F3> t_k3 <Esc>[13~ <xF3> <Esc>OR *<xF3>-xterm*
// <F4> t_k4 <Esc>[14~ <xF4> <Esc>OS *<xF4>-xterm*
// <Home> t_kh <Esc>[7~ <xHome> <Esc>OH *<xHome>-xterm*
// <End> t_@7 <Esc>[4~ <xEnd> <Esc>OF *<xEnd>-xterm*
case KEYCODE_F1:
return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P');
case KEYCODE_F2:
return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q');
case KEYCODE_F3:
return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R');
case KEYCODE_F4:
return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S');
case KEYCODE_F5:
return transformForModifiers("\033[15", keyMode, '~');
case KEYCODE_F6:
return transformForModifiers("\033[17", keyMode, '~');
case KEYCODE_F7:
return transformForModifiers("\033[18", keyMode, '~');
case KEYCODE_F8:
return transformForModifiers("\033[19", keyMode, '~');
case KEYCODE_F9:
return transformForModifiers("\033[20", keyMode, '~');
case KEYCODE_F10:
return transformForModifiers("\033[21", keyMode, '~');
case KEYCODE_F11:
return transformForModifiers("\033[23", keyMode, '~');
case KEYCODE_F12:
return transformForModifiers("\033[24", keyMode, '~');
case KEYCODE_SYSRQ:
return "\033[32~"; // Sys Request / Print
// Is this Scroll lock? case Cancel: return "\033[33~";
case KEYCODE_BREAK:
return "\033[34~"; // Pause/Break
case KEYCODE_ESCAPE:
case KEYCODE_BACK:
return "\033";
case KEYCODE_INSERT:
return transformForModifiers("\033[2", keyMode, '~');
case KEYCODE_FORWARD_DEL:
return transformForModifiers("\033[3", keyMode, '~');
case KEYCODE_PAGE_UP:
return transformForModifiers("\033[5", keyMode, '~');
case KEYCODE_PAGE_DOWN:
return transformForModifiers("\033[6", keyMode, '~');
case KEYCODE_DEL:
String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033";
// Just do what xterm and gnome-terminal does:
return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008");
case KEYCODE_NUM_LOCK:
if (keypadApplication) {
return "\033OP";
} else {
return null;
}
case KEYCODE_SPACE:
// If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a
// combining accent to be written):
return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0";
case KEYCODE_TAB:
// This is back-tab when shifted:
return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z";
case KEYCODE_ENTER:
return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r";
case KEYCODE_NUMPAD_ENTER:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n";
case KEYCODE_NUMPAD_MULTIPLY:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*";
case KEYCODE_NUMPAD_ADD:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+";
case KEYCODE_NUMPAD_COMMA:
return ",";
case KEYCODE_NUMPAD_DOT:
if (numLockOn) {
return keypadApplication ? "\033On" : ".";
} else {
// DELETE
return transformForModifiers("\033[3", keyMode, '~');
}
case KEYCODE_NUMPAD_SUBTRACT:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-";
case KEYCODE_NUMPAD_DIVIDE:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/";
case KEYCODE_NUMPAD_0:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0";
} else {
// INSERT
return transformForModifiers("\033[2", keyMode, '~');
}
case KEYCODE_NUMPAD_1:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1";
} else {
// END
return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F');
}
case KEYCODE_NUMPAD_2:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2";
} else {
// DOWN
return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B');
}
case KEYCODE_NUMPAD_3:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3";
} else {
// PGDN
return "\033[6~";
}
case KEYCODE_NUMPAD_4:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4";
} else {
// LEFT
return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D');
}
case KEYCODE_NUMPAD_5:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5";
case KEYCODE_NUMPAD_6:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6";
} else {
// RIGHT
return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C');
}
case KEYCODE_NUMPAD_7:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7";
} else {
// HOME
return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H');
}
case KEYCODE_NUMPAD_8:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8";
} else {
// UP
return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A');
}
case KEYCODE_NUMPAD_9:
if (numLockOn) {
return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9";
} else {
// PGUP
return "\033[5~";
}
case KEYCODE_NUMPAD_EQUALS:
return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "=";
}
return null;
}
private static String transformForModifiers(String start, int keymod, char lastChar) {
int modifier;
switch (keymod) {
case KEYMOD_SHIFT:
modifier = 2;
break;
case KEYMOD_ALT:
modifier = 3;
break;
case (KEYMOD_SHIFT | KEYMOD_ALT):
modifier = 4;
break;
case KEYMOD_CTRL:
modifier = 5;
break;
case KEYMOD_SHIFT | KEYMOD_CTRL:
modifier = 6;
break;
case KEYMOD_ALT | KEYMOD_CTRL:
modifier = 7;
break;
case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL:
modifier = 8;
break;
default:
return start + lastChar;
}
return start + (";" + modifier) + lastChar;
}
}
@@ -0,0 +1,80 @@
package com.termux.terminal;
import android.util.Log;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
public class Logger {
public static void logError(TerminalSessionClient client, String logTag, String message) {
if (client != null)
client.logError(logTag, message);
else
Log.e(logTag, message);
}
public static void logWarn(TerminalSessionClient client, String logTag, String message) {
if (client != null)
client.logWarn(logTag, message);
else
Log.w(logTag, message);
}
public static void logInfo(TerminalSessionClient client, String logTag, String message) {
if (client != null)
client.logInfo(logTag, message);
else
Log.i(logTag, message);
}
public static void logDebug(TerminalSessionClient client, String logTag, String message) {
if (client != null)
client.logDebug(logTag, message);
else
Log.d(logTag, message);
}
public static void logVerbose(TerminalSessionClient client, String logTag, String message) {
if (client != null)
client.logVerbose(logTag, message);
else
Log.v(logTag, message);
}
public static void logStackTraceWithMessage(TerminalSessionClient client, String tag, String message, Throwable throwable) {
logError(client, tag, getMessageAndStackTraceString(message, throwable));
}
public static String getMessageAndStackTraceString(String message, Throwable throwable) {
if (message == null && throwable == null)
return null;
else if (message != null && throwable != null)
return message + ":\n" + getStackTraceString(throwable);
else if (throwable == null)
return message;
else
return getStackTraceString(throwable);
}
public static String getStackTraceString(Throwable throwable) {
if (throwable == null) return null;
String stackTraceString = null;
try {
StringWriter errors = new StringWriter();
PrintWriter pw = new PrintWriter(errors);
throwable.printStackTrace(pw);
pw.close();
stackTraceString = errors.toString();
errors.close();
} catch (IOException e) {
e.printStackTrace();
}
return stackTraceString;
}
}
@@ -0,0 +1,497 @@
package com.termux.terminal;
import java.util.Arrays;
/**
* A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll
* history.
* <p>
* See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices.
*/
public final class TerminalBuffer {
TerminalRow[] mLines;
/** The length of {@link #mLines}. */
int mTotalRows;
/** The number of rows and columns visible on the screen. */
int mScreenRows, mColumns;
/** The number of rows kept in history. */
private int mActiveTranscriptRows = 0;
/** The index in the circular buffer where the visible screen starts. */
private int mScreenFirstRow = 0;
/**
* Create a transcript screen.
*
* @param columns the width of the screen in characters.
* @param totalRows the height of the entire text area, in rows of text.
* @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off
* the top of the screen.
*/
public TerminalBuffer(int columns, int totalRows, int screenRows) {
mColumns = columns;
mTotalRows = totalRows;
mScreenRows = screenRows;
mLines = new TerminalRow[totalRows];
blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL);
}
public String getTranscriptText() {
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim();
}
public String getTranscriptTextWithoutJoinedLines() {
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim();
}
public String getTranscriptTextWithFullLinesJoined() {
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim();
}
public String getSelectedText(int selX1, int selY1, int selX2, int selY2) {
return getSelectedText(selX1, selY1, selX2, selY2, true);
}
public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) {
return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, false);
}
public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) {
final StringBuilder builder = new StringBuilder();
final int columns = mColumns;
if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows();
if (selY2 >= mScreenRows) selY2 = mScreenRows - 1;
for (int row = selY1; row <= selY2; row++) {
int x1 = (row == selY1) ? selX1 : 0;
int x2;
if (row == selY2) {
x2 = selX2 + 1;
if (x2 > columns) x2 = columns;
} else {
x2 = columns;
}
TerminalRow lineObject = mLines[externalToInternalRow(row)];
int x1Index = lineObject.findStartOfColumn(x1);
int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed();
if (x2Index == x1Index) {
// Selected the start of a wide character.
x2Index = lineObject.findStartOfColumn(x2 + 1);
}
char[] line = lineObject.mText;
int lastPrintingCharIndex = -1;
int i;
boolean rowLineWrap = getLineWrap(row);
if (rowLineWrap && x2 == columns) {
// If the line was wrapped, we shouldn't lose trailing space:
lastPrintingCharIndex = x2Index - 1;
} else {
for (i = x1Index; i < x2Index; ++i) {
char c = line[i];
if (c != ' ') lastPrintingCharIndex = i;
}
}
int len = lastPrintingCharIndex - x1Index + 1;
if (lastPrintingCharIndex != -1 && len > 0)
builder.append(line, x1Index, len);
boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1;
if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
&& row < selY2 && row < mScreenRows - 1) builder.append('\n');
}
return builder.toString();
}
public String getWordAtLocation(int x, int y) {
// Set y1 and y2 to the lines where the wrapped line starts and ends.
// I.e. if a line that is wrapped to 3 lines starts at line 4, and this
// is called with y=5, then y1 would be set to 4 and y2 would be set to 6.
int y1 = y;
int y2 = y;
while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, true, true).contains("\n")) {
y1--;
}
while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, true, true).contains("\n")) {
y2++;
}
// Get the text for the whole wrapped line
String text = getSelectedText(0, y1, mColumns, y2, true, true);
// The index of x in text
int textOffset = (y - y1) * mColumns + x;
if (textOffset >= text.length()) {
// The click was to the right of the last word on the line, so
// there's no word to return
return "";
}
// Set x1 and x2 to the indices of the last space before x and the
// first space after x in text respectively
int x1 = text.lastIndexOf(' ', textOffset);
int x2 = text.indexOf(' ', textOffset);
if (x2 == -1) {
x2 = text.length();
}
if (x1 == x2) {
// The click was on a space, so there's no word to return
return "";
}
return text.substring(x1 + 1, x2);
}
public int getActiveTranscriptRows() {
return mActiveTranscriptRows;
}
public int getActiveRows() {
return mActiveTranscriptRows + mScreenRows;
}
/**
* Convert a row value from the public external coordinate system to our internal private coordinate system.
*
* <pre>
* - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
* - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
* mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
*
* External ↔ Internal:
*
* [ ... ] [ ... ]
* [ -mActiveTranscriptRows ] [ mScreenFirstRow - mActiveTranscriptRows ]
* [ ... ] [ ... ]
* [ 0 (visible screen starts here) ] ↔ [ mScreenFirstRow ]
* [ ... ] [ ... ]
* [ mScreenRows-1 ] [ mScreenFirstRow + mScreenRows-1 ]
* </pre>
*
* @param externalRow a row in the external coordinate system.
* @return The row corresponding to the input argument in the private coordinate system.
*/
public int externalToInternalRow(int externalRow) {
if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows)
throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows);
final int internalRow = mScreenFirstRow + externalRow;
return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows);
}
public void setLineWrap(int row) {
mLines[externalToInternalRow(row)].mLineWrap = true;
}
public boolean getLineWrap(int row) {
return mLines[externalToInternalRow(row)].mLineWrap;
}
public void clearLineWrap(int row) {
mLines[externalToInternalRow(row)].mLineWrap = false;
}
/**
* Resize the screen which this transcript backs. Currently, this only works if the number of columns does not
* change or the rows expand (that is, it only works when shrinking the number of rows).
*
* @param newColumns The number of columns the screen should have.
* @param newRows The number of rows the screen should have.
* @param cursor An int[2] containing the (column, row) cursor location.
*/
public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) {
// newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000):
if (newColumns == mColumns && newRows <= mTotalRows) {
// Fast resize where just the rows changed.
int shiftDownOfTopRow = mScreenRows - newRows;
if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) {
// Shrinking. Check if we can skip blank rows at bottom below cursor.
for (int i = mScreenRows - 1; i > 0; i--) {
if (cursor[1] >= i) break;
int r = externalToInternalRow(i);
if (mLines[r] == null || mLines[r].isBlank()) {
if (--shiftDownOfTopRow == 0) break;
}
}
} else if (shiftDownOfTopRow < 0) {
// Negative shift down = expanding. Only move screen up if there is transcript to show:
int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows);
if (shiftDownOfTopRow != actualShift) {
// The new lines revealed by the resizing are not all from the transcript. Blank the below ones.
for (int i = 0; i < actualShift - shiftDownOfTopRow; i++)
allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle);
shiftDownOfTopRow = actualShift;
}
}
mScreenFirstRow += shiftDownOfTopRow;
mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows);
mTotalRows = newTotalRows;
mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow);
cursor[1] -= shiftDownOfTopRow;
mScreenRows = newRows;
} else {
// Copy away old state and update new:
TerminalRow[] oldLines = mLines;
mLines = new TerminalRow[newTotalRows];
for (int i = 0; i < newTotalRows; i++)
mLines[i] = new TerminalRow(newColumns, currentStyle);
final int oldActiveTranscriptRows = mActiveTranscriptRows;
final int oldScreenFirstRow = mScreenFirstRow;
final int oldScreenRows = mScreenRows;
final int oldTotalRows = mTotalRows;
mTotalRows = newTotalRows;
mScreenRows = newRows;
mActiveTranscriptRows = mScreenFirstRow = 0;
mColumns = newColumns;
int newCursorRow = -1;
int newCursorColumn = -1;
int oldCursorRow = cursor[1];
int oldCursorColumn = cursor[0];
boolean newCursorPlaced = false;
int currentOutputExternalRow = 0;
int currentOutputExternalColumn = 0;
// Loop over every character in the initial state.
// Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we
// keep track how many blank lines we have skipped if we later on find a non-blank line.
int skippedBlankLines = 0;
for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) {
// Do what externalToInternalRow() does but for the old state:
int internalOldRow = oldScreenFirstRow + externalOldRow;
internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows);
TerminalRow oldLine = oldLines[internalOldRow];
boolean cursorAtThisRow = externalOldRow == oldCursorRow;
// The cursor may only be on a non-null line, which we should not skip:
if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) {
skippedBlankLines++;
continue;
} else if (skippedBlankLines > 0) {
// After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines.
for (int i = 0; i < skippedBlankLines; i++) {
if (currentOutputExternalRow == mScreenRows - 1) {
scrollDownOneLine(0, mScreenRows, currentStyle);
} else {
currentOutputExternalRow++;
}
currentOutputExternalColumn = 0;
}
skippedBlankLines = 0;
}
int lastNonSpaceIndex = 0;
boolean justToCursor = false;
if (cursorAtThisRow || oldLine.mLineWrap) {
// Take the whole line, either because of cursor on it, or if line wrapping.
lastNonSpaceIndex = oldLine.getSpaceUsed();
if (cursorAtThisRow) justToCursor = true;
} else {
for (int i = 0; i < oldLine.getSpaceUsed(); i++)
// NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices
if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */)
lastNonSpaceIndex = i + 1;
}
int currentOldCol = 0;
long styleAtCol = 0;
for (int i = 0; i < lastNonSpaceIndex; i++) {
// Note that looping over java character, not cells.
char c = oldLine.mText[i];
int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c;
int displayWidth = WcWidth.width(codePoint);
// Use the last style if this is a zero-width character:
if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol);
// Line wrap as necessary:
if (currentOutputExternalColumn + displayWidth > mColumns) {
setLineWrap(currentOutputExternalRow);
if (currentOutputExternalRow == mScreenRows - 1) {
if (newCursorPlaced) newCursorRow--;
scrollDownOneLine(0, mScreenRows, currentStyle);
} else {
currentOutputExternalRow++;
}
currentOutputExternalColumn = 0;
}
int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0);
int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar;
setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol);
if (displayWidth > 0) {
if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) {
newCursorColumn = currentOutputExternalColumn;
newCursorRow = currentOutputExternalRow;
newCursorPlaced = true;
}
currentOldCol += displayWidth;
currentOutputExternalColumn += displayWidth;
if (justToCursor && newCursorPlaced) break;
}
}
// Old row has been copied. Check if we need to insert newline if old line was not wrapping:
if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) {
if (currentOutputExternalRow == mScreenRows - 1) {
if (newCursorPlaced) newCursorRow--;
scrollDownOneLine(0, mScreenRows, currentStyle);
} else {
currentOutputExternalRow++;
}
currentOutputExternalColumn = 0;
}
}
cursor[0] = newCursorColumn;
cursor[1] = newCursorRow;
}
// Handle cursor scrolling off screen:
if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0;
}
/**
* Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound
* into account.
*
* @param srcInternal The first line to be copied.
* @param len The number of lines to be copied.
*/
private void blockCopyLinesDown(int srcInternal, int len) {
if (len == 0) return;
int totalRows = mTotalRows;
int start = len - 1;
// Save away line to be overwritten:
TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows];
// Do the copy from bottom to top.
for (int i = start; i >= 0; --i)
mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows];
// Put back overwritten line, now above the block:
mLines[(srcInternal) % totalRows] = lineToBeOverWritten;
}
/**
* Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24).
*
* @param topMargin First line that is scrolled.
* @param bottomMargin One line after the last line that is scrolled.
* @param style the style for the newly exposed line.
*/
public void scrollDownOneLine(int topMargin, int bottomMargin, long style) {
if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows)
throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows);
// Copy the fixed topMargin lines one line down so that they remain on screen in same position:
blockCopyLinesDown(mScreenFirstRow, topMargin);
// Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same
// position:
blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin);
// Update the screen location in the ring buffer:
mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows;
// Note that the history has grown if not already full:
if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++;
// Blank the newly revealed line above the bottom margin:
int blankRow = externalToInternalRow(bottomMargin - 1);
if (mLines[blankRow] == null) {
mLines[blankRow] = new TerminalRow(mColumns, style);
} else {
mLines[blankRow].clear(style);
}
}
/**
* Block copy characters from one position in the screen to another. The two positions can overlap. All characters
* of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will
* be thrown.
*
* @param sx source X coordinate
* @param sy source Y coordinate
* @param w width
* @param h height
* @param dx destination X coordinate
* @param dy destination Y coordinate
*/
public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) {
if (w == 0) return;
if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows)
throw new IllegalArgumentException();
boolean copyingUp = sy > dy;
for (int y = 0; y < h; y++) {
int y2 = copyingUp ? y : (h - (y + 1));
TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2));
allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx);
}
}
/**
* Block set characters. All characters must be within the bounds of the screen, or else and
* InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block
* of characters.
*/
public void blockSet(int sx, int sy, int w, int h, int val, long style) {
if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) {
throw new IllegalArgumentException(
"Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")");
}
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
setChar(sx + x, sy + y, val, style);
}
public TerminalRow allocateFullLineIfNecessary(int row) {
return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row];
}
public void setChar(int column, int row, int codePoint, long style) {
if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns)
throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns);
row = externalToInternalRow(row);
allocateFullLineIfNecessary(row).setChar(column, codePoint, style);
}
public long getStyleAt(int externalRow, int column) {
return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column);
}
/** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */
public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left,
int bottom, int right) {
for (int y = top; y < bottom; y++) {
TerminalRow line = mLines[externalToInternalRow(y)];
int startOfLine = (rectangular || y == top) ? left : leftMargin;
int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin;
for (int x = startOfLine; x < endOfLine; x++) {
long currentStyle = line.getStyle(x);
int foreColor = TextStyle.decodeForeColor(currentStyle);
int backColor = TextStyle.decodeBackColor(currentStyle);
int effect = TextStyle.decodeEffect(currentStyle);
if (reverse) {
// Clear out the bits to reverse and add them back in reversed:
effect = (effect & ~bits) | (bits & ~effect);
} else if (setOrClear) {
effect |= bits;
} else {
effect &= ~bits;
}
line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect);
}
}
}
public void clearTranscript() {
if (mScreenFirstRow < mActiveTranscriptRows) {
Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null);
Arrays.fill(mLines, 0, mScreenFirstRow, null);
} else {
Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null);
}
mActiveTranscriptRows = 0;
}
}
@@ -0,0 +1,126 @@
package com.termux.terminal;
import java.util.Map;
import java.util.Properties;
/**
* Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using
* Operating System Control (OSC) sequences.
*
* @see TerminalColors
*/
public final class TerminalColorScheme {
/** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */
private static final int[] DEFAULT_COLORSCHEME = {
// 16 original colors. First 8 are dim.
0xff000000, // black
0xffcd0000, // dim red
0xff00cd00, // dim green
0xffcdcd00, // dim yellow
0xff6495ed, // dim blue
0xffcd00cd, // dim magenta
0xff00cdcd, // dim cyan
0xffe5e5e5, // dim white
// Second 8 are bright:
0xff7f7f7f, // medium grey
0xffff0000, // bright red
0xff00ff00, // bright green
0xffffff00, // bright yellow
0xff5c5cff, // light blue
0xffff00ff, // bright magenta
0xff00ffff, // bright cyan
0xffffffff, // bright white
// 216 color cube, six shades of each color:
0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff,
0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff,
0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff,
0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff,
0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff,
0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff,
0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff,
0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff,
0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff,
0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff,
0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff,
0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff,
0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff,
0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff,
0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff,
0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff,
0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff,
0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff,
// 24 grey scale ramp:
0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676,
0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee,
// COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR:
0xffffffff, 0xff000000, 0xffffffff};
public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS];
public TerminalColorScheme() {
reset();
}
private void reset() {
System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS);
}
public void updateWith(Properties props) {
reset();
boolean cursorPropExists = false;
for (Map.Entry<Object, Object> entries : props.entrySet()) {
String key = (String) entries.getKey();
String value = (String) entries.getValue();
int colorIndex;
if (key.equals("foreground")) {
colorIndex = TextStyle.COLOR_INDEX_FOREGROUND;
} else if (key.equals("background")) {
colorIndex = TextStyle.COLOR_INDEX_BACKGROUND;
} else if (key.equals("cursor")) {
colorIndex = TextStyle.COLOR_INDEX_CURSOR;
cursorPropExists = true;
} else if (key.startsWith("color")) {
try {
colorIndex = Integer.parseInt(key.substring(5));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid property: '" + key + "'");
}
} else {
throw new IllegalArgumentException("Invalid property: '" + key + "'");
}
int colorValue = TerminalColors.parse(value);
if (colorValue == 0)
throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'");
mDefaultColors[colorIndex] = colorValue;
}
if (!cursorPropExists)
setCursorColorForBackground();
}
/**
* If the "cursor" color is not set by user, we need to decide on the appropriate color that will
* be visible on the current terminal background. White will not be visible on light backgrounds
* and black won't be visible on dark backgrounds. So we find the perceived brightness of the
* background color and if its below the threshold (too dark), we use white cursor and if its
* above (too bright), we use black cursor.
*/
public void setCursorColorForBackground() {
int backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND];
int brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor);
if (brightness > 0) {
if (brightness < 130)
mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xffffffff;
else
mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xff000000;
}
}
}
@@ -0,0 +1,96 @@
package com.termux.terminal;
import android.graphics.Color;
/** Current terminal colors (if different from default). */
public final class TerminalColors {
/** Static data - a bit ugly but ok for now. */
public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme();
/**
* The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC
* 4 control sequence.
*/
public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS];
/** Create a new instance with default colors from the theme. */
public TerminalColors() {
reset();
}
/** Reset a particular indexed color with the default color from the color theme. */
public void reset(int index) {
mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index];
}
/** Reset all indexed colors with the default color from the color theme. */
public void reset() {
System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS);
}
/**
* Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html
* <p/>
* Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed.
*/
static int parse(String c) {
try {
int skipInitial, skipBetween;
if (c.charAt(0) == '#') {
// #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits.
skipInitial = 1;
skipBetween = 0;
} else if (c.startsWith("rgb:")) {
// rgb:<red>/<green>/<blue> where <red>, <green>, <blue> := h | hh | hhh | hhhh. Scaled.
skipInitial = 4;
skipBetween = 1;
} else {
return 0;
}
int charsForColors = c.length() - skipInitial - 2 * skipBetween;
if (charsForColors % 3 != 0) return 0; // Unequal lengths.
int componentLength = charsForColors / 3;
double mult = 255 / (Math.pow(2, componentLength * 4) - 1);
int currentPosition = skipInitial;
String rString = c.substring(currentPosition, currentPosition + componentLength);
currentPosition += componentLength + skipBetween;
String gString = c.substring(currentPosition, currentPosition + componentLength);
currentPosition += componentLength + skipBetween;
String bString = c.substring(currentPosition, currentPosition + componentLength);
int r = (int) (Integer.parseInt(rString, 16) * mult);
int g = (int) (Integer.parseInt(gString, 16) * mult);
int b = (int) (Integer.parseInt(bString, 16) * mult);
return 0xFF << 24 | r << 16 | g << 8 | b;
} catch (NumberFormatException | IndexOutOfBoundsException e) {
return 0;
}
}
/** Try parse a color from a text parameter and into a specified index. */
public void tryParseColor(int intoIndex, String textParameter) {
int c = parse(textParameter);
if (c != 0) mCurrentColors[intoIndex] = c;
}
/**
* Get the perceived brightness of the color based on its RGB components.
*
* https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx
* http://alienryderflex.com/hsp.html
*
* @param color The color code int.
* @return Returns value between 0-255.
*/
public static int getPerceivedBrightnessOfColor(int color) {
return (int)
Math.floor(Math.sqrt(
Math.pow(Color.red(color), 2) * 0.241 +
Math.pow(Color.green(color), 2) * 0.691 +
Math.pow(Color.blue(color), 2) * 0.068
));
}
}
@@ -0,0 +1,32 @@
package com.termux.terminal;
import java.nio.charset.StandardCharsets;
/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */
public abstract class TerminalOutput {
/** Write a string using the UTF-8 encoding to the terminal client. */
public final void write(String data) {
if (data == null) return;
byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
write(bytes, 0, bytes.length);
}
/** Write bytes to the terminal client. */
public abstract void write(byte[] data, int offset, int count);
/** Notify the terminal client that the terminal title has changed. */
public abstract void titleChanged(String oldTitle, String newTitle);
/** Notify the terminal client that text should be copied to clipboard. */
public abstract void onCopyTextToClipboard(String text);
/** Notify the terminal client that text should be pasted from clipboard. */
public abstract void onPasteTextFromClipboard();
/** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */
public abstract void onBell();
public abstract void onColorsChanged();
}
@@ -0,0 +1,283 @@
package com.termux.terminal;
import java.util.Arrays;
/**
* A row in a terminal, composed of a fixed number of cells.
* <p>
* The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering.
*/
public final class TerminalRow {
private static final float SPARE_CAPACITY_FACTOR = 1.5f;
/**
* Max combining characters that can exist in a column, that are separate from the base character
* itself. Any additional combining characters will be ignored and not added to the column.
*
* There does not seem to be limit in unicode standard for max number of combination characters
* that can be combined but such characters are primarily under 10.
*
* "Section 3.6 Combination" of unicode standard contains combining characters info.
* - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf
* - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges
* - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to
*
* UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters.
* > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage.
* > While it would have been feasible to chose a smaller number, this value provides a very wide margin,
* > yet is well within the buffer size limits of practical implementations.
* - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format
* - https://stackoverflow.com/a/11983435/14686958
*
* We choose the value 15 because it should be enough for terminal based applications and keep
* the memory usage low for a terminal row, won't affect performance or cause terminal to
* lag or hang, and will keep malicious applications from causing harm. The value can be
* increased if ever needed for legitimate applications.
*/
private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15;
/** The number of columns in this terminal row. */
private final int mColumns;
/** The text filling this terminal row. */
public char[] mText;
/** The number of java chars used in {@link #mText}. */
private short mSpaceUsed;
/** If this row has been line wrapped due to text output at the end of line. */
boolean mLineWrap;
/** The style bits of each cell in the row. See {@link TextStyle}. */
final long[] mStyle;
/** If this row might contain chars with width != 1, used for deactivating fast path */
boolean mHasNonOneWidthOrSurrogateChars;
/** Construct a blank row (containing only whitespace, ' ') with a specified style. */
public TerminalRow(int columns, long style) {
mColumns = columns;
mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)];
mStyle = new long[columns];
clear(style);
}
/** NOTE: The sourceX2 is exclusive. */
public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) {
mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars;
final int x1 = line.findStartOfColumn(sourceX1);
final int x2 = line.findStartOfColumn(sourceX2);
boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1));
final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText;
int latestNonCombiningWidth = 0;
for (int i = x1; i < x2; i++) {
char sourceChar = sourceChars[i];
int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar;
if (startingFromSecondHalfOfWideChar) {
// Just treat copying second half of wide char as copying whitespace.
codePoint = ' ';
startingFromSecondHalfOfWideChar = false;
}
int w = WcWidth.width(codePoint);
if (w > 0) {
destinationX += latestNonCombiningWidth;
sourceX1 += latestNonCombiningWidth;
latestNonCombiningWidth = w;
}
setChar(destinationX, codePoint, line.getStyle(sourceX1));
}
}
public int getSpaceUsed() {
return mSpaceUsed;
}
/** Note that the column may end of second half of wide character. */
public int findStartOfColumn(int column) {
if (column == mColumns) return getSpaceUsed();
int currentColumn = 0;
int currentCharIndex = 0;
while (true) { // 0<2 1 < 2
int newCharIndex = currentCharIndex;
char c = mText[newCharIndex++]; // cci=1, cci=2
boolean isHigh = Character.isHighSurrogate(c);
int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c;
int wcwidth = WcWidth.width(codePoint); // 1, 2
if (wcwidth > 0) {
currentColumn += wcwidth;
if (currentColumn == column) {
while (newCharIndex < mSpaceUsed) {
// Skip combining chars.
if (Character.isHighSurrogate(mText[newCharIndex])) {
if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) {
newCharIndex += 2;
} else {
break;
}
} else if (WcWidth.width(mText[newCharIndex]) <= 0) {
newCharIndex++;
} else {
break;
}
}
return newCharIndex;
} else if (currentColumn > column) {
// Wide column going past end.
return currentCharIndex;
}
}
currentCharIndex = newCharIndex;
}
}
private boolean wideDisplayCharacterStartingAt(int column) {
for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) {
char c = mText[currentCharIndex++];
int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c;
int wcwidth = WcWidth.width(codePoint);
if (wcwidth > 0) {
if (currentColumn == column && wcwidth == 2) return true;
currentColumn += wcwidth;
if (currentColumn > column) return false;
}
}
return false;
}
public void clear(long style) {
Arrays.fill(mText, ' ');
Arrays.fill(mStyle, style);
mSpaceUsed = (short) mColumns;
mHasNonOneWidthOrSurrogateChars = false;
}
// https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
public void setChar(int columnToSet, int codePoint, long style) {
if (columnToSet < 0 || columnToSet >= mStyle.length)
throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style);
mStyle[columnToSet] = style;
final int newCodePointDisplayWidth = WcWidth.width(codePoint);
// Fast path when we don't have any chars with width != 1
if (!mHasNonOneWidthOrSurrogateChars) {
if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) {
mHasNonOneWidthOrSurrogateChars = true;
} else {
mText[columnToSet] = (char) codePoint;
return;
}
}
final boolean newIsCombining = newCodePointDisplayWidth <= 0;
boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1);
if (newIsCombining) {
// When standing at second half of wide character and inserting combining:
if (wasExtraColForWideChar) columnToSet--;
} else {
// Check if we are overwriting the second half of a wide character starting at the previous column:
if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style);
// Check if we are overwriting the first half of a wide character starting at the next column:
boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1);
if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style);
}
char[] text = mText;
final int oldStartOfColumnIndex = findStartOfColumn(columnToSet);
final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex);
// Get the number of elements in the mText array this column uses now
int oldCharactersUsedForColumn;
if (columnToSet + oldCodePointDisplayWidth < mColumns) {
int oldEndOfColumnIndex = findStartOfColumn(columnToSet + oldCodePointDisplayWidth);
oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex;
} else {
// Last character.
oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex;
}
// If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters.
if (newIsCombining) {
int combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn);
if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN)
return;
}
// Find how many chars this column will need
int newCharactersUsedForColumn = Character.charCount(codePoint);
if (newIsCombining) {
// Combining characters are added to the contents of the column instead of overwriting them, so that they
// modify the existing contents.
// FIXME: Unassigned characters also get width=0.
newCharactersUsedForColumn += oldCharactersUsedForColumn;
}
int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn;
int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn;
final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn;
if (javaCharDifference > 0) {
// Shift the rest of the line right.
int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex;
if (mSpaceUsed + javaCharDifference > text.length) {
// We need to grow the array
char[] newText = new char[text.length + mColumns];
System.arraycopy(text, 0, newText, 0, oldNextColumnIndex);
System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn);
mText = text = newText;
} else {
System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn);
}
} else if (javaCharDifference < 0) {
// Shift the rest of the line left.
System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex);
}
mSpaceUsed += javaCharDifference;
// Store char. A combining character is stored at the end of the existing contents so that it modifies them:
//noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used.
Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0));
if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
// Replace second half of wide char with a space. Which mean that we actually add a ' ' java character.
if (mSpaceUsed + 1 > text.length) {
char[] newText = new char[text.length + mColumns];
System.arraycopy(text, 0, newText, 0, newNextColumnIndex);
System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
mText = text = newText;
} else {
System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
}
text[newNextColumnIndex] = ' ';
++mSpaceUsed;
} else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
if (columnToSet == mColumns - 1) {
throw new IllegalArgumentException("Cannot put wide character in last column");
} else if (columnToSet == mColumns - 2) {
// Truncate the line to the second part of this wide char:
mSpaceUsed = (short) newNextColumnIndex;
} else {
// Overwrite the contents of the next column, which mean we actually remove java characters. Due to the
// check at the beginning of this method we know that we are not overwriting a wide char.
int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1);
int nextLen = newNextNextColumnIndex - newNextColumnIndex;
// Shift the array leftwards.
System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex);
mSpaceUsed -= nextLen;
}
}
}
boolean isBlank() {
for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++)
if (mText[charIndex] != ' ') return false;
return true;
}
public final long getStyle(int column) {
return mStyle[column];
}
}
@@ -0,0 +1,373 @@
package com.termux.terminal;
import android.annotation.SuppressLint;
import android.os.Handler;
import android.os.Message;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* A terminal session, consisting of a process coupled to a terminal interface.
* <p>
* The subprocess will be executed by the constructor, and when the size is made known by a call to
* {@link #updateSize(int, int, int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O.
* All terminal emulation and callback methods will be performed on the main thread.
* <p>
* The child process may be exited forcefully by using the {@link #finishIfRunning()} method.
* <p>
* NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks!
*/
public final class TerminalSession extends TerminalOutput {
private static final int MSG_NEW_INPUT = 1;
private static final int MSG_PROCESS_EXITED = 4;
public final String mHandle = UUID.randomUUID().toString();
TerminalEmulator mEmulator;
/**
* A queue written to from a separate thread when the process outputs, and read by main thread to process by
* terminal emulator.
*/
final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096);
/**
* A queue written to from the main thread due to user interaction, and read by another thread which forwards by
* writing to the {@link #mTerminalFileDescriptor}.
*/
final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096);
/** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */
private final byte[] mUtf8InputBuffer = new byte[5];
/** Callback which gets notified when a session finishes or changes title. */
TerminalSessionClient mClient;
/** The pid of the shell process. 0 if not started and -1 if finished running. */
int mShellPid;
/** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */
int mShellExitStatus;
/**
* The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling
* {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int, int, int)}.
*/
private int mTerminalFileDescriptor;
/** Set by the application for user identification of session, not by terminal. */
public String mSessionName;
final Handler mMainThreadHandler = new MainThreadHandler();
private final String mShellPath;
private final String mCwd;
private final String[] mArgs;
private final String[] mEnv;
private final Integer mTranscriptRows;
private static final String LOG_TAG = "TerminalSession";
public TerminalSession(String shellPath, String cwd, String[] args, String[] env, Integer transcriptRows, TerminalSessionClient client) {
this.mShellPath = shellPath;
this.mCwd = cwd;
this.mArgs = args;
this.mEnv = env;
this.mTranscriptRows = transcriptRows;
this.mClient = client;
}
/**
* @param client The {@link TerminalSessionClient} interface implementation to allow
* for communication between {@link TerminalSession} and its client.
*/
public void updateTerminalSessionClient(TerminalSessionClient client) {
mClient = client;
if (mEmulator != null)
mEmulator.updateTerminalSessionClient(client);
}
/** Inform the attached pty of the new size and reflow or initialize the emulator. */
public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) {
if (mEmulator == null) {
initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels);
} else {
JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns, cellWidthPixels, cellHeightPixels);
mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels);
}
}
/** The terminal title as set through escape sequences or null if none set. */
public String getTitle() {
return (mEmulator == null) ? null : mEmulator.getTitle();
}
/**
* Set the terminal emulator's window size and start terminal emulation.
*
* @param columns The number of columns in the terminal window.
* @param rows The number of rows in the terminal window.
*/
public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) {
mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient);
int[] processId = new int[1];
mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns, cellWidthPixels, cellHeightPixels);
mShellPid = processId[0];
mClient.setTerminalShellPid(this, mShellPid);
final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient);
new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
@Override
public void run() {
try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) {
final byte[] buffer = new byte[4096];
while (true) {
int read = termIn.read(buffer);
if (read == -1) return;
if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return;
mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT);
}
} catch (Exception e) {
// Ignore, just shutting down.
}
}
}.start();
new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") {
@Override
public void run() {
final byte[] buffer = new byte[4096];
try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) {
while (true) {
int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true);
if (bytesToWrite == -1) return;
termOut.write(buffer, 0, bytesToWrite);
}
} catch (IOException e) {
// Ignore.
}
}
}.start();
new Thread("TermSessionWaiter[pid=" + mShellPid + "]") {
@Override
public void run() {
int processExitCode = JNI.waitFor(mShellPid);
mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode));
}
}.start();
}
/** Write data to the shell process. */
@Override
public void write(byte[] data, int offset, int count) {
if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count);
}
/** Write the Unicode code point to the terminal encoded in UTF-8. */
public void writeCodePoint(boolean prependEscape, int codePoint) {
if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
// 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range.
throw new IllegalArgumentException("Invalid code point: " + codePoint);
}
int bufferPosition = 0;
if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27;
if (codePoint <= /* 7 bits */0b1111111) {
mUtf8InputBuffer[bufferPosition++] = (byte) codePoint;
} else if (codePoint <= /* 11 bits */0b11111111111) {
/* 110xxxxx leading byte with leading 5 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
} else if (codePoint <= /* 16 bits */0b1111111111111111) {
/* 1110xxxx leading byte with leading 4 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
} else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */
/* 11110xxx leading byte with leading 3 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
}
write(mUtf8InputBuffer, 0, bufferPosition);
}
public TerminalEmulator getEmulator() {
return mEmulator;
}
/** Notify the {@link #mClient} that the screen has changed. */
protected void notifyScreenUpdate() {
mClient.onTextChanged(this);
}
/** Reset state for terminal emulator state. */
public void reset() {
mEmulator.reset();
notifyScreenUpdate();
}
/** Finish this terminal session by sending SIGKILL to the shell. */
public void finishIfRunning() {
if (isRunning()) {
try {
Os.kill(mShellPid, OsConstants.SIGKILL);
} catch (ErrnoException e) {
Logger.logWarn(mClient, LOG_TAG, "Failed sending SIGKILL: " + e.getMessage());
}
}
}
/** Cleanup resources when the process exits. */
void cleanupResources(int exitStatus) {
synchronized (this) {
mShellPid = -1;
mShellExitStatus = exitStatus;
}
// Stop the reader and writer threads, and close the I/O streams
mTerminalToProcessIOQueue.close();
mProcessToTerminalIOQueue.close();
JNI.close(mTerminalFileDescriptor);
}
@Override
public void titleChanged(String oldTitle, String newTitle) {
mClient.onTitleChanged(this);
}
public synchronized boolean isRunning() {
return mShellPid != -1;
}
/** Only valid if not {@link #isRunning()}. */
public synchronized int getExitStatus() {
return mShellExitStatus;
}
@Override
public void onCopyTextToClipboard(String text) {
mClient.onCopyTextToClipboard(this, text);
}
@Override
public void onPasteTextFromClipboard() {
mClient.onPasteTextFromClipboard(this);
}
@Override
public void onBell() {
mClient.onBell(this);
}
@Override
public void onColorsChanged() {
mClient.onColorsChanged(this);
}
public int getPid() {
return mShellPid;
}
/** Returns the shell's working directory or null if it was unavailable. */
public String getCwd() {
if (mShellPid < 1) {
return null;
}
try {
final String cwdSymlink = String.format("/proc/%s/cwd/", mShellPid);
String outputPath = new File(cwdSymlink).getCanonicalPath();
String outputPathWithTrailingSlash = outputPath;
if (!outputPath.endsWith("/")) {
outputPathWithTrailingSlash += '/';
}
if (!cwdSymlink.equals(outputPathWithTrailingSlash)) {
return outputPath;
}
} catch (IOException | SecurityException e) {
Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Error getting current directory", e);
}
return null;
}
private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) {
FileDescriptor result = new FileDescriptor();
try {
Field descriptorField;
try {
descriptorField = FileDescriptor.class.getDeclaredField("descriptor");
} catch (NoSuchFieldException e) {
// For desktop java:
descriptorField = FileDescriptor.class.getDeclaredField("fd");
}
descriptorField.setAccessible(true);
descriptorField.set(result, fileDescriptor);
} catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
Logger.logStackTraceWithMessage(client, LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e);
System.exit(1);
}
return result;
}
@SuppressLint("HandlerLeak")
class MainThreadHandler extends Handler {
final byte[] mReceiveBuffer = new byte[4 * 1024];
@Override
public void handleMessage(Message msg) {
int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false);
if (bytesRead > 0) {
mEmulator.append(mReceiveBuffer, bytesRead);
notifyScreenUpdate();
}
if (msg.what == MSG_PROCESS_EXITED) {
int exitCode = (Integer) msg.obj;
cleanupResources(exitCode);
String exitDescription = "\r\n[Process completed";
if (exitCode > 0) {
// Non-zero process exit.
exitDescription += " (code " + exitCode + ")";
} else if (exitCode < 0) {
// Negated signal.
exitDescription += " (signal " + (-exitCode) + ")";
}
exitDescription += " - press Enter]";
byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8);
mEmulator.append(bytesToWrite, bytesToWrite.length);
notifyScreenUpdate();
mClient.onSessionFinished(TerminalSession.this);
}
}
}
}
@@ -0,0 +1,51 @@
package com.termux.terminal;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* The interface for communication between {@link TerminalSession} and its client. It is used to
* send callbacks to the client when {@link TerminalSession} changes or for sending other
* back data to the client like logs.
*/
public interface TerminalSessionClient {
void onTextChanged(@NonNull TerminalSession changedSession);
void onTitleChanged(@NonNull TerminalSession changedSession);
void onSessionFinished(@NonNull TerminalSession finishedSession);
void onCopyTextToClipboard(@NonNull TerminalSession session, String text);
void onPasteTextFromClipboard(@Nullable TerminalSession session);
void onBell(@NonNull TerminalSession session);
void onColorsChanged(@NonNull TerminalSession session);
void onTerminalCursorStateChange(boolean state);
void setTerminalShellPid(@NonNull TerminalSession session, int pid);
Integer getTerminalCursorStyle();
void logError(String tag, String message);
void logWarn(String tag, String message);
void logInfo(String tag, String message);
void logDebug(String tag, String message);
void logVerbose(String tag, String message);
void logStackTraceWithMessage(String tag, String message, Exception e);
void logStackTrace(String tag, Exception e);
}
@@ -0,0 +1,90 @@
package com.termux.terminal;
/**
* <p>
* Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal
* row in {@link TerminalRow#mStyle}.
* </p>
* <p>
* The bit layout is:
* </p>
* - 16 flags (11 currently used).
* - 24 for foreground color (only 9 first bits if a color index).
* - 24 for background color (only 9 first bits if a color index).
*/
public final class TextStyle {
public final static int CHARACTER_ATTRIBUTE_BOLD = 1;
public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1;
public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2;
public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3;
public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4;
public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5;
public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6;
/**
* The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable.
* <p>
* This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that
* come after it as erasable from the screen.
* </p>
*/
public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7;
/** Dim colors. Also known as faint or half intensity. */
public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8;
/** If true (24-bit) color is used for the cell for foreground. */
private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9;
/** If true (24-bit) color is used for the cell for foreground. */
private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10;
public final static int COLOR_INDEX_FOREGROUND = 256;
public final static int COLOR_INDEX_BACKGROUND = 257;
public final static int COLOR_INDEX_CURSOR = 258;
/** The 256 standard color entries and the three special (foreground, background and cursor) ones. */
public final static int NUM_INDEXED_COLORS = 259;
/** Normal foreground and background colors and no effects. */
final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0);
static long encode(int foreColor, int backColor, int effect) {
long result = effect & 0b111111111;
if ((0xff000000 & foreColor) == 0xff000000) {
// 24-bit color.
result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L);
} else {
// Indexed color.
result |= (foreColor & 0b111111111L) << 40;
}
if ((0xff000000 & backColor) == 0xff000000) {
// 24-bit color.
result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L);
} else {
// Indexed color.
result |= (backColor & 0b111111111L) << 16L;
}
return result;
}
public static int decodeForeColor(long style) {
if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) {
return (int) ((style >>> 40) & 0b111111111L);
} else {
return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL);
}
}
public static int decodeBackColor(long style) {
if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) {
return (int) ((style >>> 16) & 0b111111111L);
} else {
return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL);
}
}
public static int decodeEffect(long style) {
return (int) (style & 0b11111111111);
}
}
@@ -0,0 +1,566 @@
package com.termux.terminal;
/**
* Implementation of wcwidth(3) for Unicode 15.
*
* Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
*
* IMPORTANT:
* Must be kept in sync with the following:
* https://github.com/termux/wcwidth
* https://github.com/termux/libandroid-support
* https://github.com/termux/termux-packages/tree/master/packages/libandroid-support
*/
public final class WcWidth {
// From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
// from https://github.com/jquast/wcwidth/pull/64
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
private static final int[][] ZERO_WIDTH = {
{0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le
{0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli
{0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg
{0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe
{0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
{0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
{0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
{0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra
{0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below
{0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip
{0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen
{0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda
{0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon
{0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem
{0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip
{0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh
{0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun
{0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot
{0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan
{0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh
{0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A
{0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
{0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
{0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark
{0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M
{0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S
{0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara
{0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
{0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta
{0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
{0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama
{0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
{0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
{0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu
{0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta
{0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
{0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama
{0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
{0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark
{0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
{0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
{0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
{0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
{0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
{0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
{0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak
{0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
{0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara
{0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta
{0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
{0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
{0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama
{0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
{0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle
{0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu
{0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta
{0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I
{0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic
{0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama
{0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark
{0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic
{0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara
{0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
{0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama
{0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca
{0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An
{0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta
{0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
{0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
{0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama
{0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark
{0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
{0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu
{0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta
{0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I
{0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E
{0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama
{0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
{0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin
{0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular
{0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
{0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama
{0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
{0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu
{0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
{0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
{0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
{0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a
{0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu
{0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan
{0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
{0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo
{0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil)
{0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig
{0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
{0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
{0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
{0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
{0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
{0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
{0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
{0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter
{0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
{0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
{0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
{0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat
{0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
{0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
{0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
{0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
{0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S
{0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
{0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
{0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
{0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin
{0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama
{0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U
{0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U
{0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
{0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
{0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
{0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit
{0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
{0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan
{0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation
{0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation
{0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
{0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
{0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U
{0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O
{0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv
{0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i
{0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U
{0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
{0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
{0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
{0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
{0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
{0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
{0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
{0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt
{0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le
{0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang
{0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan
{0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
{0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L
{0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
{0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol
{0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
{0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
{0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
{0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign
{0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi
{0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
{0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
{0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H
{0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
{0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta
{0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha
{0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
{0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
{0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak
{0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above
{0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A
{0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea
{0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above
{0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu
{0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine
{0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette
{0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton
{0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag
{0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous
{0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer
{0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette
{0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
{0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
{0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
{0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
{0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
{0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern
{0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi
{0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig
{0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay
{0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
{0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R
{0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar
{0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
{0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
{0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign
{0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
{0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
{0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue
{0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa
{0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
{0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
{0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
{0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang
{0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U
{0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia
{0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
{0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
{0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
{0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama
{0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
{0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
{0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
{0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
{0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16
{0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo
{0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
{0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M
{0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let
{0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
{0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
{0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
{0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
{0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama
{0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation
{0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas
{0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M
{0x10efd, 0x10eff}, // (nil) ..(nil)
{0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke
{0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two
{0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
{0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama
{0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi
{0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta
{0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara
{0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
{0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta
{0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali
{0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga
{0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
{0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa
{0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta
{0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara
{0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O
{0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe
{0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca
{0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
{0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara
{0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda
{0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun
{0x11241, 0x11241}, // (nil) ..(nil)
{0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
{0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
{0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu
{0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta
{0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
{0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit
{0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter
{0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai
{0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara
{0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta
{0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark
{0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
{0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short
{0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
{0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta
{0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
{0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara
{0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta
{0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
{0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai
{0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara
{0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra
{0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara
{0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
{0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au
{0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta
{0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
{0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
{0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer
{0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara
{0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta
{0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab
{0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama
{0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta
{0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V
{0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A
{0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama
{0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L
{0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An
{0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster
{0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin
{0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe
{0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar
{0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara
{0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner
{0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc
{0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara
{0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama
{0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter
{0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa
{0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E
{0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu
{0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
{0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
{0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
{0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama
{0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara
{0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign
{0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv
{0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama
{0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U
{0x11f00, 0x11f01}, // (nil) ..(nil)
{0x11f36, 0x11f3a}, // (nil) ..(nil)
{0x11f40, 0x11f40}, // (nil) ..(nil)
{0x11f42, 0x11f42}, // (nil) ..(nil)
{0x13440, 0x13440}, // (nil) ..(nil)
{0x13447, 0x13455}, // (nil) ..(nil)
{0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High
{0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
{0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi
{0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below
{0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill
{0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark
{0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark
{0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie
{0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining
{0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining
{0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining
{0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining
{0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical
{0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking
{0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement
{0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T
{0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea
{0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie
{0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod
{0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let
{0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let
{0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let
{0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let
{0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let
{0x1e08f, 0x1e08f}, // (nil) ..(nil)
{0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T
{0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone
{0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini
{0x1e4ec, 0x1e4ef}, // (nil) ..(nil)
{0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining
{0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta
{0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256
};
// https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
// from https://github.com/jquast/wcwidth/pull/64
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
private static final int[][] WIDE_EASTASIAN = {
{0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler
{0x0231a, 0x0231b}, // Watch ..Hourglass
{0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra
{0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub
{0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock
{0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S
{0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar
{0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage
{0x02648, 0x02653}, // Aries ..Pisces
{0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol
{0x02693, 0x02693}, // Anchor ..Anchor
{0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign
{0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle
{0x026bd, 0x026be}, // Soccer Ball ..Baseball
{0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud
{0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus
{0x026d4, 0x026d4}, // No Entry ..No Entry
{0x026ea, 0x026ea}, // Church ..Church
{0x026f2, 0x026f3}, // Fountain ..Flag In Hole
{0x026f5, 0x026f5}, // Sailboat ..Sailboat
{0x026fa, 0x026fa}, // Tent ..Tent
{0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump
{0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark
{0x0270a, 0x0270b}, // Raised Fist ..Raised Hand
{0x02728, 0x02728}, // Sparkles ..Sparkles
{0x0274c, 0x0274c}, // Cross Mark ..Cross Mark
{0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M
{0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O
{0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S
{0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign
{0x027b0, 0x027b0}, // Curly Loop ..Curly Loop
{0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop
{0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square
{0x02b50, 0x02b50}, // White Medium Star ..White Medium Star
{0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle
{0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap
{0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified
{0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute
{0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description
{0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In
{0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke
{0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto
{0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn
{0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae
{0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q
{0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha
{0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto
{0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d
{0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr
{0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke
{0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
{0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih
{0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil)
{0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve
{0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop
{0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign
{0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At
{0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa
{0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign
{0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill
{0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea
{0x17000, 0x187f7}, // (nil) ..(nil)
{0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char
{0x18d00, 0x18d08}, // (nil) ..(nil)
{0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T
{0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N
{0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N
{0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic
{0x1b132, 0x1b132}, // (nil) ..(nil)
{0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo
{0x1b155, 0x1b155}, // (nil) ..(nil)
{0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N
{0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb
{0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
{0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker
{0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab
{0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs
{0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa
{0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo
{0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed
{0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept
{0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai
{0x1f300, 0x1f320}, // Cyclone ..Shooting Star
{0x1f32d, 0x1f335}, // Hot Dog ..Cactus
{0x1f337, 0x1f37c}, // Tulip ..Baby Bottle
{0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap
{0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer
{0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And
{0x1f3e0, 0x1f3f0}, // House Building ..European Castle
{0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag
{0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints
{0x1f440, 0x1f440}, // Eyes ..Eyes
{0x1f442, 0x1f4fc}, // Ear ..Videocassette
{0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red
{0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch
{0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty
{0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing
{0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be
{0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart
{0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands
{0x1f680, 0x1f6c5}, // Rocket ..Left Luggage
{0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation
{0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley
{0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator
{0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy
{0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving
{0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate
{0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square
{0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign
{0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer
{0x1f93c, 0x1f945}, // Wrestlers ..Goal Net
{0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet
{0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch
{0x1fa80, 0x1fa88}, // Yo-yo ..(nil)
{0x1fa90, 0x1fabd}, // Ringed Planet ..(nil)
{0x1fabf, 0x1fac5}, // (nil) ..Person With Crown
{0x1face, 0x1fadb}, // (nil) ..(nil)
{0x1fae0, 0x1fae8}, // Melting Face ..(nil)
{0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil)
{0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil)
{0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil)
};
private static boolean intable(int[][] table, int c) {
// First quick check f|| Latin1 etc. characters.
if (c < table[0][0]) return false;
// Binary search in table.
int bot = 0;
int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1);
while (top >= bot) {
int mid = (bot + top) / 2;
if (table[mid][1] < c) {
bot = mid + 1;
} else if (table[mid][0] > c) {
top = mid - 1;
} else {
return true;
}
}
return false;
}
/** Return the terminal display width of a code point: 0, 1 || 2. */
public static int width(int ucs) {
if (ucs == 0 ||
ucs == 0x034F ||
(0x200B <= ucs && ucs <= 0x200F) ||
ucs == 0x2028 ||
ucs == 0x2029 ||
(0x202A <= ucs && ucs <= 0x202E) ||
(0x2060 <= ucs && ucs <= 0x2063)) {
return 0;
}
// C0/C1 control characters
// Termux change: Return 0 instead of -1.
if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0;
// combining characters with zero width
if (intable(ZERO_WIDTH, ucs)) return 0;
return intable(WIDE_EASTASIAN, ucs) ? 2 : 1;
}
/** The width at an index position in a java char array. */
public static int width(char[] chars, int index) {
char c = chars[index];
return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c);
}
/**
* The zero width characters count like combining characters in the `chars` array from start
* index to end index (exclusive).
*/
public static int zeroWidthCharsCount(char[] chars, int start, int end) {
if (start < 0 || start >= chars.length)
return 0;
int count = 0;
for (int i = start; i < end && i < chars.length;) {
if (Character.isHighSurrogate(chars[i])) {
if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) {
count++;
}
i += 2;
} else {
if (width(chars[i]) <= 0) {
count++;
}
i++;
}
}
return count;
}
}
@@ -0,0 +1,5 @@
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE:= libtermux
LOCAL_SRC_FILES:= termux.c
include $(BUILD_SHARED_LIBRARY)
@@ -0,0 +1,218 @@
#include <dirent.h>
#include <fcntl.h>
#include <jni.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
#define TERMUX_UNUSED(x) x __attribute__((__unused__))
#ifdef __APPLE__
# define LACKS_PTSNAME_R
#endif
static int throw_runtime_exception(JNIEnv* env, char const* message)
{
jclass exClass = (*env)->FindClass(env, "java/lang/RuntimeException");
(*env)->ThrowNew(env, exClass, message);
return -1;
}
static int create_subprocess(JNIEnv* env,
char const* cmd,
char const* cwd,
char* const argv[],
char** envp,
int* pProcessId,
jint rows,
jint columns,
jint cell_width,
jint cell_height)
{
int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx");
#ifdef LACKS_PTSNAME_R
char* devname;
#else
char devname[64];
#endif
if (grantpt(ptm) || unlockpt(ptm) ||
#ifdef LACKS_PTSNAME_R
(devname = ptsname(ptm)) == NULL
#else
ptsname_r(ptm, devname, sizeof(devname))
#endif
) {
return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx");
}
// Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display.
struct termios tios;
tcgetattr(ptm, &tios);
tios.c_iflag |= IUTF8;
tios.c_iflag &= ~(IXON | IXOFF);
tcsetattr(ptm, TCSANOW, &tios);
/** Set initial winsize. */
struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns, .ws_xpixel = (unsigned short) (columns * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height)};
ioctl(ptm, TIOCSWINSZ, &sz);
pid_t pid = fork();
if (pid < 0) {
return throw_runtime_exception(env, "Fork failed");
} else if (pid > 0) {
*pProcessId = (int) pid;
return ptm;
} else {
// Clear signals which the Android java process may have blocked:
sigset_t signals_to_unblock;
sigfillset(&signals_to_unblock);
sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0);
close(ptm);
setsid();
int pts = open(devname, O_RDWR);
if (pts < 0) exit(-1);
dup2(pts, 0);
dup2(pts, 1);
dup2(pts, 2);
DIR* self_dir = opendir("/proc/self/fd");
if (self_dir != NULL) {
int self_dir_fd = dirfd(self_dir);
struct dirent* entry;
while ((entry = readdir(self_dir)) != NULL) {
int fd = atoi(entry->d_name);
if (fd > 2 && fd != self_dir_fd) close(fd);
}
closedir(self_dir);
}
clearenv();
if (envp) for (; *envp; ++envp) putenv(*envp);
if (chdir(cwd) != 0) {
char* error_message;
// No need to free asprintf()-allocated memory since doing execvp() or exit() below.
if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1) error_message = "chdir()";
perror(error_message);
fflush(stderr);
}
execvp(cmd, argv);
// Show terminal output about failing exec() call:
char* error_message;
if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1) error_message = "exec()";
perror(error_message);
_exit(1);
}
}
JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
JNIEnv* env,
jclass TERMUX_UNUSED(clazz),
jstring cmd,
jstring cwd,
jobjectArray args,
jobjectArray envVars,
jintArray processIdArray,
jint rows,
jint columns,
jint cell_width,
jint cell_height)
{
jsize size = args ? (*env)->GetArrayLength(env, args) : 0;
char** argv = NULL;
if (size > 0) {
argv = (char**) malloc((size + 1) * sizeof(char*));
if (!argv) return throw_runtime_exception(env, "Couldn't allocate argv array");
for (int i = 0; i < size; ++i) {
jstring arg_java_string = (jstring) (*env)->GetObjectArrayElement(env, args, i);
char const* arg_utf8 = (*env)->GetStringUTFChars(env, arg_java_string, NULL);
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
argv[i] = strdup(arg_utf8);
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
}
argv[size] = NULL;
}
size = envVars ? (*env)->GetArrayLength(env, envVars) : 0;
char** envp = NULL;
if (size > 0) {
envp = (char**) malloc((size + 1) * sizeof(char *));
if (!envp) return throw_runtime_exception(env, "malloc() for envp array failed");
for (int i = 0; i < size; ++i) {
jstring env_java_string = (jstring) (*env)->GetObjectArrayElement(env, envVars, i);
char const* env_utf8 = (*env)->GetStringUTFChars(env, env_java_string, 0);
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
envp[i] = strdup(env_utf8);
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
}
envp[size] = NULL;
}
int procId = 0;
char const* cmd_cwd = (*env)->GetStringUTFChars(env, cwd, NULL);
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns, cell_width, cell_height);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
if (argv) {
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
free(argv);
}
if (envp) {
for (char** tmp = envp; *tmp; ++tmp) free(*tmp);
free(envp);
}
int* pProcId = (int*) (*env)->GetPrimitiveArrayCritical(env, processIdArray, NULL);
if (!pProcId) return throw_runtime_exception(env, "JNI call GetPrimitiveArrayCritical(processIdArray, &isCopy) failed");
*pProcId = procId;
(*env)->ReleasePrimitiveArrayCritical(env, processIdArray, pProcId, 0);
return ptm;
}
JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyWindowSize(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd, jint rows, jint cols, jint cell_width, jint cell_height)
{
struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols, .ws_xpixel = (unsigned short) (cols * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height) };
ioctl(fd, TIOCSWINSZ, &sz);
}
JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyUTF8Mode(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd)
{
struct termios tios;
tcgetattr(fd, &tios);
if ((tios.c_iflag & IUTF8) == 0) {
tios.c_iflag |= IUTF8;
tcsetattr(fd, TCSANOW, &tios);
}
}
JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_waitFor(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint pid)
{
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
return -WTERMSIG(status);
} else {
// Should never happen - waitpid(2) says "One of the first three macros will evaluate to a non-zero (true) value".
return 0;
}
}
JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_close(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fileDescriptor)
{
close(fileDescriptor);
}
@@ -0,0 +1,21 @@
package com.termux.terminal;
public class ApcTest extends TerminalTestCase {
public void testApcConsumed() {
// At time of writing this is part of what yazi sends for probing for kitty graphics protocol support:
// https://github.com/sxyazi/yazi/blob/0cdaff98d0b3723caff63eebf1974e7907a43a2c/yazi-adapter/src/emulator.rs#L129
// This should not result in anything being written to the screen: If kitty graphics protocol support
// is implemented it should instead result in an error code on stdin, and if not it should be consumed
// silently just as xterm does. See https://sw.kovidgoyal.net/kitty/graphics-protocol/.
withTerminalSized(2, 2)
.enterString("\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\")
.assertLinesAre(" ", " ");
// It is ok for the APC content to be non printable characters:
withTerminalSized(12, 2)
.enterString("hello \033_some\023\033_\\apc#end\033\\ world")
.assertLinesAre("hello world", " ");
}
}
@@ -0,0 +1,54 @@
package com.termux.terminal;
import junit.framework.TestCase;
public class ByteQueueTest extends TestCase {
private static void assertArrayEquals(byte[] expected, byte[] actual) {
if (expected.length != actual.length) {
fail("Difference array length");
}
for (int i = 0; i < expected.length; i++) {
if (expected[i] != actual[i]) {
fail("Inequals at index=" + i + ", expected=" + (int) expected[i] + ", actual=" + (int) actual[i]);
}
}
}
public void testCompleteWrites() throws Exception {
ByteQueue q = new ByteQueue(10);
assertTrue(q.write(new byte[]{1, 2, 3}, 0, 3));
byte[] arr = new byte[10];
assertEquals(3, q.read(arr, true));
assertArrayEquals(new byte[]{1, 2, 3}, new byte[]{arr[0], arr[1], arr[2]});
assertTrue(q.write(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0, 10));
assertEquals(10, q.read(arr, true));
assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, arr);
}
public void testQueueWraparound() throws Exception {
ByteQueue q = new ByteQueue(10);
byte[] origArray = new byte[]{1, 2, 3, 4, 5, 6};
byte[] readArray = new byte[origArray.length];
for (int i = 0; i < 20; i++) {
q.write(origArray, 0, origArray.length);
assertEquals(origArray.length, q.read(readArray, true));
assertArrayEquals(origArray, readArray);
}
}
public void testWriteNotesClosing() throws Exception {
ByteQueue q = new ByteQueue(10);
q.close();
assertFalse(q.write(new byte[]{1, 2, 3}, 0, 3));
}
public void testReadNonBlocking() throws Exception {
ByteQueue q = new ByteQueue(10);
assertEquals(0, q.read(new byte[128], false));
}
}
@@ -0,0 +1,131 @@
package com.termux.terminal;
import java.util.List;
/** "\033[" is the Control Sequence Introducer char sequence (CSI). */
public class ControlSequenceIntroducerTest extends TerminalTestCase {
/** CSI Ps P Scroll down Ps lines (default = 1) (SD). */
public void testCsiT() {
withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[2Tyo\r\nA\r\nB").assertLinesAre(" ", " ", "1 ", "2 yo", "A ",
"Bi ");
// Default value (1):
withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[Tyo\r\nA\r\nB").assertLinesAre(" ", "1 ", "2 ", "3 yo", "Ai ",
"B ");
}
/** CSI Ps S Scroll up Ps lines (default = 1) (SU). */
public void testCsiS() {
// The behaviour here is a bit inconsistent between terminals - this is how the OS X Terminal.app does it:
withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[2Sy").assertLinesAre("3 ", "hi ", " ", " y");
// Default value (1):
withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[Sy").assertLinesAre("2 ", "3 ", "hi ", " y");
}
/** CSI Ps X Erase Ps Character(s) (default = 1) (ECH). */
public void testCsiX() {
// See https://code.google.com/p/chromium/issues/detail?id=212712 where test was extraced from.
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[X").assertLinesAre("abcdefg ijkl ", " ");
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[1X").assertLinesAre("abcdefg ijkl ", " ");
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[2X").assertLinesAre("abcdefg jkl ", " ");
withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[20X").assertLinesAre("abcdefg ", " ");
}
/** CSI Pm m Set SGR parameter(s) from semicolon-separated list Pm. */
public void testCsiSGRParameters() {
// Set more parameters (19) than supported (16). Additional parameters should be silently consumed.
withTerminalSized(3, 2).enterString("\033[0;38;2;255;255;255;48;2;0;0;0;1;2;3;4;5;7;8;9mabc").assertLinesAre("abc", " ");
}
/** CSI Ps b Repeat the preceding graphic character Ps times (REP). */
public void testRepeat() {
withTerminalSized(3, 2).enterString("a\033[b").assertLinesAre("aa ", " ");
withTerminalSized(3, 2).enterString("a\033[2b").assertLinesAre("aaa", " ");
// When no char has been output we ignore REP:
withTerminalSized(3, 2).enterString("\033[b").assertLinesAre(" ", " ");
// This shows that REP outputs the last emitted code point and not the one relative to the
// current cursor position:
withTerminalSized(5, 2).enterString("abcde\033[2G\033[2b\n").assertLinesAre("aeede", " ");
}
/** CSI 3 J Clear scrollback (xterm, libvte; non-standard). */
public void testCsi3J() {
withTerminalSized(3, 2).enterString("a\r\nb\r\nc\r\nd");
assertEquals("a\nb\nc\nd", mTerminal.getScreen().getTranscriptText());
enterString("\033[3J");
assertEquals("c\nd", mTerminal.getScreen().getTranscriptText());
withTerminalSized(3, 2).enterString("Lorem_ipsum");
assertEquals("Lorem_ipsum", mTerminal.getScreen().getTranscriptText());
enterString("\033[3J");
assertEquals("ipsum", mTerminal.getScreen().getTranscriptText());
withTerminalSized(3, 2).enterString("w\r\nx\r\ny\r\nz\033[?1049h\033[3J\033[?1049l");
assertEquals("y\nz", mTerminal.getScreen().getTranscriptText());
}
public void testReportPixelSize() {
int columns = 3;
int rows = 3;
withTerminalSized(columns, rows);
int cellWidth = TerminalTest.INITIAL_CELL_WIDTH_PIXELS;
int cellHeight = TerminalTest.INITIAL_CELL_HEIGHT_PIXELS;
assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t");
assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t");
columns = 23;
rows = 33;
resize(columns, rows);
assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t");
assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t");
cellWidth = 8;
cellHeight = 18;
mTerminal.resize(columns, rows, cellWidth, cellHeight);
assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t");
assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t");
}
/**
* See <a href="https://sw.kovidgoyal.net/kitty/underlines/">Colored and styled underlines</a>:
*
* <pre>
* <ESC>[4:0m # no underline
* <ESC>[4:1m # straight underline
* <ESC>[4:2m # double underline
* <ESC>[4:3m # curly underline
* <ESC>[4:4m # dotted underline
* <ESC>[4:5m # dashed underline
* <ESC>[4m # straight underline (for backwards compat)
* <ESC>[24m # no underline (for backwards compat)
* </pre>
* <p>
* We currently parse the variants, but map them to normal/no underlines as appropriate
*/
public void testUnderlineVariants() {
for (String suffix : List.of("", ":1", ":2", ":3", ":4", ":5")) {
for (String stop : List.of("24", "4:0")) {
withTerminalSized(3, 3);
enterString("\033[4" + suffix + "m").assertLinesAre(" ", " ", " ");
assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect);
enterString("\033[4;1m").assertLinesAre(" ", " ", " ");
assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect);
enterString("\033[" + stop + "m").assertLinesAre(" ", " ", " ");
assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD, mTerminal.mEffect);
}
}
}
public void testManyParameters() {
StringBuilder b = new StringBuilder("\033[");
for (int i = 0; i < 30; i++) {
b.append("0;");
}
b.append("4:2");
// This clearing of underline should be ignored as the parameters pass the threshold for too many parameters:
b.append("4:0m");
withTerminalSized(3, 3)
.enterString(b.toString())
.assertLinesAre(" ", " ", " ");
assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect);
}
}
@@ -0,0 +1,266 @@
package com.termux.terminal;
import org.junit.Assert;
public class CursorAndScreenTest extends TerminalTestCase {
public void testDeleteLinesKeepsStyles() {
int cols = 5, rows = 5;
withTerminalSized(cols, rows);
for (int row = 0; row < 5; row++) {
for (int col = 0; col < 5; col++) {
// Foreground color to col, background to row:
enterString("\033[38;5;" + col + "m");
enterString("\033[48;5;" + row + "m");
enterString(Character.toString((char) ('A' + col + row * 5)));
}
}
assertLinesAre("ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY");
for (int row = 0; row < 5; row++) {
for (int col = 0; col < 5; col++) {
long s = getStyleAt(row, col);
Assert.assertEquals(col, TextStyle.decodeForeColor(s));
Assert.assertEquals(row, TextStyle.decodeBackColor(s));
}
}
// "${CSI}H" - place cursor at 1,1, then "${CSI}2M" to delete two lines.
enterString("\033[H\033[2M");
assertLinesAre("KLMNO", "PQRST", "UVWXY", " ", " ");
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 5; col++) {
long s = getStyleAt(row, col);
Assert.assertEquals(col, TextStyle.decodeForeColor(s));
Assert.assertEquals(row + 2, TextStyle.decodeBackColor(s));
}
}
// Set default fg and background for the new blank lines:
enterString("\033[38;5;98m");
enterString("\033[48;5;99m");
// "${CSI}B" to go down one line, then "${CSI}2L" to insert two lines:
enterString("\033[B\033[2L");
assertLinesAre("KLMNO", " ", " ", "PQRST", "UVWXY");
for (int row = 0; row < 5; row++) {
for (int col = 0; col < 5; col++) {
int wantedForeground = (row == 1 || row == 2) ? 98 : col;
int wantedBackground = (row == 1 || row == 2) ? 99 : (row == 0 ? 2 : row);
long s = getStyleAt(row, col);
Assert.assertEquals(wantedForeground, TextStyle.decodeForeColor(s));
Assert.assertEquals(wantedBackground, TextStyle.decodeBackColor(s));
}
}
}
public void testDeleteCharacters() {
withTerminalSized(5, 2).enterString("枝ce").assertLinesAre("枝ce ", " ");
withTerminalSized(5, 2).enterString("a枝ce").assertLinesAre("a枝ce", " ");
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[P").assertLinesAre("ice ", " ");
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[2P").assertLinesAre("ne ", " ");
// "${CSI}${n}P, the delete characters (DCH) sequence should cap characters to delete.
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[99P").assertLinesAre(" ", " ");
// With combining char U+0302.
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[P").assertLinesAre("ice ", " ");
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[2G\033[2P").assertLinesAre("n\u0302e ", " ");
// With wide 枝 char, checking that putting char at part replaces other with whitespace:
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[Ga").assertLinesAre("a ce ", " ");
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2Ga").assertLinesAre(" ace ", " ");
// With wide 枝 char, deleting either part replaces other with whitespace:
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[P").assertLinesAre(" ce ", " ");
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[P").assertLinesAre(" ce ", " ");
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[2P").assertLinesAre(" e ", " ");
withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
withTerminalSized(5, 2).enterString("a枝ce").enterString("\033[G\033[P").assertLinesAre("枝ce ", " ");
}
public void testInsertMode() {
// "${CSI}4h" enables insert mode.
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hA").assertLinesAre("Anice", " ");
withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4hA").assertLinesAre("nAice", " ");
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hABC").assertLinesAre("ABCni", " ");
// With combining char U+0302.
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hA").assertLinesAre("An\u0302ice", " ");
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hAB").assertLinesAre("ABn\u0302ic", " ");
withTerminalSized(5, 2).enterString("n\u0302ic\u0302e").enterString("\033[2G\033[4hA").assertLinesAre("n\u0302Aic\u0302e", " ");
// ... but without insert mode, combining char should be overwritten:
withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[GA").assertLinesAre("Aice ", " ");
// ... also with two combining:
withTerminalSized(5, 2).enterString("n\u0302\u0302i\u0302ce").enterString("\033[GA").assertLinesAre("Ai\u0302ce ", " ");
// ... and in last column:
withTerminalSized(5, 2).enterString("n\u0302\u0302ice!\u0302").enterString("\033[5GA").assertLinesAre("n\u0302\u0302iceA", " ");
withTerminalSized(5, 2).enterString("nic\u0302e!\u0302").enterString("\033[4G枝").assertLinesAre("nic\u0302枝", " ");
withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " ");
withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " ");
// With wide 枝 char.
withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4h枝").assertLinesAre("枝nic", " ");
withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4h枝").assertLinesAre("n枝ic", " ");
withTerminalSized(5, 2).enterString("n枝ce").enterString("\033[G\033[4ha").assertLinesAre("an枝c", " ");
}
/** HPA—Horizontal Position Absolute (http://www.vt100.net/docs/vt510-rm/HPA) */
public void testCursorHorizontalPositionAbsolute() {
withTerminalSized(4, 4).enterString("ABC\033[`").assertCursorAt(0, 0);
enterString("\033[1`").assertCursorAt(0, 0).enterString("\033[2`").assertCursorAt(0, 1);
enterString("\r\n\033[3`").assertCursorAt(1, 2).enterString("\033[22`").assertCursorAt(1, 3);
// Enable and configure right and left margins, first without origin mode:
enterString("\033[?69h\033[2;3s\033[`").assertCursorAt(0, 0).enterString("\033[22`").assertCursorAt(0, 3);
// .. now with origin mode:
enterString("\033[?6h\033[`").assertCursorAt(0, 1).enterString("\033[22`").assertCursorAt(0, 2);
}
public void testCursorForward() {
// "${CSI}${N:=1}C" moves cursor forward N columns:
withTerminalSized(6, 2).enterString("A\033[CB\033[2CC").assertLinesAre("A B C", " ");
// If an attempt is made to move the cursor to the right of the right margin, the cursor stops at the right margin:
withTerminalSized(6, 2).enterString("A\033[44CB").assertLinesAre("A B", " ");
// Enable right margin and verify that CUF ends at the set right margin:
withTerminalSized(6, 2).enterString("\033[?69h\033[1;3s\033[44CAB").assertLinesAre(" A ", "B ");
}
public void testCursorBack() {
// "${CSI}${N:=1}D" moves cursor back N columns:
withTerminalSized(3, 2).enterString("A\033[DB").assertLinesAre("B ", " ");
withTerminalSized(3, 2).enterString("AB\033[2DC").assertLinesAre("CB ", " ");
// If an attempt is made to move the cursor to the left of the left margin, the cursor stops at the left margin:
withTerminalSized(3, 2).enterString("AB\033[44DC").assertLinesAre("CB ", " ");
// Enable left margin and verify that CUB ends at the set left margin:
withTerminalSized(6, 2).enterString("ABCD\033[?69h\033[2;6s\033[44DE").assertLinesAre("AECD ", " ");
}
public void testCursorUp() {
// "${CSI}${N:=1}A" moves cursor up N rows:
withTerminalSized(3, 3).enterString("ABCDEFG\033[AH").assertLinesAre("ABC", "DHF", "G ");
withTerminalSized(3, 3).enterString("ABCDEFG\033[2AH").assertLinesAre("AHC", "DEF", "G ");
// If an attempt is made to move the cursor above the top margin, the cursor stops at the top margin:
withTerminalSized(3, 3).enterString("ABCDEFG\033[44AH").assertLinesAre("AHC", "DEF", "G ");
}
public void testCursorDown() {
// "${CSI}${N:=1}B" moves cursor down N rows:
withTerminalSized(3, 3).enterString("AB\033[BC").assertLinesAre("AB ", " C", " ");
withTerminalSized(3, 3).enterString("AB\033[2BC").assertLinesAre("AB ", " ", " C");
// If an attempt is made to move the cursor below the bottom margin, the cursor stops at the bottom margin:
withTerminalSized(3, 3).enterString("AB\033[44BC").assertLinesAre("AB ", " ", " C");
}
public void testReportCursorPosition() {
withTerminalSized(10, 10);
for (int i = 0; i < 10; i++) {
for (int j = 0; j < 10; j++) {
enterString("\033[" + (i + 1) + ";" + (j + 1) + "H"); // CUP cursor position.
assertCursorAt(i, j);
// Device Status Report (DSR):
assertEnteringStringGivesResponse("\033[6n", "\033[" + (i + 1) + ";" + (j + 1) + "R");
// DECXCPR — Extended Cursor Position. Note that http://www.vt100.net/docs/vt510-rm/DECXCPR says
// the response is "${CSI}${LINE};${COLUMN};${PAGE}R" while xterm (http://invisible-island.net/xterm/ctlseqs/ctlseqs.html)
// drops the question mark. Expect xterm behaviour here.
assertEnteringStringGivesResponse("\033[?6n", "\033[?" + (i + 1) + ";" + (j + 1) + ";1R");
}
}
}
/**
* See comments on horizontal tab handling in TerminalEmulator.java.
* <p/>
* We do not want to color already written cells when tabbing over them.
*/
public void DISABLED_testHorizontalTabColorsBackground() {
withTerminalSized(10, 3).enterString("\033[48;5;15m").enterString("\t");
assertCursorAt(0, 8);
for (int i = 0; i < 10; i++) {
int expectedColor = i < 8 ? 15 : TextStyle.COLOR_INDEX_BACKGROUND;
assertEquals(expectedColor, TextStyle.decodeBackColor(getStyleAt(0, i)));
}
}
/**
* Test interactions between the cursor overflow bit and various escape sequences.
* <p/>
* Adapted from hterm:
* https://chromium.googlesource.com/chromiumos/platform/assets/+/2337afa5c063127d5ce40ec7fec9b602d096df86%5E%21/#F2
*/
public void testClearingOfAutowrap() {
// Fill a row with the last hyphen wrong, then run a command that
// modifies the screen, then add a hyphen. The wrap bit should be
// cleared, so the extra hyphen can fix the row.
withTerminalSized(15, 6);
enterString("----- 1 ----X");
enterString("\033[K-"); // EL
enterString("----- 2 ----X");
enterString("\033[J-"); // ED
enterString("----- 3 ----X");
enterString("\033[@-"); // ICH
enterString("----- 4 ----X");
enterString("\033[P-"); // DCH
enterString("----- 5 ----X");
enterString("\033[X-"); // ECH
// DL will delete the entire line but clear the wrap bit, so we
// expect a hyphen at the end and nothing else.
enterString("XXXXXXXXXXXXXXX");
enterString("\033[M-"); // DL
assertLinesAre(
"----- 1 -----",
"----- 2 -----",
"----- 3 -----",
"----- 4 -----",
"----- 5 -----",
" -");
}
public void testBackspaceAcrossWrappedLines() {
// Backspace should not go to previous line if not auto-wrapped:
withTerminalSized(3, 3).enterString("hi\r\n\b\byou").assertLinesAre("hi ", "you", " ");
// Backspace should go to previous line if auto-wrapped:
withTerminalSized(3, 3).enterString("hi y").assertLinesAre("hi ", "y ", " ").enterString("\b\b#").assertLinesAre("hi#", "y ", " ");
// Initial backspace should do nothing:
withTerminalSized(3, 3).enterString("\b\b\b\bhi").assertLinesAre("hi ", " ", " ");
}
public void testCursorSaveRestoreLocation() {
// DEC save/restore
withTerminalSized(4, 2).enterString("t\0337est\r\nme\0338ry ").assertLinesAre("try ", "me ");
// ANSI.SYS save/restore
withTerminalSized(4, 2).enterString("t\033[sest\r\nme\033[ury ").assertLinesAre("try ", "me ");
// Alternate screen enter/exit
withTerminalSized(4, 2).enterString("t\033[?1049h\033[Hest\r\nme").assertLinesAre("est ", "me ").enterString("\033[?1049lry").assertLinesAre("try ", " ");
}
public void testCursorSaveRestoreTextStyle() {
long s;
// DEC save/restore
withTerminalSized(4, 2).enterString("\033[31;42;4m..\0337\033[36;47;24m\0338..");
s = getStyleAt(0, 3);
Assert.assertEquals(1, TextStyle.decodeForeColor(s));
Assert.assertEquals(2, TextStyle.decodeBackColor(s));
Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
// ANSI.SYS save/restore
withTerminalSized(4, 2).enterString("\033[31;42;4m..\033[s\033[36;47;24m\033[u..");
s = getStyleAt(0, 3);
Assert.assertEquals(1, TextStyle.decodeForeColor(s));
Assert.assertEquals(2, TextStyle.decodeBackColor(s));
Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
// Alternate screen enter/exit
withTerminalSized(4, 2);
enterString("\033[31;42;4m..\033[?1049h\033[H\033[36;47;24m.");
s = getStyleAt(0, 0);
Assert.assertEquals(6, TextStyle.decodeForeColor(s));
Assert.assertEquals(7, TextStyle.decodeBackColor(s));
Assert.assertEquals(0, TextStyle.decodeEffect(s));
enterString("\033[?1049l..");
s = getStyleAt(0, 3);
Assert.assertEquals(1, TextStyle.decodeForeColor(s));
Assert.assertEquals(2, TextStyle.decodeBackColor(s));
Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
}
}
@@ -0,0 +1,78 @@
package com.termux.terminal;
/**
* <pre>
* "CSI ? Pm h", DEC Private Mode Set (DECSET)
* </pre>
* <p/>
* and
* <p/>
* <pre>
* "CSI ? Pm l", DEC Private Mode Reset (DECRST)
* </pre>
* <p/>
* controls various aspects of the terminal
*/
public class DecSetTest extends TerminalTestCase {
/** DECSET 25, DECTCEM, controls visibility of the cursor. */
public void testEnableDisableCursor() {
withTerminalSized(3, 3);
assertTrue("Initially the cursor should be enabled", mTerminal.isCursorEnabled());
enterString("\033[?25l"); // Disable Cursor (DECTCEM).
assertFalse(mTerminal.isCursorEnabled());
enterString("\033[?25h"); // Enable Cursor (DECTCEM).
assertTrue(mTerminal.isCursorEnabled());
enterString("\033[?25l"); // Disable Cursor (DECTCEM), again.
assertFalse(mTerminal.isCursorEnabled());
mTerminal.reset();
assertTrue("Resetting the terminal should enable the cursor", mTerminal.isCursorEnabled());
enterString("\033[?25l");
assertFalse(mTerminal.isCursorEnabled());
enterString("\033c"); // RIS resetting should enabled cursor.
assertTrue(mTerminal.isCursorEnabled());
}
/** DECSET 2004, controls bracketed paste mode. */
public void testBracketedPasteMode() {
withTerminalSized(3, 3);
mTerminal.paste("a");
assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear());
enterString("\033[?2004h"); // Enable bracketed paste mode.
mTerminal.paste("a");
assertEquals("Pasting when in bracketed paste mode should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear());
enterString("\033[?2004l"); // Disable bracketed paste mode.
mTerminal.paste("a");
assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear());
enterString("\033[?2004h"); // Enable bracketed paste mode, again.
mTerminal.paste("a");
assertEquals("Pasting when in bracketed paste mode again should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear());
mTerminal.paste("\033ab\033cd\033");
assertEquals("Pasting an escape character should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear());
mTerminal.paste("\u0081ab\u0081cd\u009F");
assertEquals("Pasting C1 control codes should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear());
mTerminal.reset();
mTerminal.paste("a");
assertEquals("Terminal reset() should disable bracketed paste mode", "a", mOutput.getOutputAndClear());
}
/** DECSET 7, DECAWM, controls wraparound mode. */
public void testWrapAroundMode() {
// Default with wraparound:
withTerminalSized(3, 3).enterString("abcd").assertLinesAre("abc", "d ", " ");
// With wraparound disabled:
withTerminalSized(3, 3).enterString("\033[?7labcd").assertLinesAre("abd", " ", " ");
enterString("efg").assertLinesAre("abg", " ", " ");
// Re-enabling wraparound:
enterString("\033[?7hhij").assertLinesAre("abh", "ij ", " ");
}
}
@@ -0,0 +1,53 @@
package com.termux.terminal;
/**
* "\033P" is a device control string.
*/
public class DeviceControlStringTest extends TerminalTestCase {
private static String hexEncode(String s) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < s.length(); i++)
result.append(String.format("%02X", (int) s.charAt(i)));
return result.toString();
}
private void assertCapabilityResponse(String cap, String expectedResponse) {
String input = "\033P+q" + hexEncode(cap) + "\033\\";
assertEnteringStringGivesResponse(input, "\033P1+r" + hexEncode(cap) + "=" + hexEncode(expectedResponse) + "\033\\");
}
public void testReportColorsAndName() {
// Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in
// hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key
// names.
// Two special features are also recognized, which are not key names: Co for termcap colors (or colors
// for terminfo colors), and TN for termcap name (or name for terminfo name).
// xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the
// corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are
// encoded in hexadecimal (2 digits per character).
withTerminalSized(3, 3).enterString("A");
assertCapabilityResponse("Co", "256");
assertCapabilityResponse("colors", "256");
assertCapabilityResponse("TN", "xterm");
assertCapabilityResponse("name", "xterm");
enterString("B").assertLinesAre("AB ", " ", " ");
}
public void testReportKeys() {
withTerminalSized(3, 3);
assertCapabilityResponse("kB", "\033[Z");
}
public void testReallyLongDeviceControlString() {
withTerminalSized(3, 3).enterString("\033P");
for (int i = 0; i < 10000; i++) {
enterString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
}
// The terminal should ignore the overlong DCS sequence and continue printing "aaa." and fill at least the first two lines with
// them:
assertLineIs(0, "aaa");
assertLineIs(1, "aaa");
}
}
@@ -0,0 +1,33 @@
package com.termux.terminal;
public class HistoryTest extends TerminalTestCase {
public void testHistory() {
final int rows = 3;
final int cols = 3;
withTerminalSized(cols, rows).enterString("111222333444555666777888999");
assertCursorAt(2, 2);
assertLinesAre("777", "888", "999");
assertHistoryStartsWith("666", "555");
resize(cols, 2);
assertHistoryStartsWith("777", "666", "555");
resize(cols, 3);
assertHistoryStartsWith("666", "555");
}
public void testHistoryWithScrollRegion() {
// "CSI P_s ; P_s r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM).
withTerminalSized(3, 4).enterString("111222333444");
assertLinesAre("111", "222", "333", "444");
enterString("\033[2;3r");
// NOTE: "DECSTBM moves the cursor to column 1, line 1 of the page."
assertCursorAt(0, 0);
enterString("\nCDEFGH").assertLinesAre("111", "CDE", "FGH", "444");
enterString("IJK").assertLinesAre("111", "FGH", "IJK", "444").assertHistoryStartsWith("CDE");
enterString("LMN").assertLinesAre("111", "IJK", "LMN", "444").assertHistoryStartsWith("FGH", "CDE");
}
}
@@ -0,0 +1,203 @@
package com.termux.terminal;
import android.view.KeyEvent;
import junit.framework.TestCase;
public class KeyHandlerTest extends TestCase {
private static String stringToHex(String s) {
if (s == null) return null;
StringBuilder buffer = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
if (buffer.length() > 0) {
buffer.append(" ");
}
buffer.append("0x");
buffer.append(Integer.toHexString(s.charAt(i)));
}
return buffer.toString();
}
private static void assertKeysEquals(String expected, String actual) {
if (!expected.equals(actual)) {
assertEquals(stringToHex(expected), stringToHex(actual));
}
}
/** See http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html */
public void testTermCaps() {
// Backspace.
assertKeysEquals("\u007f", KeyHandler.getCodeFromTermcap("kb", false, false));
// Back tab.
assertKeysEquals("\033[Z", KeyHandler.getCodeFromTermcap("kB", false, false));
// Arrow keys (up/down/right/left):
assertKeysEquals("\033[A", KeyHandler.getCodeFromTermcap("ku", false, false));
assertKeysEquals("\033[B", KeyHandler.getCodeFromTermcap("kd", false, false));
assertKeysEquals("\033[C", KeyHandler.getCodeFromTermcap("kr", false, false));
assertKeysEquals("\033[D", KeyHandler.getCodeFromTermcap("kl", false, false));
// .. shifted:
assertKeysEquals("\033[1;2A", KeyHandler.getCodeFromTermcap("kUP", false, false));
assertKeysEquals("\033[1;2B", KeyHandler.getCodeFromTermcap("kDN", false, false));
assertKeysEquals("\033[1;2C", KeyHandler.getCodeFromTermcap("%i", false, false));
assertKeysEquals("\033[1;2D", KeyHandler.getCodeFromTermcap("#4", false, false));
// Home/end keys:
assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("kh", false, false));
assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("@7", false, false));
// ... shifted:
assertKeysEquals("\033[1;2H", KeyHandler.getCodeFromTermcap("#2", false, false));
assertKeysEquals("\033[1;2F", KeyHandler.getCodeFromTermcap("*7", false, false));
// The traditional keyboard keypad:
// [Insert] [Home] [Page Up ]
// [Delete] [End] [Page Down]
//
// Termcap names (with xterm response in parenthesis):
// K1=Upper left of keypad (xterm sends same "<ESC>[H" = Home).
// K2=Center of keypad (xterm sends invalid response).
// K3=Upper right of keypad (xterm sends "<ESC>[5~" = Page Up).
// K4=Lower left of keypad (xterm sends "<ESC>[F" = End key).
// K5=Lower right of keypad (xterm sends "<ESC>[6~" = Page Down).
//
// vim/neovim (runtime/doc/term.txt):
// t_K1 <kHome> keypad home key
// t_K3 <kPageUp> keypad page-up key
// t_K4 <kEnd> keypad end key
// t_K5 <kPageDown> keypad page-down key
//
assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("K1", false, false));
assertKeysEquals("\033OH", KeyHandler.getCodeFromTermcap("K1", true, false));
assertKeysEquals("\033[5~", KeyHandler.getCodeFromTermcap("K3", false, false));
assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("K4", false, false));
assertKeysEquals("\033OF", KeyHandler.getCodeFromTermcap("K4", true, false));
assertKeysEquals("\033[6~", KeyHandler.getCodeFromTermcap("K5", false, false));
// Function keys F1-F12:
assertKeysEquals("\033OP", KeyHandler.getCodeFromTermcap("k1", false, false));
assertKeysEquals("\033OQ", KeyHandler.getCodeFromTermcap("k2", false, false));
assertKeysEquals("\033OR", KeyHandler.getCodeFromTermcap("k3", false, false));
assertKeysEquals("\033OS", KeyHandler.getCodeFromTermcap("k4", false, false));
assertKeysEquals("\033[15~", KeyHandler.getCodeFromTermcap("k5", false, false));
assertKeysEquals("\033[17~", KeyHandler.getCodeFromTermcap("k6", false, false));
assertKeysEquals("\033[18~", KeyHandler.getCodeFromTermcap("k7", false, false));
assertKeysEquals("\033[19~", KeyHandler.getCodeFromTermcap("k8", false, false));
assertKeysEquals("\033[20~", KeyHandler.getCodeFromTermcap("k9", false, false));
assertKeysEquals("\033[21~", KeyHandler.getCodeFromTermcap("k;", false, false));
assertKeysEquals("\033[23~", KeyHandler.getCodeFromTermcap("F1", false, false));
assertKeysEquals("\033[24~", KeyHandler.getCodeFromTermcap("F2", false, false));
// Function keys F13-F24 (same as shifted F1-F12):
assertKeysEquals("\033[1;2P", KeyHandler.getCodeFromTermcap("F3", false, false));
assertKeysEquals("\033[1;2Q", KeyHandler.getCodeFromTermcap("F4", false, false));
assertKeysEquals("\033[1;2R", KeyHandler.getCodeFromTermcap("F5", false, false));
assertKeysEquals("\033[1;2S", KeyHandler.getCodeFromTermcap("F6", false, false));
assertKeysEquals("\033[15;2~", KeyHandler.getCodeFromTermcap("F7", false, false));
assertKeysEquals("\033[17;2~", KeyHandler.getCodeFromTermcap("F8", false, false));
assertKeysEquals("\033[18;2~", KeyHandler.getCodeFromTermcap("F9", false, false));
assertKeysEquals("\033[19;2~", KeyHandler.getCodeFromTermcap("FA", false, false));
assertKeysEquals("\033[20;2~", KeyHandler.getCodeFromTermcap("FB", false, false));
assertKeysEquals("\033[21;2~", KeyHandler.getCodeFromTermcap("FC", false, false));
assertKeysEquals("\033[23;2~", KeyHandler.getCodeFromTermcap("FD", false, false));
assertKeysEquals("\033[24;2~", KeyHandler.getCodeFromTermcap("FE", false, false));
}
public void testKeyCodes() {
// Return sends carriage return (\r), which normally gets translated by the device driver to newline (\n) unless the ICRNL termios
// flag has been set.
assertKeysEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, 0, false, false));
// Backspace.
assertKeysEquals("\u007f", KeyHandler.getCode(KeyEvent.KEYCODE_DEL, 0, false, false));
// Space.
assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, 0, false, false));
assertKeysEquals("\u0000", KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, KeyHandler.KEYMOD_CTRL, false, false));
// Back tab.
assertKeysEquals("\033[Z", KeyHandler.getCode(KeyEvent.KEYCODE_TAB, KeyHandler.KEYMOD_SHIFT, false, false));
// Arrow keys (up/down/right/left):
assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, false, false));
assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, 0, false, false));
assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, 0, false, false));
assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, 0, false, false));
// .. shifted:
assertKeysEquals("\033[1;2A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_SHIFT, false, false));
// .. ctrl:ed:
assertKeysEquals("\033[1;5A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_CTRL, false, false));
assertKeysEquals("\033[1;5B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_CTRL, false, false));
assertKeysEquals("\033[1;5C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_CTRL, false, false));
assertKeysEquals("\033[1;5D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_CTRL, false, false));
// .. ctrl:ed and shifted:
int mod = KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT;
assertKeysEquals("\033[1;6A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, mod, false, false));
assertKeysEquals("\033[1;6B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, mod, false, false));
assertKeysEquals("\033[1;6C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, mod, false, false));
assertKeysEquals("\033[1;6D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, mod, false, false));
// Home/end keys:
assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, 0, false, false));
assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, 0, false, false));
// ... shifted:
assertKeysEquals("\033[1;2H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, KeyHandler.KEYMOD_SHIFT, false, false));
// Function keys F1-F12:
assertKeysEquals("\033OP", KeyHandler.getCode(KeyEvent.KEYCODE_F1, 0, false, false));
assertKeysEquals("\033OQ", KeyHandler.getCode(KeyEvent.KEYCODE_F2, 0, false, false));
assertKeysEquals("\033OR", KeyHandler.getCode(KeyEvent.KEYCODE_F3, 0, false, false));
assertKeysEquals("\033OS", KeyHandler.getCode(KeyEvent.KEYCODE_F4, 0, false, false));
assertKeysEquals("\033[15~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, 0, false, false));
assertKeysEquals("\033[17~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, 0, false, false));
assertKeysEquals("\033[18~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, 0, false, false));
assertKeysEquals("\033[19~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, 0, false, false));
assertKeysEquals("\033[20~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, 0, false, false));
assertKeysEquals("\033[21~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, 0, false, false));
assertKeysEquals("\033[23~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, 0, false, false));
assertKeysEquals("\033[24~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, 0, false, false));
// Function keys F13-F24 (same as shifted F1-F12):
assertKeysEquals("\033[1;2P", KeyHandler.getCode(KeyEvent.KEYCODE_F1, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2Q", KeyHandler.getCode(KeyEvent.KEYCODE_F2, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2R", KeyHandler.getCode(KeyEvent.KEYCODE_F3, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[1;2S", KeyHandler.getCode(KeyEvent.KEYCODE_F4, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[15;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[17;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[18;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[19;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[20;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[21;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[23;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("\033[24;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, KeyHandler.KEYMOD_SHIFT, false, false));
assertKeysEquals("0", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("1", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("2", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("3", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("4", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("6", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("7", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("8", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("9", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals(",", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_COMMA, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals(".", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, KeyHandler.KEYMOD_NUM_LOCK, false, false));
assertKeysEquals("\033[2~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, 0, false, false));
assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, 0, false, false));
assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, 0, false, false));
assertKeysEquals("\033[6~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, 0, false, false));
assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, 0, false, false));
assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, 0, false, false));
assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, 0, false, false));
assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, 0, false, false));
assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, 0, false, false));
assertKeysEquals("\033[5~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, 0, false, false));
assertKeysEquals("\033[3~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, 0, false, false));
}
}
@@ -0,0 +1,196 @@
package com.termux.terminal;
import android.util.Base64;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/** "ESC ]" is the Operating System Command. */
public class OperatingSystemControlTest extends TerminalTestCase {
public void testSetTitle() throws Exception {
List<ChangedTitle> expectedTitleChanges = new ArrayList<>();
withTerminalSized(10, 10);
enterString("\033]0;Hello, world\007");
assertEquals("Hello, world", mTerminal.getTitle());
expectedTitleChanges.add(new ChangedTitle(null, "Hello, world"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033]0;Goodbye, world\007");
assertEquals("Goodbye, world", mTerminal.getTitle());
expectedTitleChanges.add(new ChangedTitle("Hello, world", "Goodbye, world"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033]0;Goodbye, \u00F1 world\007");
assertEquals("Goodbye, \uu00F1 world", mTerminal.getTitle());
expectedTitleChanges.add(new ChangedTitle("Goodbye, world", "Goodbye, \uu00F1 world"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
// 2 should work as well (0 sets both title and icon).
enterString("\033]2;Updated\007");
assertEquals("Updated", mTerminal.getTitle());
expectedTitleChanges.add(new ChangedTitle("Goodbye, \uu00F1 world", "Updated"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033[22;0t");
enterString("\033]0;FIRST\007");
expectedTitleChanges.add(new ChangedTitle("Updated", "FIRST"));
assertEquals("FIRST", mTerminal.getTitle());
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033[22;0t");
enterString("\033]0;SECOND\007");
assertEquals("SECOND", mTerminal.getTitle());
expectedTitleChanges.add(new ChangedTitle("FIRST", "SECOND"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033[23;0t");
assertEquals("FIRST", mTerminal.getTitle());
expectedTitleChanges.add(new ChangedTitle("SECOND", "FIRST"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033[23;0t");
expectedTitleChanges.add(new ChangedTitle("FIRST", "Updated"));
assertEquals(expectedTitleChanges, mOutput.titleChanges);
enterString("\033[22;0t");
enterString("\033[22;0t");
enterString("\033[22;0t");
// Popping to same title should not cause changes.
enterString("\033[23;0t");
enterString("\033[23;0t");
enterString("\033[23;0t");
assertEquals(expectedTitleChanges, mOutput.titleChanges);
}
public void testTitleStack() throws Exception {
// echo -ne '\e]0;BEFORE\007' # set title
// echo -ne '\e[22t' # push to stack
// echo -ne '\e]0;AFTER\007' # set new title
// echo -ne '\e[23t' # retrieve from stack
withTerminalSized(10, 10);
enterString("\033]0;InitialTitle\007");
assertEquals("InitialTitle", mTerminal.getTitle());
enterString("\033[22t");
assertEquals("InitialTitle", mTerminal.getTitle());
enterString("\033]0;UpdatedTitle\007");
assertEquals("UpdatedTitle", mTerminal.getTitle());
enterString("\033[23t");
assertEquals("InitialTitle", mTerminal.getTitle());
enterString("\033[23t\033[23t\033[23t");
assertEquals("InitialTitle", mTerminal.getTitle());
}
public void testSetColor() throws Exception {
// "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC.
withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007");
assertEquals(Integer.toHexString(0xFF00FF00), Integer.toHexString(mTerminal.mColors.mCurrentColors[5]));
enterString("\033]4;5;#00FFAB\007");
assertEquals(mTerminal.mColors.mCurrentColors[5], 0xFF00FFAB);
enterString("\033]4;255;#ABFFAB\007");
assertEquals(mTerminal.mColors.mCurrentColors[255], 0xFFABFFAB);
// Two indexed colors at once:
enterString("\033]4;7;#00FF00;8;#0000FF\007");
assertEquals(mTerminal.mColors.mCurrentColors[7], 0xFF00FF00);
assertEquals(mTerminal.mColors.mCurrentColors[8], 0xFF0000FF);
}
void assertIndexColorsMatch(int[] expected) {
for (int i = 0; i < 255; i++)
assertEquals("index=" + i, expected[i], mTerminal.mColors.mCurrentColors[i]);
}
public void testResetColor() throws Exception {
withTerminalSized(4, 4);
int[] initialColors = new int[TextStyle.NUM_INDEXED_COLORS];
System.arraycopy(mTerminal.mColors.mCurrentColors, 0, initialColors, 0, initialColors.length);
int[] expectedColors = new int[initialColors.length];
System.arraycopy(mTerminal.mColors.mCurrentColors, 0, expectedColors, 0, expectedColors.length);
Random rand = new Random();
for (int endType = 0; endType < 3; endType++) {
// Both BEL (7) and ST (ESC \) can end an OSC sequence.
String ender = (endType == 0) ? "\007" : "\033\\";
for (int i = 0; i < 255; i++) {
expectedColors[i] = 0xFF000000 + (rand.nextInt() & 0xFFFFFF);
int r = (expectedColors[i] >> 16) & 0xFF;
int g = (expectedColors[i] >> 8) & 0xFF;
int b = expectedColors[i] & 0xFF;
String rgbHex = String.format("%02x", r) + String.format("%02x", g) + String.format("%02x", b);
enterString("\033]4;" + i + ";#" + rgbHex + ender);
assertEquals(expectedColors[i], mTerminal.mColors.mCurrentColors[i]);
}
}
enterString("\033]104;0\007");
expectedColors[0] = TerminalColors.COLOR_SCHEME.mDefaultColors[0];
assertIndexColorsMatch(expectedColors);
enterString("\033]104;1;2\007");
expectedColors[1] = TerminalColors.COLOR_SCHEME.mDefaultColors[1];
expectedColors[2] = TerminalColors.COLOR_SCHEME.mDefaultColors[2];
assertIndexColorsMatch(expectedColors);
enterString("\033]104\007"); // Reset all colors.
assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors);
}
public void disabledTestSetClipboard() {
// Cannot run this as a unit test since Base64 is a android.util class.
enterString("\033]52;c;" + Base64.encodeToString("Hello, world".getBytes(), 0) + "\007");
}
public void testResettingTerminalResetsColor() throws Exception {
// "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC.
withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007");
enterString("\033]4;5;#00FFAB\007").assertColor(5, 0xFF00FFAB);
enterString("\033]4;255;#ABFFAB\007").assertColor(255, 0xFFABFFAB);
mTerminal.reset();
assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors);
}
public void testSettingDynamicColors() {
// "${OSC}${DYNAMIC};${COLORSPEC}${BEL_OR_STRINGTERMINATOR}" => Change ${DYNAMIC} color to the color specified by $COLORSPEC where:
// DYNAMIC=10: Text foreground color.
// DYNAMIC=11: Text background color.
// DYNAMIC=12: Text cursor color.
withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00);
enterString("\033]11;#0ABCD0\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF0ABCD0);
enterString("\033]12;#00ABCD\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00ABCD);
// Two special colors at once
// ("Each successive parameter changes the next color in the list. The value of P s tells the starting point in the list"):
enterString("\033]10;#FF0000;#00FF00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00);
// Three at once:
enterString("\033]10;#0000FF;#00FF00;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFF0000FF);
assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFFFF0000);
// Without ending semicolon:
enterString("\033]10;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
// For background and cursor:
enterString("\033]11;#FFFF00;\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00);
enterString("\033]12;#00FFFF;\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF);
// Using string terminator:
String stringTerminator = "\033\\";
enterString("\033]10;#FF0000" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
// For background and cursor:
enterString("\033]11;#FFFF00;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00);
enterString("\033]12;#00FFFF;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF);
}
public void testReportSpecialColors() {
// "${OSC}${DYNAMIC};?${BEL}" => Terminal responds with the control sequence which would set the current color.
// Both xterm and libvte (gnome-terminal and others) use the longest color representation, which means that
// the response is "${OSC}rgb:RRRR/GGGG/BBBB"
withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00);
assertEnteringStringGivesResponse("\033]10;?\007", "\033]10;rgb:abab/cdcd/0000\007");
// Same as above but with string terminator. xterm uses the same string terminator in the response, which
// e.g. script posted at http://superuser.com/questions/157563/programmatic-access-to-current-xterm-background-color
// relies on:
assertEnteringStringGivesResponse("\033]10;?\033\\", "\033]10;rgb:abab/cdcd/0000\033\\");
}
}
@@ -0,0 +1,117 @@
package com.termux.terminal;
public class RectangularAreasTest extends TerminalTestCase {
/** http://www.vt100.net/docs/vt510-rm/DECFRA */
public void testFillRectangularArea() {
withTerminalSized(3, 3).enterString("\033[88$x").assertLinesAre("XXX", "XXX", "XXX");
withTerminalSized(3, 3).enterString("\033[88;1;1;2;10$x").assertLinesAre("XXX", "XXX", " ");
withTerminalSized(3, 3).enterString("\033[88;2;1;3;10$x").assertLinesAre(" ", "XXX", "XXX");
withTerminalSized(3, 3).enterString("\033[88;1;1;100;1$x").assertLinesAre("X ", "X ", "X ");
withTerminalSized(3, 3).enterString("\033[88;1;1;100;2$x").assertLinesAre("XX ", "XX ", "XX ");
withTerminalSized(3, 3).enterString("\033[88;100;1;100;2$x").assertLinesAre(" ", " ", " ");
}
/** http://www.vt100.net/docs/vt510-rm/DECERA */
public void testEraseRectangularArea() {
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[$z").assertLinesAre(" ", " ", " ");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10$z").assertLinesAre(" ", " ", "GHI");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10$z").assertLinesAre("ABC", " ", " ");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1$z").assertLinesAre(" BC", " EF", " HI");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2$z").assertLinesAre(" C", " F", " I");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2$z").assertLinesAre("ABC", "DEF", "GHI");
withTerminalSized(3, 3).enterString("A\033[$zBC").assertLinesAre(" BC", " ", " ");
}
/** http://www.vt100.net/docs/vt510-rm/DECSED */
public void testSelectiveEraseInDisplay() {
// ${CSI}1"q enables protection, ${CSI}0"q disables it.
// ${CSI}?${0,1,2}J" erases (0=cursor to end, 1=start to cursor, 2=complete display).
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2J").assertLinesAre(" ", " ", " ");
withTerminalSized(3, 3).enterString("ABC\033[1\"qDE\033[0\"qFGHI\033[?2J").assertLinesAre(" ", "DE ", " ");
withTerminalSized(3, 3).enterString("\033[1\"qABCDE\033[0\"qFGHI\033[?2J").assertLinesAre("ABC", "DE ", " ");
}
/** http://vt100.net/docs/vt510-rm/DECSEL */
public void testSelectiveEraseInLine() {
// ${CSI}1"q enables protection, ${CSI}0"q disables it.
// ${CSI}?${0,1,2}K" erases (0=cursor to end, 1=start to cursor, 2=complete line).
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2K").assertLinesAre("ABC", "DEF", " ");
withTerminalSized(3, 3).enterString("ABCDE\033[?0KFGHI").assertLinesAre("ABC", "DEF", "GHI");
withTerminalSized(3, 3).enterString("ABCDE\033[?1KFGHI").assertLinesAre("ABC", " F", "GHI");
withTerminalSized(3, 3).enterString("ABCDE\033[?2KFGHI").assertLinesAre("ABC", " F", "GHI");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;2H\033[?0K").assertLinesAre("ABC", "D ", "GHI");
withTerminalSized(3, 3).enterString("ABC\033[1\"qD\033[0\"qE\033[?2KFGHI").assertLinesAre("ABC", "D F", "GHI");
}
/** http://www.vt100.net/docs/vt510-rm/DECSERA */
public void testSelectiveEraseInRectangle() {
// ${CSI}1"q enables protection, ${CSI}0"q disables it.
// ${CSI}?${TOP};${LEFT};${BOTTOM};${RIGHT}${" erases.
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[${").assertLinesAre(" ", " ", " ");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10${").assertLinesAre(" ", " ", "GHI");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10${").assertLinesAre("ABC", " ", " ");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1${").assertLinesAre(" BC", " EF", " HI");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2${").assertLinesAre(" C", " F", " I");
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2${").assertLinesAre("ABC", "DEF", "GHI");
withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[${").assertLinesAre(" ", " E ", " ");
withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[1;1;2;10${").assertLinesAre(" ", " E ", "GHI");
}
/** http://vt100.net/docs/vt510-rm/DECCRA */
public void testRectangularCopy() {
// "${CSI}${SRC_TOP};${SRC_LEFT};${SRC_BOTTOM};${SRC_RIGHT};${SRC_PAGE};${DST_TOP};${DST_LEFT};${DST_PAGE}\$v"
withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;5;1$v").assertLinesAre("ABC ", "DEF AB ", "GHI DE ");
withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;4;1$v").assertLinesAre("ABCABC ", "DEFDEF ", "GHIGHI ");
withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;3;1$v").assertLinesAre("ABABC ", "DEDEF ", "GHGHI ");
withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;1;1$v").assertLinesAre("ABCABC ", "DEFDEF ",
"GHIGHI ");
withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;2;1$v").assertLinesAre(" ABCBC ", " DEFEF ",
" GHIHI ");
withTerminalSized(3, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;2;1$v").assertLinesAre("ABC", "DAB", "GDE");
// Enable ${CSI}?6h origin mode (DECOM) and ${CSI}?69h for left/right margin (DECLRMM) enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s
// for DECSLRM margin setting.
withTerminalSized(5, 5).enterString("\033[?6h\033[?69h\033[2;4s");
enterString("ABCDEFGHIJK").assertLinesAre(" ABC ", " DEF ", " GHI ", " JK ", " ");
enterString("\033[1;1;2;2;1;2;2;1$v").assertLinesAre(" ABC ", " DAB ", " GDE ", " JK ", " ");
}
/** http://vt100.net/docs/vt510-rm/DECCARA */
public void testChangeAttributesInRectangularArea() {
final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
// "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$r"
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI");
assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0));
// Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle:
withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI");
assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
}
/** http://vt100.net/docs/vt510-rm/DECCARA */
public void testReverseAttributesInRectangularArea() {
final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
final int u = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
final int bu = TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
// "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$t"
withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0));
// Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle:
withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
// Check reversal by initially bolding the B:
withTerminalSized(3, 3).enterString("\033[2*xA\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
assertEffectAttributesSet(effectLine(b, 0, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
// Check reversal by initially underlining A, bolding B, then reversing both bold and underline:
withTerminalSized(3, 3).enterString("\033[2*x\033[4mA\033[0m\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1;4$t").assertLinesAre("ABC", "DEF",
"GHI");
assertEffectAttributesSet(effectLine(b, u, 0), effectLine(bu, bu, 0), effectLine(0, 0, 0));
}
}
@@ -0,0 +1,212 @@
package com.termux.terminal;
public class ResizeTest extends TerminalTestCase {
public void testResizeWhenHasHistory() {
final int cols = 3;
withTerminalSized(cols, 3).enterString("111222333444555666777888999").assertCursorAt(2, 2).assertLinesAre("777", "888", "999");
resize(cols, 5).assertCursorAt(4, 2).assertLinesAre("555", "666", "777", "888", "999");
resize(cols, 3).assertCursorAt(2, 2).assertLinesAre("777", "888", "999");
}
public void testResizeWhenInAltBuffer() {
final int rows = 3, cols = 3;
withTerminalSized(cols, rows).enterString("a\r\ndef$").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
// Resize and back again while in main buffer:
resize(cols, 5).assertLinesAre("a ", "def", "$ ", " ", " ").assertCursorAt(2, 1);
resize(cols, rows).assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
// Switch to alt buffer:
enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 1);
enterString("h").assertLinesAre(" ", " ", " h ").assertCursorAt(2, 2);
resize(cols, 5).resize(cols, rows);
// Switch from alt buffer:
enterString("\033[?1049l").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
}
public void testShrinkingInAltBuffer() {
final int rows = 5;
final int cols = 3;
withTerminalSized(cols, rows).enterString("A\r\nB\r\nC\r\nD\r\nE").assertLinesAre("A ", "B ", "C ", "D ", "E ");
enterString("\033[?1049h").assertLinesAre(" ", " ", " ", " ", " ");
resize(3, 3).enterString("\033[?1049lF").assertLinesAre("C ", "D ", "EF ");
}
public void testResizeAfterNewlineWhenInAltBuffer() {
final int rows = 3;
final int cols = 3;
withTerminalSized(cols, rows);
enterString("a\r\nb\r\nc\r\nd\r\ne\r\nf\r\n").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0);
assertLineWraps(false, false, false);
// Switch to alt buffer:
enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 0);
enterString("h").assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1);
// Grow by two rows:
resize(cols, 5).assertLinesAre(" ", " ", "h ", " ", " ").assertCursorAt(2, 1);
resize(cols, rows).assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1);
// Switch from alt buffer:
enterString("\033[?1049l").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0);
}
public void testResizeAfterHistoryWraparound() {
final int rows = 3;
final int cols = 10;
withTerminalSized(cols, rows);
StringBuilder buffer = new StringBuilder();
for (int i = 0; i < 1000; i++) {
String s = Integer.toString(i);
enterString(s);
buffer.setLength(0);
buffer.append(s);
while (buffer.length() < cols)
buffer.append(' ');
if (i > rows) {
assertLineIs(rows - 1, buffer.toString());
}
enterString("\r\n");
}
assertLinesAre("998 ", "999 ", " ");
resize(cols, 2);
assertLinesAre("999 ", " ");
resize(cols, 5);
assertLinesAre("996 ", "997 ", "998 ", "999 ", " ");
resize(cols, rows);
assertLinesAre("998 ", "999 ", " ");
}
public void testVerticalResize() {
final int rows = 5;
final int cols = 3;
withTerminalSized(cols, rows);
// Foreground color to 119:
enterString("\033[38;5;119m");
// Background color to 129:
enterString("\033[48;5;129m");
// Clear with ED, Erase in Display:
enterString("\033[2J");
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
long style = getStyleAt(r, c);
assertEquals(119, TextStyle.decodeForeColor(style));
assertEquals(129, TextStyle.decodeBackColor(style));
}
}
enterString("11\r\n22");
assertLinesAre("11 ", "22 ", " ", " ", " ").assertLineWraps(false, false, false, false, false);
resize(cols, rows - 2).assertLinesAre("11 ", "22 ", " ");
// After resize, screen should still be same color:
for (int r = 0; r < rows - 2; r++) {
for (int c = 0; c < cols; c++) {
long style = getStyleAt(r, c);
assertEquals(119, TextStyle.decodeForeColor(style));
assertEquals(129, TextStyle.decodeBackColor(style));
}
}
// Background color to 200 and grow back size (which should be cleared to the new background color):
enterString("\033[48;5;200m");
resize(cols, rows);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
long style = getStyleAt(r, c);
assertEquals(119, TextStyle.decodeForeColor(style));
assertEquals("wrong at row=" + r, r >= 3 ? 200 : 129, TextStyle.decodeBackColor(style));
}
}
}
public void testHorizontalResize() {
final int rows = 5;
final int cols = 5;
withTerminalSized(cols, rows);
// Background color to 129:
// enterString("\033[48;5;129m").assertLinesAre(" ", " ", " ", " ", " ");
enterString("1111\r\n2222\r\n3333\r\n4444\r\n5555").assertCursorAt(4, 4);
// assertEquals(129, TextStyle.decodeBackColor(getStyleAt(2, 2)));
assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertLineWraps(false, false, false, false, false);
resize(cols + 2, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
assertLineWraps(false, false, false, false, false);
resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
assertLineWraps(false, false, false, false, false);
resize(cols - 1, rows).assertLinesAre("2222", "3333", "4444", "5555", " ").assertCursorAt(4, 0);
assertLineWraps(false, false, false, true, false);
resize(cols - 2, rows).assertLinesAre("3 ", "444", "4 ", "555", "5 ").assertCursorAt(4, 1);
assertLineWraps(false, true, false, true, false);
// Back to original size:
resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
assertLineWraps(false, false, false, false, false);
}
public void testLineWrap() {
final int rows = 3, cols = 5;
withTerminalSized(cols, rows).enterString("111111").assertLinesAre("11111", "1 ", " ");
assertCursorAt(1, 1).assertLineWraps(true, false, false);
resize(7, rows).assertCursorAt(0, 6).assertLinesAre("111111 ", " ", " ").assertLineWraps(false, false, false);
resize(cols, rows).assertCursorAt(1, 1).assertLinesAre("11111", "1 ", " ").assertLineWraps(true, false, false);
enterString("2").assertLinesAre("11111", "12 ", " ").assertLineWraps(true, false, false);
enterString("123").assertLinesAre("11111", "12123", " ").assertLineWraps(true, false, false);
enterString("W").assertLinesAre("11111", "12123", "W ").assertLineWraps(true, true, false);
withTerminalSized(cols, rows).enterString("1234512345");
assertLinesAre("12345", "12345", " ").assertLineWraps(true, false, false);
enterString("W").assertLinesAre("12345", "12345", "W ").assertLineWraps(true, true, false);
}
public void testCursorPositionWhenShrinking() {
final int rows = 5, cols = 3;
withTerminalSized(cols, rows).enterString("$ ").assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2);
resize(3, 3).assertLinesAre("$ ", " ", " ").assertCursorAt(0, 2);
resize(cols, rows).assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2);
}
public void testResizeWithCombiningCharInLastColumn() {
withTerminalSized(3, 3).enterString("ABC\u0302DEF").assertLinesAre("ABC\u0302", "DEF", " ");
resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " ");
// Same as above but with colors:
withTerminalSized(3, 3).enterString("\033[37mA\033[35mB\033[33mC\u0302\033[32mD\033[31mE\033[34mF").assertLinesAre("ABC\u0302",
"DEF", " ");
resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " ");
assertForegroundIndices(effectLine(7, 5, 3, 2), effectLine(1, 4, 4, 4), effectLine(4, 4, 4, 4));
}
public void testResizeWithLineWrappingContinuing() {
withTerminalSized(5, 3).enterString("\r\nAB DE").assertLinesAre(" ", "AB DE", " ");
resize(4, 3).assertLinesAre("AB D", "E ", " ");
resize(3, 3).assertLinesAre("AB ", "DE ", " ");
resize(5, 3).assertLinesAre(" ", "AB DE", " ");
}
public void testResizeWithWideChars() {
final int rows = 3, cols = 4;
String twoCharsWidthOne = new String(Character.toChars(TerminalRowTest.TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
withTerminalSized(cols, rows).enterString(twoCharsWidthOne).enterString("\r\n");
enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " ");
resize(3, 3).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " ");
enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + twoCharsWidthOne + " ", " ");
}
public void testResizeWithMoreWideChars() {
final int rows = 4, cols = 5;
withTerminalSized(cols, rows).enterString("qqrr").assertLinesAre("qqrr ", " ", " ", " ");
resize(2, rows).assertLinesAre("qq", "rr", " ", " ");
resize(5, rows).assertLinesAre("qqrr ", " ", " ", " ");
withTerminalSized(cols, rows).enterString("QR").assertLinesAre("QR ", " ", " ", " ");
resize(2, rows).assertLinesAre("", "", " ", " ");
resize(5, rows).assertLinesAre("QR ", " ", " ", " ");
}
}
@@ -0,0 +1,65 @@
package com.termux.terminal;
public class ScreenBufferTest extends TerminalTestCase {
public void testBasics() {
TerminalBuffer screen = new TerminalBuffer(5, 3, 3);
assertEquals("", screen.getTranscriptText());
screen.setChar(0, 0, 'a', 0);
assertEquals("a", screen.getTranscriptText());
screen.setChar(0, 0, 'b', 0);
assertEquals("b", screen.getTranscriptText());
screen.setChar(2, 0, 'c', 0);
assertEquals("b c", screen.getTranscriptText());
screen.setChar(2, 2, 'f', 0);
assertEquals("b c\n\n f", screen.getTranscriptText());
screen.blockSet(0, 0, 2, 2, 'X', 0);
}
public void testBlockSet() {
TerminalBuffer screen = new TerminalBuffer(5, 3, 3);
screen.blockSet(0, 0, 2, 2, 'X', 0);
assertEquals("XX\nXX", screen.getTranscriptText());
screen.blockSet(1, 1, 2, 2, 'Y', 0);
assertEquals("XX\nXYY\n YY", screen.getTranscriptText());
}
public void testGetSelectedText() {
withTerminalSized(5, 3).enterString("ABCDEFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " ");
assertEquals("AB", mTerminal.getSelectedText(0, 0, 1, 0));
assertEquals("BC", mTerminal.getSelectedText(1, 0, 2, 0));
assertEquals("CDE", mTerminal.getSelectedText(2, 0, 4, 0));
assertEquals("FG", mTerminal.getSelectedText(0, 1, 1, 1));
assertEquals("GH", mTerminal.getSelectedText(1, 1, 2, 1));
assertEquals("HIJ", mTerminal.getSelectedText(2, 1, 4, 1));
assertEquals("ABCDEFG", mTerminal.getSelectedText(0, 0, 1, 1));
withTerminalSized(5, 3).enterString("ABCDE\r\nFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " ");
assertEquals("ABCDE\nFG", mTerminal.getSelectedText(0, 0, 1, 1));
}
public void testGetSelectedTextJoinFullLines() {
withTerminalSized(5, 3).enterString("ABCDE\r\nFG");
assertEquals("ABCDEFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true));
withTerminalSized(5, 3).enterString("ABC\r\nFG");
assertEquals("ABC\nFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true));
}
public void testGetWordAtLocation() {
withTerminalSized(5, 3).enterString("ABCDEFGHIJ\r\nKLMNO");
assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(0, 0));
assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 1));
assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 2));
withTerminalSized(5, 3).enterString("ABC DEF GHI ");
assertEquals("ABC", mTerminal.getScreen().getWordAtLocation(0, 0));
assertEquals("", mTerminal.getScreen().getWordAtLocation(3, 0));
assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(4, 0));
assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(0, 1));
assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(1, 1));
assertEquals("GHI", mTerminal.getScreen().getWordAtLocation(0, 2));
assertEquals("", mTerminal.getScreen().getWordAtLocation(1, 2));
assertEquals("", mTerminal.getScreen().getWordAtLocation(2, 2));
}
}
@@ -0,0 +1,167 @@
package com.termux.terminal;
/**
* ${CSI}${top};${bottom}r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM).
* <p/>
* "DECSTBM moves the cursor to column 1, line 1 of the page" (http://www.vt100.net/docs/vt510-rm/DECSTBM).
*/
public class ScrollRegionTest extends TerminalTestCase {
public void testScrollRegionTop() {
withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444");
enterString("\033[2r").assertCursorAt(0, 0);
enterString("\r\n\r\n\r\n\r\nCDEFGH").assertLinesAre("111", "444", "CDE", "FGH").assertHistoryStartsWith("333");
enterString("IJK").assertLinesAre("111", "CDE", "FGH", "IJK").assertHistoryStartsWith("444");
// Reset scroll region and enter line:
enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("LMNOPQ").assertLinesAre("CDE", "FGH", "LMN", "OPQ");
}
public void testScrollRegionBottom() {
withTerminalSized(3, 4).enterString("111222333444");
assertLinesAre("111", "222", "333", "444");
enterString("\033[1;3r").assertCursorAt(0, 0);
enterString("\r\n\r\nCDEFGH").assertLinesAre("222", "CDE", "FGH", "444").assertHistoryStartsWith("111");
// Reset scroll region and enter line:
enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("IJKLMN").assertLinesAre("CDE", "FGH", "IJK", "LMN");
}
public void testScrollRegionResetWithOriginMode() {
withTerminalSized(3, 4).enterString("111222333444");
assertLinesAre("111", "222", "333", "444");
// "\033[?6h" sets origin mode, so that the later DECSTBM resets cursor to below margin:
enterString("\033[?6h\033[2r").assertCursorAt(1, 0);
}
public void testScrollRegionLeft() {
// ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEFG").assertLinesAre("ABC", " DE", " FG");
enterString("HI").assertLinesAre("ADE", " FG", " HI").enterString("JK").assertLinesAre("AFG", " HI", " JK");
enterString("\n").assertLinesAre("AHI", " JK", " ");
}
public void testScrollRegionRight() {
// ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
withTerminalSized(3, 3).enterString("YYY\033[?69h\033[1;2sABCDEF").assertLinesAre("ABY", "CD ", "EF ");
enterString("GH").assertLinesAre("CDY", "EF ", "GH ").enterString("IJ").assertLinesAre("EFY", "GH ", "IJ ");
enterString("\n").assertLinesAre("GHY", "IJ ", " ");
}
public void testScrollRegionOnAllSides() {
// ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
withTerminalSized(4, 4).enterString("ABCDEFGHIJKLMNOP").assertLinesAre("ABCD", "EFGH", "IJKL", "MNOP");
// http://www.vt100.net/docs/vt510-rm/DECOM
enterString("\033[?6h\033[2;3r").assertCursorAt(1, 0);
enterString("\033[?69h\033[2;3s").assertCursorAt(1, 1);
enterString("QRST").assertLinesAre("ABCD", "EQRH", "ISTL", "MNOP");
enterString("UV").assertLinesAre("ABCD", "ESTH", "IUVL", "MNOP");
}
public void testDECCOLMResetsScrollMargin() {
// DECCOLM — Select 80 or 132 Columns per Page (http://www.vt100.net/docs/vt510-rm/DECCOLM) has the important
// side effect to clear scroll margins, which is useful for e.g. the "reset" utility to clear scroll margins.
withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444");
enterString("\033[2r\033[?3h\r\nABCDEFGHIJKL").assertLinesAre("ABC", "DEF", "GHI", "JKL");
}
public void testScrollOutsideVerticalRegion() {
withTerminalSized(3, 4).enterString("\033[0;2rhi\033[4;0Hyou").assertLinesAre("hi ", " ", " ", "you");
//enterString("see").assertLinesAre("hi ", " ", " ", "see");
}
public void testNELRespectsLeftMargin() {
// vttest "Menu 11.3.2: VT420 Cursor-Movement Test", select "10. Test other movement (CR/HT/LF/FF) within margins".
// The NEL (ESC E) sequence moves cursor to first position on next line, where first position depends on origin mode and margin.
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033ED").assertLinesAre("ABC", "D ", " ");
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033[?6h\033ED").assertLinesAre("ABC", " D ", " ");
}
public void testRiRespectsLeftMargin() {
// Reverse Index (RI), ${ESC}M, should respect horizontal margins:
withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033M").assertLinesAre("A D", " BC ", " ");
}
public void testSdRespectsLeftMargin() {
// Scroll Down (SD), ${CSI}${N}T, should respect horizontal margins:
withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033[2T").assertLinesAre("A D", " ", " BC ");
}
public void testBackwardIndex() {
// vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 7.
// Without margins:
withTerminalSized(3, 3).enterString("ABCDEF\0336H").assertLinesAre("ABC", "DHF", " ");
enterString("\0336\0336I").assertLinesAre("ABC", "IHF", " ");
enterString("\0336\0336").assertLinesAre(" AB", " IH", " ");
// With left margin:
withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEF\0336\0336").assertLinesAre("A B", " D", " F");
}
public void testForwardIndex() {
// vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 8.
// Without margins:
withTerminalSized(3, 3).enterString("ABCD\0339E").assertLinesAre("ABC", "D E", " ");
enterString("\0339").assertLinesAre("BC ", " E ", " ");
// With right margin:
withTerminalSized(3, 3).enterString("\033[?69h\033[0;2sABCD\0339").assertLinesAre("B ", "D ", " ");
}
public void testScrollDownWithScrollRegion() {
withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 ");
enterString("\033[3r").enterString("\033[2T").assertLinesAre("1 ", "2 ", " ", " ", "3 ");
}
public void testScrollDownBelowScrollRegion() {
withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 ");
enterString("\033[1;3r"); // DECSTBM margins.
enterString("\033[4;1H"); // Place cursor just below bottom margin.
enterString("QQ\r\nRR\r\n\r\n\r\nYY");
assertLinesAre("1 ", "2 ", "3 ", "QQ", "YY");
}
/** See https://github.com/termux/termux-app/issues/1340 */
public void testScrollRegionDoesNotLimitCursorMovement() {
withTerminalSized(6, 4)
.enterString("\033[4;7r\033[3;1Haaa\033[Axxx")
.assertLinesAre(
" ",
" xxx",
"aaa ",
" "
);
withTerminalSized(6, 4)
.enterString("\033[1;3r\033[3;1Haaa\033[Bxxx")
.assertLinesAre(
" ",
" ",
"aaa ",
" xxx"
);
}
/**
* See <a href="https://github.com/termux/termux-packages/issues/12556">reported issue</a>.
*/
public void testClearingWhenScrollingWithMargins() {
int newForeground = 2;
int newBackground = 3;
int size = 3;
TerminalTestCase terminal = withTerminalSized(size, size)
// Enable horizontal margin and set left margin to 1:
.enterString("\033[?69h\033[2s")
// Set foreground and background color:
.enterString("\033[" + (30 + newForeground) + ";" + (40 + newBackground) + "m")
// Enter newlines to scroll down:
.enterString("\r\n\r\n\r\n\r\n\r\n");
for (int row = 0; row < size; row++) {
for (int col = 0; col < size; col++) {
// The first column (outside of the scrolling area, due to us setting a left scroll
// margin of 1) should be unmodified, the others should use the current style:
int expectedForeground = col == 0 ? TextStyle.COLOR_INDEX_FOREGROUND : newForeground;
int expectedBackground = col == 0 ? TextStyle.COLOR_INDEX_BACKGROUND : newBackground;
terminal.assertForegroundColorAt(row, col, expectedForeground);
terminal.assertBackgroundColorAt(row, col, expectedBackground);
}
}
}
}
@@ -0,0 +1,432 @@
package com.termux.terminal;
import junit.framework.TestCase;
import java.util.Arrays;
import java.util.Random;
public class TerminalRowTest extends TestCase {
/** The properties of these code points are validated in {@link #testStaticConstants()}. */
private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 = 0x679C;
private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2 = 0x679D;
private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1 = 0x2070E;
private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2 = 0x20731;
/** Unicode Character 'MUSICAL SYMBOL G CLEF' (U+1D11E). Two java chars required for this. */
static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1 = 0x1D11E;
/** Unicode Character 'MUSICAL SYMBOL G CLEF OTTAVA ALTA' (U+1D11F). Two java chars required for this. */
private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2 = 0x1D11F;
private final int COLUMNS = 80;
/** A combining character. */
private static final int DIARESIS_CODEPOINT = 0x0308;
private TerminalRow row;
@Override
protected void setUp() throws Exception {
super.setUp();
row = new TerminalRow(COLUMNS, TextStyle.NORMAL);
}
private void assertLineStartsWith(int... codePoints) {
char[] chars = row.mText;
int charIndex = 0;
for (int i = 0; i < codePoints.length; i++) {
int lineCodePoint = chars[charIndex++];
if (Character.isHighSurrogate((char) lineCodePoint)) {
lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]);
}
assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint);
}
}
private void assertColumnCharIndicesStartsWith(int... indices) {
for (int i = 0; i < indices.length; i++) {
int expected = indices[i];
int actual = row.findStartOfColumn(i);
assertEquals("At index=" + i, expected, actual);
}
}
public void testSimpleDiaresis() {
row.setChar(0, DIARESIS_CODEPOINT, 0);
assertEquals(81, row.getSpaceUsed());
row.setChar(0, DIARESIS_CODEPOINT, 0);
assertEquals(82, row.getSpaceUsed());
assertLineStartsWith(' ', DIARESIS_CODEPOINT, DIARESIS_CODEPOINT, ' ');
}
public void testStaticConstants() {
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2));
assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2));
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1));
assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2));
assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1));
assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2));
assertEquals(1, Character.charCount(DIARESIS_CODEPOINT));
assertEquals(0, WcWidth.width(DIARESIS_CODEPOINT));
}
public void testOneColumn() {
assertEquals(0, row.findStartOfColumn(0));
row.setChar(0, 'a', 0);
assertEquals(0, row.findStartOfColumn(0));
}
public void testAscii() {
assertEquals(0, row.findStartOfColumn(0));
row.setChar(0, 'a', 0);
assertLineStartsWith('a', ' ', ' ');
assertEquals(1, row.findStartOfColumn(1));
assertEquals(80, row.getSpaceUsed());
row.setChar(0, 'b', 0);
assertEquals(1, row.findStartOfColumn(1));
assertEquals(2, row.findStartOfColumn(2));
assertEquals(80, row.getSpaceUsed());
assertColumnCharIndicesStartsWith(0, 1, 2, 3);
char[] someChars = new char[]{'a', 'c', 'e', '4', '5', '6', '7', '8'};
char[] rawLine = new char[80];
Arrays.fill(rawLine, ' ');
Random random = new Random();
for (int i = 0; i < 1000; i++) {
int lineIndex = random.nextInt(rawLine.length);
int charIndex = random.nextInt(someChars.length);
rawLine[lineIndex] = someChars[charIndex];
row.setChar(lineIndex, someChars[charIndex], 0);
}
char[] lineChars = row.mText;
for (int i = 0; i < rawLine.length; i++) {
assertEquals(rawLine[i], lineChars[i]);
}
}
public void testUnicode() {
assertEquals(0, row.findStartOfColumn(0));
assertEquals(80, row.getSpaceUsed());
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
assertEquals(81, row.getSpaceUsed());
assertEquals(0, row.findStartOfColumn(0));
assertEquals(2, row.findStartOfColumn(1));
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, ' ', ' ');
assertColumnCharIndicesStartsWith(0, 2, 3, 4);
row.setChar(0, 'a', 0);
assertEquals(80, row.getSpaceUsed());
assertEquals(0, row.findStartOfColumn(0));
assertEquals(1, row.findStartOfColumn(1));
assertLineStartsWith('a', ' ', ' ');
assertColumnCharIndicesStartsWith(0, 1, 2, 3);
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
row.setChar(1, 'a', 0);
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 'a', ' ');
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, 0);
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, ' ');
assertColumnCharIndicesStartsWith(0, 2, 4, 5);
assertEquals(82, row.getSpaceUsed());
}
public void testDoubleWidth() {
row.setChar(0, 'a', 0);
row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
assertLineStartsWith('a', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' ');
assertColumnCharIndicesStartsWith(0, 1, 1, 2);
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ' ', ' ');
assertColumnCharIndicesStartsWith(0, 0, 1, 2);
row.setChar(0, ' ', 0);
assertLineStartsWith(' ', ' ', ' ', ' ');
assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4);
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2);
assertColumnCharIndicesStartsWith(0, 0, 1, 1, 2);
row.setChar(0, 'a', 0);
assertLineStartsWith('a', ' ', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' ');
}
/** Just as {@link #testDoubleWidth()} but requires a surrogate pair. */
public void testDoubleWidthSurrogage() {
row.setChar(0, 'a', 0);
assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4);
row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0);
assertColumnCharIndicesStartsWith(0, 1, 1, 3, 4);
assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0);
assertColumnCharIndicesStartsWith(0, 0, 2, 3, 4);
assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, ' ', ' ', ' ');
row.setChar(0, ' ', 0);
assertLineStartsWith(' ', ' ', ' ', ' ');
row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0);
row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0);
assertLineStartsWith(' ', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
row.setChar(0, 'a', 0);
assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
}
public void testReplacementChar() {
row.setChar(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 0);
row.setChar(1, 'Y', 0);
assertLineStartsWith(TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'Y', ' ', ' ');
}
public void testSurrogateCharsWithNormalDisplayWidth() {
// These requires a UTF-16 surrogate pair, and has a display width of one.
int first = 0x1D306;
int second = 0x1D307;
// Assert the above statement:
assertEquals(2, Character.toChars(first).length);
assertEquals(2, Character.toChars(second).length);
row.setChar(0, second, 0);
assertEquals(second, Character.toCodePoint(row.mText[0], row.mText[1]));
assertEquals(' ', row.mText[2]);
assertEquals(2, row.findStartOfColumn(1));
row.setChar(0, first, 0);
assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1]));
assertEquals(' ', row.mText[2]);
assertEquals(2, row.findStartOfColumn(1));
row.setChar(1, second, 0);
row.setChar(2, 'a', 0);
assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1]));
assertEquals(second, Character.toCodePoint(row.mText[2], row.mText[3]));
assertEquals('a', row.mText[4]);
assertEquals(' ', row.mText[5]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(2, row.findStartOfColumn(1));
assertEquals(4, row.findStartOfColumn(2));
assertEquals(5, row.findStartOfColumn(3));
assertEquals(6, row.findStartOfColumn(4));
row.setChar(0, ' ', 0);
assertEquals(' ', row.mText[0]);
assertEquals(second, Character.toCodePoint(row.mText[1], row.mText[2]));
assertEquals('a', row.mText[3]);
assertEquals(' ', row.mText[4]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(1, row.findStartOfColumn(1));
assertEquals(3, row.findStartOfColumn(2));
assertEquals(4, row.findStartOfColumn(3));
assertEquals(5, row.findStartOfColumn(4));
for (int i = 0; i < 80; i++) {
row.setChar(i, i % 2 == 0 ? first : second, 0);
}
for (int i = 0; i < 80; i++) {
int idx = row.findStartOfColumn(i);
assertEquals(i % 2 == 0 ? first : second, Character.toCodePoint(row.mText[idx], row.mText[idx + 1]));
}
for (int i = 0; i < 80; i++) {
row.setChar(i, i % 2 == 0 ? 'a' : 'b', 0);
}
for (int i = 0; i < 80; i++) {
int idx = row.findStartOfColumn(i);
assertEquals(i, idx);
assertEquals(i % 2 == 0 ? 'a' : 'b', row.mText[i]);
}
}
public void testOverwritingDoubleDisplayWidthWithNormalDisplayWidth() {
// Initial "OO "
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
assertEquals(' ', row.mText[1]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(0, row.findStartOfColumn(1));
assertEquals(1, row.findStartOfColumn(2));
// Setting first column to a clears second: "a "
row.setChar(0, 'a', 0);
assertEquals('a', row.mText[0]);
assertEquals(' ', row.mText[1]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(1, row.findStartOfColumn(1));
assertEquals(2, row.findStartOfColumn(2));
// Back to initial "OO "
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
assertEquals(' ', row.mText[1]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(0, row.findStartOfColumn(1));
assertEquals(1, row.findStartOfColumn(2));
// Setting first column to a clears first: " a "
row.setChar(1, 'a', 0);
assertEquals(' ', row.mText[0]);
assertEquals('a', row.mText[1]);
assertEquals(' ', row.mText[2]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(1, row.findStartOfColumn(1));
assertEquals(2, row.findStartOfColumn(2));
}
public void testOverwritingDoubleDisplayWidthWithSelf() {
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
assertEquals(' ', row.mText[1]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(0, row.findStartOfColumn(1));
assertEquals(1, row.findStartOfColumn(2));
}
public void testNormalCharsWithDoubleDisplayWidth() {
// These fit in one java char, and has a display width of two.
assertTrue(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 != ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2);
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
assertEquals(0, row.findStartOfColumn(1));
assertEquals(' ', row.mText[1]);
row.setChar(0, 'a', 0);
assertEquals('a', row.mText[0]);
assertEquals(' ', row.mText[1]);
assertEquals(1, row.findStartOfColumn(1));
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
// The first character fills both first columns.
assertEquals(0, row.findStartOfColumn(1));
row.setChar(2, 'a', 0);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
assertEquals('a', row.mText[1]);
assertEquals(1, row.findStartOfColumn(2));
row.setChar(0, 'c', 0);
assertEquals('c', row.mText[0]);
assertEquals(' ', row.mText[1]);
assertEquals('a', row.mText[2]);
assertEquals(' ', row.mText[3]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(1, row.findStartOfColumn(1));
assertEquals(2, row.findStartOfColumn(2));
}
public void testNormalCharsWithDoubleDisplayWidthOverlapping() {
// These fit in one java char, and has a display width of two.
row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
row.setChar(4, 'a', 0);
// O = ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO
// A = ANOTHER_JAVA_CHAR_DISPLAY_WIDTH_TWO
// "OOAAa "
assertEquals(0, row.findStartOfColumn(0));
assertEquals(0, row.findStartOfColumn(1));
assertEquals(1, row.findStartOfColumn(2));
assertEquals(1, row.findStartOfColumn(3));
assertEquals(2, row.findStartOfColumn(4));
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]);
assertEquals('a', row.mText[2]);
assertEquals(' ', row.mText[3]);
row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
// " AA a "
assertEquals(' ', row.mText[0]);
assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]);
assertEquals(' ', row.mText[2]);
assertEquals('a', row.mText[3]);
assertEquals(' ', row.mText[4]);
assertEquals(0, row.findStartOfColumn(0));
assertEquals(1, row.findStartOfColumn(1));
assertEquals(1, row.findStartOfColumn(2));
assertEquals(2, row.findStartOfColumn(3));
assertEquals(3, row.findStartOfColumn(4));
}
// https://github.com/jackpal/Android-Terminal-Emulator/issues/145
public void testCrashATE145() {
// 0xC2541 is unassigned, use display width 1 for UNICODE_REPLACEMENT_CHAR.
// assertEquals(1, WcWidth.width(0xC2541));
assertEquals(2, Character.charCount(0xC2541));
assertEquals(2, WcWidth.width(0x73EE));
assertEquals(1, Character.charCount(0x73EE));
assertEquals(0, WcWidth.width(0x009F));
assertEquals(1, Character.charCount(0x009F));
int[] points = new int[]{0xC2541, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD, 'B', 0x009B, 0x61C9, 'Z'};
// int[] expected = new int[] { TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD,
// 'B', 0x009B, 0x61C9, 'Z' };
int currentColumn = 0;
for (int point : points) {
row.setChar(currentColumn, point, 0);
currentColumn += WcWidth.width(point);
}
// assertLineStartsWith(points);
// assertEquals(Character.highSurrogate(0xC2541), line.mText[0]);
// assertEquals(Character.lowSurrogate(0xC2541), line.mText[1]);
// assertEquals('a', line.mText[2]);
// assertEquals('8', line.mText[3]);
// assertEquals(Character.highSurrogate(0x73EE), line.mText[4]);
// assertEquals(Character.lowSurrogate(0x73EE), line.mText[5]);
//
// char[] chars = line.mText;
// int charIndex = 0;
// for (int i = 0; i < points.length; i++) {
// char c = chars[charIndex];
// charIndex++;
// int thisPoint = (int) c;
// if (Character.isHighSurrogate(c)) {
// thisPoint = Character.toCodePoint(c, chars[charIndex]);
// charIndex++;
// }
// assertEquals("At index=" + i + ", charIndex=" + charIndex + ", char=" + (char) thisPoint, points[i], thisPoint);
// }
}
public void testNormalization() {
// int lowerCaseN = 0x006E;
// int combiningTilde = 0x0303;
// int combined = 0x00F1;
row.setChar(0, 0x006E, 0);
assertEquals(80, row.getSpaceUsed());
row.setChar(0, 0x0303, 0);
assertEquals(81, row.getSpaceUsed());
// assertEquals("\u00F1 ", new String(term.getScreen().getLine(0)));
assertLineStartsWith(0x006E, 0x0303, ' ');
}
public void testInsertWideAtLastColumn() {
row.setChar(COLUMNS - 2, 'Z', 0);
row.setChar(COLUMNS - 1, 'a', 0);
assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]);
assertEquals('a', row.mText[row.findStartOfColumn(COLUMNS - 1)]);
row.setChar(COLUMNS - 1, 'ö', 0);
assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]);
assertEquals('ö', row.mText[row.findStartOfColumn(COLUMNS - 1)]);
// line.setChar(COLUMNS - 1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1);
// assertEquals('Z', line.mText[line.findStartOfColumn(COLUMNS - 2)]);
// assertEquals(' ', line.mText[line.findStartOfColumn(COLUMNS - 1)]);
}
}
@@ -0,0 +1,350 @@
package com.termux.terminal;
import java.io.UnsupportedEncodingException;
public class TerminalTest extends TerminalTestCase {
public void testCursorPositioning() throws Exception {
withTerminalSized(10, 10).placeCursorAndAssert(1, 2).placeCursorAndAssert(3, 5).placeCursorAndAssert(2, 2).enterString("A")
.assertCursorAt(2, 3);
}
public void testScreen() throws UnsupportedEncodingException {
withTerminalSized(3, 3);
assertLinesAre(" ", " ", " ");
assertEquals("", mTerminal.getScreen().getTranscriptText());
enterString("hi").assertLinesAre("hi ", " ", " ");
assertEquals("hi", mTerminal.getScreen().getTranscriptText());
enterString("\r\nu");
assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText());
mTerminal.reset();
assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText());
withTerminalSized(3, 3).enterString("hello");
assertEquals("hello", mTerminal.getScreen().getTranscriptText());
enterString("\r\nworld");
assertEquals("hello\nworld", mTerminal.getScreen().getTranscriptText());
}
public void testScrollDownInAltBuffer() {
withTerminalSized(3, 3).enterString("\033[?1049h");
enterString("\033[38;5;111m1\r\n");
enterString("\033[38;5;112m2\r\n");
enterString("\033[38;5;113m3\r\n");
enterString("\033[38;5;114m4\r\n");
enterString("\033[38;5;115m5");
assertLinesAre("3 ", "4 ", "5 ");
assertForegroundColorAt(0, 0, 113);
assertForegroundColorAt(1, 0, 114);
assertForegroundColorAt(2, 0, 115);
}
public void testMouseClick() throws Exception {
withTerminalSized(10, 10);
assertFalse(mTerminal.isMouseTrackingActive());
enterString("\033[?1000h");
assertTrue(mTerminal.isMouseTrackingActive());
enterString("\033[?1000l");
assertFalse(mTerminal.isMouseTrackingActive());
enterString("\033[?1000h");
assertTrue(mTerminal.isMouseTrackingActive());
enterString("\033[?1006h");
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, true);
assertEquals("\033[<0;3;4M", mOutput.getOutputAndClear());
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, false);
assertEquals("\033[<0;3;4m", mOutput.getOutputAndClear());
// When the client says that a click is outside (which could happen when pixels are outside
// the terminal area, see https://github.com/termux/termux-app/issues/501) the terminal
// sends a click at the edge.
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 0, 0, true);
assertEquals("\033[<0;1;1M", mOutput.getOutputAndClear());
mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 11, 11, false);
assertEquals("\033[<0;10;10m", mOutput.getOutputAndClear());
}
public void testNormalization() throws UnsupportedEncodingException {
// int lowerCaseN = 0x006E;
// int combiningTilde = 0x0303;
// int combined = 0x00F1;
withTerminalSized(3, 3).assertLinesAre(" ", " ", " ");
enterString("\u006E\u0303");
assertEquals(1, WcWidth.width("\u006E\u0303".toCharArray(), 0));
// assertEquals("\u00F1 ", new String(mTerminal.getScreen().getLine(0)));
assertLinesAre("\u006E\u0303 ", " ", " ");
}
/** On "\e[18t" xterm replies with "\e[8;${HEIGHT};${WIDTH}t" */
public void testReportTerminalSize() throws Exception {
withTerminalSized(5, 5);
assertEnteringStringGivesResponse("\033[18t", "\033[8;5;5t");
for (int width = 3; width < 12; width++) {
for (int height = 3; height < 12; height++) {
resize(width, height);
assertEnteringStringGivesResponse("\033[18t", "\033[8;" + height + ";" + width + "t");
}
}
}
/** Device Status Report (DSR) and Report Cursor Position (CPR). */
public void testDeviceStatusReport() throws Exception {
withTerminalSized(5, 5);
assertEnteringStringGivesResponse("\033[5n", "\033[0n");
assertEnteringStringGivesResponse("\033[6n", "\033[1;1R");
enterString("AB");
assertEnteringStringGivesResponse("\033[6n", "\033[1;3R");
enterString("\r\n");
assertEnteringStringGivesResponse("\033[6n", "\033[2;1R");
}
/** Test the cursor shape changes using DECSCUSR. */
public void testSetCursorStyle() throws Exception {
withTerminalSized(5, 5);
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
enterString("\033[3 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
enterString("\033[5 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle());
enterString("\033[0 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
enterString("\033[6 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle());
enterString("\033[4 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
enterString("\033[1 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
enterString("\033[4 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
enterString("\033[2 q");
assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
}
public void testPaste() {
withTerminalSized(5, 5);
mTerminal.paste("hi");
assertEquals("hi", mOutput.getOutputAndClear());
enterString("\033[?2004h");
mTerminal.paste("hi");
assertEquals("\033[200~" + "hi" + "\033[201~", mOutput.getOutputAndClear());
enterString("\033[?2004l");
mTerminal.paste("hi");
assertEquals("hi", mOutput.getOutputAndClear());
}
public void testSelectGraphics() {
selectGraphicsTestRun(';');
selectGraphicsTestRun(':');
}
public void selectGraphicsTestRun(char separator) {
withTerminalSized(5, 5);
enterString("\033[31m");
assertEquals(mTerminal.mForeColor, 1);
enterString("\033[32m");
assertEquals(mTerminal.mForeColor, 2);
enterString("\033[43m");
assertEquals(2, mTerminal.mForeColor);
assertEquals(3, mTerminal.mBackColor);
// SGR 0 should reset both foreground and background color.
enterString("\033[0m");
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
// Test CSI resetting to default if sequence starts with ; or has sequential ;;
// Check TerminalEmulator.parseArg()
enterString("\033[31m\033[m");
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
enterString("\033[31m\033[;m".replace(';', separator));
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
enterString("\033[31m\033[0m");
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
enterString("\033[31m\033[0;m".replace(';', separator));
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
enterString("\033[31;;m");
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
enterString("\033[31::m");
assertEquals(1, mTerminal.mForeColor);
enterString("\033[31;m");
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
enterString("\033[31:m");
assertEquals(1, mTerminal.mForeColor);
enterString("\033[31;;41m");
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
assertEquals(1, mTerminal.mBackColor);
enterString("\033[0m");
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
// 256 colors:
enterString("\033[38;5;119m".replace(';', separator));
assertEquals(119, mTerminal.mForeColor);
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
enterString("\033[48;5;129m".replace(';', separator));
assertEquals(119, mTerminal.mForeColor);
assertEquals(129, mTerminal.mBackColor);
// Invalid parameter:
enterString("\033[48;8;129m".replace(';', separator));
assertEquals(119, mTerminal.mForeColor);
assertEquals(129, mTerminal.mBackColor);
// Multiple parameters at once:
enterString("\033[38;5;178".replace(';', separator) + ";" + "48;5;179m".replace(';', separator));
assertEquals(178, mTerminal.mForeColor);
assertEquals(179, mTerminal.mBackColor);
// Omitted parameter means zero:
enterString("\033[38;5;m".replace(';', separator));
assertEquals(0, mTerminal.mForeColor);
assertEquals(179, mTerminal.mBackColor);
enterString("\033[48;5;m".replace(';', separator));
assertEquals(0, mTerminal.mForeColor);
assertEquals(0, mTerminal.mBackColor);
// 24 bit colors:
enterString(("\033[0m")); // Reset fg and bg colors.
enterString("\033[38;2;255;127;2m".replace(';', separator));
int expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2;
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
enterString("\033[48;2;1;2;254m".replace(';', separator));
int expectedBackground = 0xff000000 | (1 << 16) | (2 << 8) | 254;
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(expectedBackground, mTerminal.mBackColor);
// 24 bit colors, set fg and bg at once:
enterString(("\033[0m")); // Reset fg and bg colors.
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
enterString("\033[38;2;255;127;2".replace(';', separator) + ";" + "48;2;1;2;254m".replace(';', separator));
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(expectedBackground, mTerminal.mBackColor);
// 24 bit colors, invalid input:
enterString("\033[38;2;300;127;2;48;2;1;300;254m".replace(';', separator));
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(expectedBackground, mTerminal.mBackColor);
// 24 bit colors, omitted parameter means zero:
enterString("\033[38;2;255;127;m".replace(';', separator));
expectedForeground = 0xff000000 | (255 << 16) | (127 << 8);
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(expectedBackground, mTerminal.mBackColor);
enterString("\033[38;2;123;;77m".replace(';', separator));
expectedForeground = 0xff000000 | (123 << 16) | 77;
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(expectedBackground, mTerminal.mBackColor);
// 24 bit colors, extra sub-parameters are skipped:
expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2;
enterString("\033[0;38:2:255:127:2:48:2:1:2:254m");
assertEquals(expectedForeground, mTerminal.mForeColor);
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
}
public void testBackgroundColorErase() {
final int rows = 3;
final int cols = 3;
withTerminalSized(cols, rows);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
long style = getStyleAt(r, c);
assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.decodeForeColor(style));
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(style));
}
}
// Foreground color to 119:
enterString("\033[38;5;119m");
// Background color to 129:
enterString("\033[48;5;129m");
// Clear with ED, Erase in Display:
enterString("\033[2J");
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
long style = getStyleAt(r, c);
assertEquals(119, TextStyle.decodeForeColor(style));
assertEquals(129, TextStyle.decodeBackColor(style));
}
}
// Background color to 139:
enterString("\033[48;5;139m");
// Insert two blank lines.
enterString("\033[2L");
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
long style = getStyleAt(r, c);
assertEquals((r == 0 || r == 1) ? 139 : 129, TextStyle.decodeBackColor(style));
}
}
withTerminalSized(cols, rows);
// Background color to 129:
enterString("\033[48;5;129m");
// Erase two characters, filling them with background color:
enterString("\033[2X");
assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 0)));
assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 1)));
assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(getStyleAt(0, 2)));
}
public void testParseColor() {
assertEquals(0xFF0000FA, TerminalColors.parse("#0000FA"));
assertEquals(0xFF000000, TerminalColors.parse("#000000"));
assertEquals(0xFF000000, TerminalColors.parse("#000"));
assertEquals(0xFF000000, TerminalColors.parse("#000000000"));
assertEquals(0xFF53186f, TerminalColors.parse("#53186f"));
assertEquals(0xFFFF00FF, TerminalColors.parse("rgb:F/0/F"));
assertEquals(0xFF0000FA, TerminalColors.parse("rgb:00/00/FA"));
assertEquals(0xFF53186f, TerminalColors.parse("rgb:53/18/6f"));
assertEquals(0, TerminalColors.parse("invalid_0000FA"));
assertEquals(0, TerminalColors.parse("#3456"));
}
/** The ncurses library still uses this. */
public void testLineDrawing() {
// 016 - shift out / G1. 017 - shift in / G0. "ESC ) 0" - use line drawing for G1
withTerminalSized(4, 2).enterString("q\033)0q\016q\017q").assertLinesAre("qq─q", " ");
// "\0337", saving cursor should save G0, G1 and invoked charset and "ESC 8" should restore.
withTerminalSized(4, 2).enterString("\033)0\016qqq\0337\017\0338q").assertLinesAre("────", " ");
}
public void testSoftTerminalReset() {
// See http://vt100.net/docs/vt510-rm/DECSTR and https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=650304
// "\033[?7l" is DECRST to disable wrap-around, and DECSTR ("\033[!p") should reset it.
withTerminalSized(3, 3).enterString("\033[?7lABCD").assertLinesAre("ABD", " ", " ");
enterString("\033[!pEF").assertLinesAre("ABE", "F ", " ");
}
public void testBel() {
withTerminalSized(3, 3);
assertEquals(0, mOutput.bellsRung);
enterString("\07");
assertEquals(1, mOutput.bellsRung);
enterString("hello\07");
assertEquals(2, mOutput.bellsRung);
enterString("\07hello");
assertEquals(3, mOutput.bellsRung);
enterString("hello\07world");
assertEquals(4, mOutput.bellsRung);
}
public void testAutomargins() throws UnsupportedEncodingException {
withTerminalSized(3, 3).enterString("abc").assertLinesAre("abc", " ", " ").assertCursorAt(0, 2);
enterString("d").assertLinesAre("abc", "d ", " ").assertCursorAt(1, 1);
withTerminalSized(3, 3).enterString("abc\r ").assertLinesAre(" bc", " ", " ").assertCursorAt(0, 1);
}
public void testTab() {
withTerminalSized(11, 2).enterString("01234567890\r\tXX").assertLinesAre("01234567XX0", " ");
withTerminalSized(11, 2).enterString("01234567890\033[44m\r\tXX").assertLinesAre("01234567XX0", " ");
}
}
@@ -0,0 +1,319 @@
package com.termux.terminal;
import junit.framework.AssertionFailedError;
import junit.framework.TestCase;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public abstract class TerminalTestCase extends TestCase {
public static final int INITIAL_CELL_WIDTH_PIXELS = 13;
public static final int INITIAL_CELL_HEIGHT_PIXELS = 15;
public static class MockTerminalOutput extends TerminalOutput {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
public final List<ChangedTitle> titleChanges = new ArrayList<>();
public final List<String> clipboardPuts = new ArrayList<>();
public int bellsRung = 0;
public int colorsChanged = 0;
@Override
public void write(byte[] data, int offset, int count) {
baos.write(data, offset, count);
}
public String getOutputAndClear() {
String result = new String(baos.toByteArray(), StandardCharsets.UTF_8);
baos.reset();
return result;
}
@Override
public void titleChanged(String oldTitle, String newTitle) {
titleChanges.add(new ChangedTitle(oldTitle, newTitle));
}
@Override
public void onCopyTextToClipboard(String text) {
clipboardPuts.add(text);
}
@Override
public void onPasteTextFromClipboard() {
}
@Override
public void onBell() {
bellsRung++;
}
@Override
public void onColorsChanged() {
colorsChanged++;
}
}
public TerminalEmulator mTerminal;
public MockTerminalOutput mOutput;
public static final class ChangedTitle {
final String oldTitle;
final String newTitle;
public ChangedTitle(String oldTitle, String newTitle) {
this.oldTitle = oldTitle;
this.newTitle = newTitle;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ChangedTitle)) return false;
ChangedTitle other = (ChangedTitle) o;
return Objects.equals(oldTitle, other.oldTitle) && Objects.equals(newTitle, other.newTitle);
}
@Override
public int hashCode() {
return Objects.hash(oldTitle, newTitle);
}
@Override
public String toString() {
return "ChangedTitle[oldTitle=" + oldTitle + ", newTitle=" + newTitle + "]";
}
}
public TerminalTestCase enterString(String s) {
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
mTerminal.append(bytes, bytes.length);
assertInvariants();
return this;
}
public void assertEnteringStringGivesResponse(String input, String expectedResponse) {
enterString(input);
String response = mOutput.getOutputAndClear();
assertEquals(expectedResponse, response);
}
@Override
protected void setUp() throws Exception {
super.setUp();
mOutput = new MockTerminalOutput();
}
protected TerminalTestCase withTerminalSized(int columns, int rows) {
// The tests aren't currently using the client, so a null client will suffice, a dummy client should be implemented if needed
mTerminal = new TerminalEmulator(mOutput, columns, rows, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS, rows * 2, null);
return this;
}
public void assertHistoryStartsWith(String... rows) {
assertTrue("About to check " + rows.length + " lines, but only " + mTerminal.getScreen().getActiveTranscriptRows() + " in history",
mTerminal.getScreen().getActiveTranscriptRows() >= rows.length);
for (int i = 0; i < rows.length; i++) {
assertLineIs(-i - 1, rows[i]);
}
}
private static final class LineWrapper {
final TerminalRow mLine;
public LineWrapper(TerminalRow line) {
mLine = line;
}
@Override
public int hashCode() {
return System.identityHashCode(mLine);
}
@Override
public boolean equals(Object o) {
return o instanceof LineWrapper && ((LineWrapper) o).mLine == mLine;
}
}
protected TerminalTestCase assertInvariants() {
TerminalBuffer screen = mTerminal.getScreen();
TerminalRow[] lines = screen.mLines;
Set<LineWrapper> linesSet = new HashSet<>();
for (int i = 0; i < lines.length; i++) {
if (lines[i] == null) continue;
assertTrue("Line exists at multiple places: " + i, linesSet.add(new LineWrapper(lines[i])));
char[] text = lines[i].mText;
int usedChars = lines[i].getSpaceUsed();
int currentColumn = 0;
for (int j = 0; j < usedChars; j++) {
char c = text[j];
int codePoint;
if (Character.isHighSurrogate(c)) {
char lowSurrogate = text[++j];
assertTrue("High surrogate without following low surrogate", Character.isLowSurrogate(lowSurrogate));
codePoint = Character.toCodePoint(c, lowSurrogate);
} else {
assertFalse("Low surrogate without preceding high surrogate", Character.isLowSurrogate(c));
codePoint = c;
}
assertFalse("Screen should never contain unassigned characters", Character.getType(codePoint) == Character.UNASSIGNED);
int width = WcWidth.width(codePoint);
assertFalse("The first column should not start with combining character", currentColumn == 0 && width < 0);
if (width > 0) currentColumn += width;
}
assertEquals("Line whose width does not match screens. line=" + new String(lines[i].mText, 0, lines[i].getSpaceUsed()),
screen.mColumns, currentColumn);
}
assertEquals("The alt buffer should have have no history", mTerminal.mAltBuffer.mTotalRows, mTerminal.mAltBuffer.mScreenRows);
if (mTerminal.isAlternateBufferActive()) {
assertEquals("The alt buffer should be the same size as the screen", mTerminal.mRows, mTerminal.mAltBuffer.mTotalRows);
}
return this;
}
protected void assertLineIs(int line, String expected) {
TerminalRow l = mTerminal.getScreen().allocateFullLineIfNecessary(mTerminal.getScreen().externalToInternalRow(line));
char[] chars = l.mText;
int textLen = l.getSpaceUsed();
if (textLen != expected.length()) fail("Expected '" + expected + "' (len=" + expected.length() + "), was='"
+ new String(chars, 0, textLen) + "' (len=" + textLen + ")");
for (int i = 0; i < textLen; i++) {
if (expected.charAt(i) != chars[i])
fail("Expected '" + expected + "', was='" + new String(chars, 0, textLen) + "' - first different at index=" + i);
}
}
public TerminalTestCase assertLinesAre(String... lines) {
assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
for (int i = 0; i < lines.length; i++)
try {
assertLineIs(i, lines[i]);
} catch (AssertionFailedError e) {
throw new AssertionFailedError("Line: " + i + " - " + e.getMessage());
}
return this;
}
public TerminalTestCase resize(int cols, int rows) {
mTerminal.resize(cols, rows, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS);
assertInvariants();
return this;
}
public TerminalTestCase assertLineWraps(boolean... lines) {
for (int i = 0; i < lines.length; i++)
assertEquals("line=" + i, lines[i], mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(i)].mLineWrap);
return this;
}
protected TerminalTestCase assertLineStartsWith(int line, int... codePoints) {
char[] chars = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(line)].mText;
int charIndex = 0;
for (int i = 0; i < codePoints.length; i++) {
int lineCodePoint = chars[charIndex++];
if (Character.isHighSurrogate((char) lineCodePoint)) {
lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]);
}
assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint);
}
return this;
}
protected TerminalTestCase placeCursorAndAssert(int row, int col) {
// +1 due to escape sequence being one based.
enterString("\033[" + (row + 1) + ";" + (col + 1) + "H");
assertCursorAt(row, col);
return this;
}
public TerminalTestCase assertCursorAt(int row, int col) {
int actualRow = mTerminal.getCursorRow();
int actualCol = mTerminal.getCursorCol();
if (!(row == actualRow && col == actualCol))
fail("Expected cursor at (row,col)=(" + row + ", " + col + ") but was (" + actualRow + ", " + actualCol + ")");
return this;
}
/** For testing only. Encoded style according to {@link TextStyle}. */
public long getStyleAt(int externalRow, int column) {
return mTerminal.getScreen().getStyleAt(externalRow, column);
}
public static class EffectLine {
final int[] styles;
public EffectLine(int[] styles) {
this.styles = styles;
}
}
protected EffectLine effectLine(int... bits) {
return new EffectLine(bits);
}
public TerminalTestCase assertEffectAttributesSet(EffectLine... lines) {
assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
for (int i = 0; i < lines.length; i++) {
int[] line = lines[i].styles;
for (int j = 0; j < line.length; j++) {
int effectsAtCell = TextStyle.decodeEffect(getStyleAt(i, j));
int attributes = line[j];
if ((effectsAtCell & attributes) != attributes) fail("Line=" + i + ", column=" + j + ", expected "
+ describeStyle(attributes) + " set, was " + describeStyle(effectsAtCell));
}
}
return this;
}
public TerminalTestCase assertForegroundIndices(EffectLine... lines) {
assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
for (int i = 0; i < lines.length; i++) {
int[] line = lines[i].styles;
for (int j = 0; j < line.length; j++) {
int actualColor = TextStyle.decodeForeColor(getStyleAt(i, j));
int expectedColor = line[j];
if (actualColor != expectedColor) fail("Line=" + i + ", column=" + j + ", expected color "
+ Integer.toHexString(expectedColor) + " set, was " + Integer.toHexString(actualColor));
}
}
return this;
}
private static String describeStyle(int styleBits) {
return "'" + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BLINK) != 0 ? ":BLINK:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BOLD) != 0 ? ":BOLD:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0 ? ":INVERSE:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) != 0 ? ":INVISIBLE:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0 ? ":ITALIC:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0 ? ":PROTECTED:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0 ? ":STRIKETHROUGH:" : "")
+ ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0 ? ":UNDERLINE:" : "") + "'";
}
public void assertForegroundColorAt(int externalRow, int column, int color) {
long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column);
assertEquals(color, TextStyle.decodeForeColor(style));
}
public void assertBackgroundColorAt(int externalRow, int column, int color) {
long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column);
assertEquals(color, TextStyle.decodeBackColor(style));
}
public TerminalTestCase assertColor(int colorIndex, int expected) {
int actual = mTerminal.mColors.mCurrentColors[colorIndex];
if (expected != actual) {
fail("Color index=" + colorIndex + ", expected=" + Integer.toHexString(expected) + ", was=" + Integer.toHexString(actual));
}
return this;
}
}
@@ -0,0 +1,65 @@
package com.termux.terminal;
import junit.framework.TestCase;
public class TextStyleTest extends TestCase {
private static final int[] ALL_EFFECTS = new int[]{0, TextStyle.CHARACTER_ATTRIBUTE_BOLD, TextStyle.CHARACTER_ATTRIBUTE_ITALIC,
TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.CHARACTER_ATTRIBUTE_BLINK, TextStyle.CHARACTER_ATTRIBUTE_INVERSE,
TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE, TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH, TextStyle.CHARACTER_ATTRIBUTE_PROTECTED,
TextStyle.CHARACTER_ATTRIBUTE_DIM};
public void testEncodingSingle() {
for (int fx : ALL_EFFECTS) {
for (int fg = 0; fg < TextStyle.NUM_INDEXED_COLORS; fg++) {
for (int bg = 0; bg < TextStyle.NUM_INDEXED_COLORS; bg++) {
long encoded = TextStyle.encode(fg, bg, fx);
assertEquals(fg, TextStyle.decodeForeColor(encoded));
assertEquals(bg, TextStyle.decodeBackColor(encoded));
assertEquals(fx, TextStyle.decodeEffect(encoded));
}
}
}
}
public void testEncoding24Bit() {
int[] values = {255, 240, 127, 1, 0};
for (int red : values) {
for (int green : values) {
for (int blue : values) {
int argb = 0xFF000000 | (red << 16) | (green << 8) | blue;
long encoded = TextStyle.encode(argb, 0, 0);
assertEquals(argb, TextStyle.decodeForeColor(encoded));
encoded = TextStyle.encode(0, argb, 0);
assertEquals(argb, TextStyle.decodeBackColor(encoded));
}
}
}
}
public void testEncodingCombinations() {
for (int f1 : ALL_EFFECTS) {
for (int f2 : ALL_EFFECTS) {
int combined = f1 | f2;
assertEquals(combined, TextStyle.decodeEffect(TextStyle.encode(0, 0, combined)));
}
}
}
public void testEncodingStrikeThrough() {
long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH);
assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0);
}
public void testEncodingProtected() {
long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH);
assertEquals(0, (TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED));
encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH | TextStyle.CHARACTER_ATTRIBUTE_PROTECTED);
assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0);
}
}

Some files were not shown because too many files have changed in this diff Show More