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
+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);
}
}
@@ -0,0 +1,136 @@
package com.termux.terminal;
import java.io.UnsupportedEncodingException;
public class UnicodeInputTest extends TerminalTestCase {
public void testIllFormedUtf8SuccessorByteNotConsumed() throws Exception {
// The Unicode Standard Version 6.2 Core Specification (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf):
// "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first byte, but which does not
// continue with valid successor bytes (see Table 3-7), it must not consume the successor bytes as part of the ill-formed
// subsequence whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit subsequence."
withTerminalSized(5, 5);
mTerminal.append(new byte[]{(byte) 0b11101111, (byte) 'a'}, 2);
assertLineIs(0, ((char) TerminalEmulator.UNICODE_REPLACEMENT_CHAR) + "a ");
// https://code.google.com/p/chromium/issues/detail?id=212704
byte[] input = new byte[]{
(byte) 0x61, (byte) 0xF1,
(byte) 0x80, (byte) 0x80,
(byte) 0xe1, (byte) 0x80,
(byte) 0xc2, (byte) 0x62,
(byte) 0x80, (byte) 0x63,
(byte) 0x80, (byte) 0xbf,
(byte) 0x64
};
withTerminalSized(10, 2);
mTerminal.append(input, input.length);
assertLinesAre("a\uFFFD\uFFFD\uFFFDb\uFFFDc\uFFFD\uFFFDd", " ");
// Surrogate pairs.
withTerminalSized(5, 2);
input = new byte[]{
(byte) 0xed, (byte) 0xa0,
(byte) 0x80, (byte) 0xed,
(byte) 0xad, (byte) 0xbf,
(byte) 0xed, (byte) 0xae,
(byte) 0x80, (byte) 0xed,
(byte) 0xbf, (byte) 0xbf
};
mTerminal.append(input, input.length);
assertLinesAre("\uFFFD\uFFFD\uFFFD\uFFFD ", " ");
// https://bugzilla.mozilla.org/show_bug.cgi?id=746900: "with this patch 0xe0 0x80 is decoded as two U+FFFDs,
// but 0xe0 0xa0 is decoded as a single U+FFFD, and this is correct according to the "Best Practices", but IE
// and Chrome (Version 22.0.1229.94) decode both of them as two U+FFFDs. Opera 12.11 decodes both of them as
// one U+FFFD".
withTerminalSized(5, 2);
input = new byte[]{(byte) 0xe0, (byte) 0xa0, ' '};
mTerminal.append(input, input.length);
assertLinesAre("\uFFFD ", " ");
// withTerminalSized(5, 2);
// input = new byte[]{(byte) 0xe0, (byte) 0x80, 'a'};
// mTerminal.append(input, input.length);
// assertLinesAre("\uFFFD\uFFFDa ", " ");
}
public void testUnassignedCodePoint() throws UnsupportedEncodingException {
withTerminalSized(3, 3);
// UTF-8 for U+C2541, an unassigned code point:
byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81};
mTerminal.append(b, b.length);
enterString("Y");
assertEquals(1, Character.charCount(TerminalEmulator.UNICODE_REPLACEMENT_CHAR));
assertLineStartsWith(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, (int) 'Y', ' ');
}
public void testStuff() {
withTerminalSized(80, 24);
byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81, (byte) 0x61, (byte) 0x38, (byte) 0xe7, (byte) 0x8f,
(byte) 0xae, (byte) 0xc2, (byte) 0x9f, (byte) 0xe8, (byte) 0xa0, (byte) 0x9f, (byte) 0xe8, (byte) 0x8c, (byte) 0xa4,
(byte) 0xed, (byte) 0x93, (byte) 0x89, (byte) 0xef, (byte) 0xbf, (byte) 0xbd, (byte) 0x42, (byte) 0xc2, (byte) 0x9b,
(byte) 0xe6, (byte) 0x87, (byte) 0x89, (byte) 0x5a};
mTerminal.append(b, b.length);
}
public void testSimpleCombining() throws Exception {
withTerminalSized(3, 2).enterString(" a\u0302 ").assertLinesAre(" a\u0302 ", " ");
}
public void testCombiningCharacterInFirstColumn() throws Exception {
withTerminalSized(5, 3).enterString("test\r\nhi\r\n").assertLinesAre("test ", "hi ", " ");
// U+0302 is COMBINING CIRCUMFLEX ACCENT. Test case from mosh (http://mosh.mit.edu/).
withTerminalSized(5, 5).enterString("test\r\nabc\r\n\u0302\r\ndef\r\n");
assertLinesAre("test ", "abc ", " \u0302 ", "def ", " ");
}
public void testCombiningCharacterInLastColumn() throws Exception {
withTerminalSized(3, 2).enterString(" a\u0302").assertLinesAre(" a\u0302", " ");
withTerminalSized(3, 2).enterString(" à̲").assertLinesAre(" à̲", " ");
withTerminalSized(3, 2).enterString("Aà̲F").assertLinesAre("Aà̲F", " ");
}
public void testWideCharacterInLastColumn() throws Exception {
withTerminalSized(3, 2).enterString("\u0302").assertLinesAre(" ", "\u0302 ");
withTerminalSized(3, 2).enterString("").assertLinesAre("", " ").assertCursorAt(0, 2);
enterString("a").assertLinesAre("", "a ");
}
public void testWideCharacterDeletion() throws Exception {
// CSI Ps D Cursor Backward Ps Times
withTerminalSized(3, 2).enterString("\033[Da").assertLinesAre(" a ", " ");
withTerminalSized(3, 2).enterString("\033[2Da").assertLinesAre("a ", " ");
withTerminalSized(3, 2).enterString("\033[2D枝").assertLinesAre("", " ");
withTerminalSized(3, 2).enterString("\033[1D枝").assertLinesAre("", " ");
withTerminalSized(5, 2).enterString("\033[Da").assertLinesAre(" 枝a ", " ");
withTerminalSized(5, 2).enterString("a \033[D\u0302").assertLinesAre("a\u0302 ", " ");
withTerminalSized(5, 2).enterString("\033[D\u0302").assertLinesAre("\u0302 ", " ");
enterString("Z").assertLinesAre("\u0302Z ", " ");
enterString("\033[D ").assertLinesAre("\u0302 ", " ");
// Go back two columns, standing at the second half of the wide character:
enterString("\033[2DU").assertLinesAre(" U ", " ");
}
public void testWideCharOverwriting() {
withTerminalSized(3, 2).enterString("abc\033[3D枝").assertLinesAre("枝c", " ");
}
public void testOverlongUtf8Encoding() throws Exception {
// U+0020 should be encoded as 0x20, 0xc0 0xa0 is an overlong encoding
// so should be replaced with the replacement char U+FFFD.
withTerminalSized(5, 5).mTerminal.append(new byte[]{(byte) 0xc0, (byte) 0xa0, 'Y'}, 3);
assertLineIs(0, "\uFFFDY ");
}
public void testWideCharacterWithoutWrapping() throws Exception {
// With wraparound disabled. The behaviour when a wide character is output with cursor in
// the last column when autowrap is disabled is not obvious, but we expect the wide
// character to be ignored here.
withTerminalSized(3, 3).enterString("\033[?7l").enterString("枝枝枝").assertLinesAre("", " ", " ");
enterString("a枝").assertLinesAre("枝a", " ", " ");
}
}
@@ -0,0 +1,81 @@
package com.termux.terminal;
import junit.framework.TestCase;
public class WcWidthTest extends TestCase {
private static void assertWidthIs(int expectedWidth, int codePoint) {
int wcWidth = WcWidth.width(codePoint);
assertEquals(expectedWidth, wcWidth);
}
public void testPrintableAscii() {
for (int i = 0x20; i <= 0x7E; i++) {
assertWidthIs(1, i);
}
}
public void testSomeWidthOne() {
assertWidthIs(1, 'å');
assertWidthIs(1, 'ä');
assertWidthIs(1, 'ö');
assertWidthIs(1, 0x23F2);
}
public void testSomeWide() {
assertWidthIs(2, '');
assertWidthIs(2, '');
assertWidthIs(2, '');
assertWidthIs(2, '中');
assertWidthIs(2, '文');
assertWidthIs(2, 0x679C);
assertWidthIs(2, 0x679D);
assertWidthIs(2, 0x2070E);
assertWidthIs(2, 0x20731);
assertWidthIs(1, 0x1F781);
}
public void testSomeNonWide() {
assertWidthIs(1, 0x1D11E);
assertWidthIs(1, 0x1D11F);
}
public void testCombining() {
assertWidthIs(0, 0x0302);
assertWidthIs(0, 0x0308);
assertWidthIs(0, 0xFE0F);
}
public void testWordJoiner() {
// https://en.wikipedia.org/wiki/Word_joiner
// The word joiner (WJ) is a code point in Unicode used to separate words when using scripts
// that do not use explicit spacing. It is encoded since Unicode version 3.2
// (released in 2002) as U+2060 WORD JOINER (HTML &#8288;).
// The word joiner does not produce any space, and prohibits a line break at its position.
assertWidthIs(0, 0x2060);
}
public void testSofthyphen() {
// http://osdir.com/ml/internationalization.linux/2003-05/msg00006.html:
// "Existing implementation practice in terminals is that the SOFT HYPHEN is
// a spacing graphical character, and the purpose of my wcwidth() was to
// predict the advancement of the cursor position after a string is sent to
// a terminal. Hence, I have no choice but to keep wcwidth(SOFT HYPHEN) = 1.
// VT100-style terminals do not hyphenate."
assertWidthIs(1, 0x00AD);
}
public void testHangul() {
assertWidthIs(1, 0x11A3);
}
public void testEmojis() {
assertWidthIs(2, 0x1F428); // KOALA.
assertWidthIs(2, 0x231a); // WATCH.
assertWidthIs(2, 0x1F643); // UPSIDE-DOWN FACE (Unicode 8).
}
}
+28
View File
@@ -0,0 +1,28 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion 35
namespace = "com.termux.view"
defaultConfig {
minSdkVersion 24
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
api project(":terminal-emulator")
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,112 @@
package com.termux.view;
import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */
final class GestureAndScaleRecognizer {
public interface Listener {
boolean onSingleTapUp(MotionEvent e);
boolean onDoubleTap(MotionEvent e);
boolean onScroll(MotionEvent e2, float dx, float dy);
boolean onFling(MotionEvent e, float velocityX, float velocityY);
boolean onScale(float focusX, float focusY, float scale);
boolean onDown(float x, float y);
boolean onUp(MotionEvent e);
void onLongPress(MotionEvent e);
}
private final GestureDetector mGestureDetector;
private final ScaleGestureDetector mScaleDetector;
final Listener mListener;
boolean isAfterLongPress;
public GestureAndScaleRecognizer(Context context, Listener listener) {
mListener = listener;
mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
return mListener.onScroll(e2, dx, dy);
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return mListener.onFling(e2, velocityX, velocityY);
}
@Override
public boolean onDown(MotionEvent e) {
return mListener.onDown(e.getX(), e.getY());
}
@Override
public void onLongPress(MotionEvent e) {
mListener.onLongPress(e);
isAfterLongPress = true;
}
}, null, true /* ignoreMultitouch */);
mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() {
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
return mListener.onSingleTapUp(e);
}
@Override
public boolean onDoubleTap(MotionEvent e) {
return mListener.onDoubleTap(e);
}
@Override
public boolean onDoubleTapEvent(MotionEvent e) {
return true;
}
});
mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor());
}
});
mScaleDetector.setQuickScaleEnabled(false);
}
public void onTouchEvent(MotionEvent event) {
mGestureDetector.onTouchEvent(event);
mScaleDetector.onTouchEvent(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isAfterLongPress = false;
break;
case MotionEvent.ACTION_UP:
if (!isAfterLongPress) {
// This behaviour is desired when in e.g. vim with mouse events, where we do not
// want to move the cursor when lifting finger after a long press.
mListener.onUp(event);
}
break;
}
}
public boolean isInProgress() {
return mScaleDetector.isInProgress();
}
}
@@ -0,0 +1,249 @@
package com.termux.view;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.Typeface;
import com.termux.terminal.TerminalBuffer;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalRow;
import com.termux.terminal.TextStyle;
import com.termux.terminal.WcWidth;
/**
* Renderer of a {@link TerminalEmulator} into a {@link Canvas}.
* <p/>
* Saves font metrics, so needs to be recreated each time the typeface or font size changes.
*/
public final class TerminalRenderer {
final int mTextSize;
final Typeface mTypeface;
private final Paint mTextPaint = new Paint();
/** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */
final float mFontWidth;
/** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
final int mFontLineSpacing;
/** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
private final int mFontAscent;
/** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */
final int mFontLineSpacingAndAscent;
private final float[] asciiMeasures = new float[127];
public TerminalRenderer(int textSize, Typeface typeface) {
mTextSize = textSize;
mTypeface = typeface;
mTextPaint.setTypeface(typeface);
mTextPaint.setAntiAlias(true);
mTextPaint.setTextSize(textSize);
mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing());
mFontAscent = (int) Math.ceil(mTextPaint.ascent());
mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent;
mFontWidth = mTextPaint.measureText("X");
StringBuilder sb = new StringBuilder(" ");
for (int i = 0; i < asciiMeasures.length; i++) {
sb.setCharAt(0, (char) i);
asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1);
}
}
/** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */
public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow,
int selectionY1, int selectionY2, int selectionX1, int selectionX2) {
final boolean reverseVideo = mEmulator.isReverseVideo();
final int endRow = topRow + mEmulator.mRows;
final int columns = mEmulator.mColumns;
final int cursorCol = mEmulator.getCursorCol();
final int cursorRow = mEmulator.getCursorRow();
final boolean cursorVisible = mEmulator.shouldCursorBeVisible();
final TerminalBuffer screen = mEmulator.getScreen();
final int[] palette = mEmulator.mColors.mCurrentColors;
final int cursorShape = mEmulator.getCursorStyle();
if (reverseVideo)
canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC);
float heightOffset = mFontLineSpacingAndAscent;
for (int row = topRow; row < endRow; row++) {
heightOffset += mFontLineSpacing;
final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1;
int selx1 = -1, selx2 = -1;
if (row >= selectionY1 && row <= selectionY2) {
if (row == selectionY1) selx1 = selectionX1;
selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns;
}
TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row));
final char[] line = lineObject.mText;
final int charsUsedInLine = lineObject.getSpaceUsed();
long lastRunStyle = 0;
boolean lastRunInsideCursor = false;
boolean lastRunInsideSelection = false;
int lastRunStartColumn = -1;
int lastRunStartIndex = 0;
boolean lastRunFontWidthMismatch = false;
int currentCharIndex = 0;
float measuredWidthForRun = 0.f;
for (int column = 0; column < columns; ) {
final char charAtIndex = line[currentCharIndex];
final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex);
final int charsForCodePoint = charIsHighsurrogate ? 2 : 1;
final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex;
final int codePointWcWidth = WcWidth.width(codePoint);
final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1));
final boolean insideSelection = column >= selx1 && column <= selx2;
final long style = lineObject.getStyle(column);
// Check if the measured text width for this code point is not the same as that expected by wcwidth().
// This could happen for some fonts which are not truly monospace, or for more exotic characters such as
// smileys which android font renders as wide.
// If this is detected, we draw this code point scaled to match what wcwidth() expects.
final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line,
currentCharIndex, charsForCodePoint);
final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01;
if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) {
if (column == 0) {
// Skip first column as there is nothing to draw, just record the current style.
} else {
final int columnWidthSinceLastRun = column - lastRunStartColumn;
final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
boolean invertCursorTextColor = false;
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
invertCursorTextColor = true;
}
drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection);
}
measuredWidthForRun = 0.f;
lastRunStyle = style;
lastRunInsideCursor = insideCursor;
lastRunInsideSelection = insideSelection;
lastRunStartColumn = column;
lastRunStartIndex = currentCharIndex;
lastRunFontWidthMismatch = fontWidthMismatch;
}
measuredWidthForRun += measuredCodePointWidth;
column += codePointWcWidth;
currentCharIndex += charsForCodePoint;
while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) {
// Eat combining chars so that they are treated as part of the last non-combining code point,
// instead of e.g. being considered inside the cursor in the next run.
currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1;
}
}
final int columnWidthSinceLastRun = columns - lastRunStartColumn;
final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
boolean invertCursorTextColor = false;
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
invertCursorTextColor = true;
}
drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun,
measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection);
}
}
private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns,
int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle,
long textStyle, boolean reverseVideo) {
int foreColor = TextStyle.decodeForeColor(textStyle);
final int effect = TextStyle.decodeEffect(textStyle);
int backColor = TextStyle.decodeBackColor(textStyle);
final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0;
final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0;
final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0;
final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0;
final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0;
if ((foreColor & 0xff000000) != 0xff000000) {
// Let bold have bright colors if applicable (one of the first 8):
if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8;
foreColor = palette[foreColor];
}
if ((backColor & 0xff000000) != 0xff000000) {
backColor = palette[backColor];
}
// Reverse video here if _one and only one_ of the reverse flags are set:
final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0;
if (reverseVideoHere) {
int tmp = foreColor;
foreColor = backColor;
backColor = tmp;
}
float left = startColumn * mFontWidth;
float right = left + runWidthColumns * mFontWidth;
mes = mes / mFontWidth;
boolean savedMatrix = false;
if (Math.abs(mes - runWidthColumns) > 0.01) {
canvas.save();
canvas.scale(runWidthColumns / mes, 1.f);
left *= mes / runWidthColumns;
right *= mes / runWidthColumns;
savedMatrix = true;
}
if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
// Only draw non-default background.
mTextPaint.setColor(backColor);
canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint);
}
if (cursor != 0) {
mTextPaint.setColor(cursor);
float cursorHeight = mFontLineSpacingAndAscent - mFontAscent;
if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.;
else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.;
canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint);
}
if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
if (dim) {
int red = (0xFF & (foreColor >> 16));
int green = (0xFF & (foreColor >> 8));
int blue = (0xFF & foreColor);
// Dim color handling used by libvte which in turn took it from xterm
// (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267):
red = red * 2 / 3;
green = green * 2 / 3;
blue = blue * 2 / 3;
foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue;
}
mTextPaint.setFakeBoldText(bold);
mTextPaint.setUnderlineText(underline);
mTextPaint.setTextSkewX(italic ? -0.35f : 0.f);
mTextPaint.setStrikeThruText(strikeThrough);
mTextPaint.setColor(foreColor);
// The text alignment is the default Paint.Align.LEFT.
canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint);
}
if (savedMatrix) canvas.restore();
}
public float getFontWidth() {
return mFontWidth;
}
public int getFontLineSpacing() {
return mFontLineSpacing;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
package com.termux.view;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import com.termux.terminal.TerminalSession;
/**
* The interface for communication between {@link TerminalView} and its client. It allows for getting
* various configuration options from the client and for sending back data to the client like logs,
* key events, both hardware and IME (which makes it different from that available with
* {@link View#setOnKeyListener(View.OnKeyListener)}, etc. It must be set for the
* {@link TerminalView} through {@link TerminalView#setTerminalViewClient(TerminalViewClient)}.
*/
public interface TerminalViewClient {
/**
* Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}.
*/
float onScale(float scale);
/**
* On a single tap on the terminal if terminal mouse reporting not enabled.
*/
void onSingleTapUp(MotionEvent e);
boolean shouldBackButtonBeMappedToEscape();
boolean shouldEnforceCharBasedInput();
/** Returns the input mode: 0=default, 1=TYPE_NULL, 2=VISIBLE_PASSWORD */
int getInputMode();
boolean shouldUseCtrlSpaceWorkaround();
boolean isTerminalViewSelected();
void copyModeChanged(boolean copyMode);
boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session);
boolean onKeyUp(int keyCode, KeyEvent e);
boolean onLongPress(MotionEvent event);
boolean readControlKey();
boolean readAltKey();
boolean readShiftKey();
boolean readFnKey();
boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session);
void onEmulatorSet();
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,75 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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
*
* http://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
*/
package com.termux.view.support;
import android.util.Log;
import android.widget.PopupWindow;
import java.lang.reflect.Method;
/**
* Implementation of PopupWindow compatibility that can call Gingerbread APIs.
* https://chromium.googlesource.com/android_tools/+/HEAD/sdk/extras/android/support/v4/src/gingerbread/android/support/v4/widget/PopupWindowCompatGingerbread.java
*/
public class PopupWindowCompatGingerbread {
private static Method sSetWindowLayoutTypeMethod;
private static boolean sSetWindowLayoutTypeMethodAttempted;
private static Method sGetWindowLayoutTypeMethod;
private static boolean sGetWindowLayoutTypeMethodAttempted;
public static void setWindowLayoutType(PopupWindow popupWindow, int layoutType) {
if (!sSetWindowLayoutTypeMethodAttempted) {
try {
sSetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod(
"setWindowLayoutType", int.class);
sSetWindowLayoutTypeMethod.setAccessible(true);
} catch (Exception e) {
// Reflection method fetch failed. Oh well.
}
sSetWindowLayoutTypeMethodAttempted = true;
}
if (sSetWindowLayoutTypeMethod != null) {
try {
sSetWindowLayoutTypeMethod.invoke(popupWindow, layoutType);
} catch (Exception e) {
// Reflection call failed. Oh well.
}
}
}
public static int getWindowLayoutType(PopupWindow popupWindow) {
if (!sGetWindowLayoutTypeMethodAttempted) {
try {
sGetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod(
"getWindowLayoutType");
sGetWindowLayoutTypeMethod.setAccessible(true);
} catch (Exception e) {
// Reflection method fetch failed. Oh well.
}
sGetWindowLayoutTypeMethodAttempted = true;
}
if (sGetWindowLayoutTypeMethod != null) {
try {
return (Integer) sGetWindowLayoutTypeMethod.invoke(popupWindow);
} catch (Exception e) {
// Reflection call failed. Oh well.
}
}
return 0;
}
}
@@ -0,0 +1,55 @@
package com.termux.view.textselection;
import android.view.MotionEvent;
import android.view.ViewTreeObserver;
import com.termux.view.TerminalView;
/**
* A CursorController instance can be used to control cursors in the text.
* It is not used outside of {@link TerminalView}.
*/
public interface CursorController extends ViewTreeObserver.OnTouchModeChangeListener {
/**
* Show the cursors on screen. Will be drawn by {@link #render()} by a call during onDraw.
* See also {@link #hide()}.
*/
void show(MotionEvent event);
/**
* Hide the cursors from screen.
* See also {@link #show(MotionEvent event)}.
*/
boolean hide();
/**
* Render the cursors.
*/
void render();
/**
* Update the cursor positions.
*/
void updatePosition(TextSelectionHandleView handle, int x, int y);
/**
* This method is called by {@link #onTouchEvent(MotionEvent)} and gives the cursors
* a chance to become active and/or visible.
*
* @param event The touch event
*/
boolean onTouchEvent(MotionEvent event);
/**
* Called when the view is detached from window. Perform house keeping task, such as
* stopping Runnable thread that would otherwise keep a reference on the context, thus
* preventing the activity to be recycled.
*/
void onDetached();
/**
* @return true if the cursors are currently active.
*/
boolean isActive();
}
@@ -0,0 +1,407 @@
package com.termux.view.textselection;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.text.TextUtils;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
import com.termux.terminal.TerminalBuffer;
import com.termux.terminal.WcWidth;
import com.termux.view.R;
import com.termux.view.TerminalView;
public class TextSelectionCursorController implements CursorController {
private final TerminalView terminalView;
private final TextSelectionHandleView mStartHandle, mEndHandle;
private String mStoredSelectedText;
private boolean mIsSelectingText = false;
private long mShowStartTime = System.currentTimeMillis();
private final int mHandleHeight;
private int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1;
private ActionMode mActionMode;
public final int ACTION_COPY = 1;
public final int ACTION_PASTE = 2;
public final int ACTION_MORE = 3;
public TextSelectionCursorController(TerminalView terminalView) {
this.terminalView = terminalView;
mStartHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT);
mEndHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT);
mHandleHeight = Math.max(mStartHandle.getHandleHeight(), mEndHandle.getHandleHeight());
}
@Override
public void show(MotionEvent event) {
setInitialTextSelectionPosition(event);
mStartHandle.positionAtCursor(mSelX1, mSelY1, true);
mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true);
setActionModeCallBacks();
mShowStartTime = System.currentTimeMillis();
mIsSelectingText = true;
}
@Override
public boolean hide() {
if (!isActive()) return false;
// prevent hide calls right after a show call, like long pressing the down key
// 300ms seems long enough that it wouldn't cause hide problems if action button
// is quickly clicked after the show, otherwise decrease it
if (System.currentTimeMillis() - mShowStartTime < 300) {
return false;
}
mStartHandle.hide();
mEndHandle.hide();
if (mActionMode != null) {
// This will hide the TextSelectionCursorController
mActionMode.finish();
}
mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1;
mIsSelectingText = false;
return true;
}
@Override
public void render() {
if (!isActive()) return;
mStartHandle.positionAtCursor(mSelX1, mSelY1, false);
mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false);
if (mActionMode != null) {
mActionMode.invalidate();
}
}
public void setInitialTextSelectionPosition(MotionEvent event) {
int[] columnAndRow = terminalView.getColumnAndRow(event, true);
mSelX1 = mSelX2 = columnAndRow[0];
mSelY1 = mSelY2 = columnAndRow[1];
TerminalBuffer screen = terminalView.mEmulator.getScreen();
if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) {
// Selecting something other than whitespace. Expand to word.
while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) {
mSelX1--;
}
while (mSelX2 < terminalView.mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) {
mSelX2++;
}
}
}
public void setActionModeCallBacks() {
final ActionMode.Callback callback = new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
int show = MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT;
ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy_text).setShowAsAction(show);
menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show);
menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
if (!isActive()) {
// Fix issue where the dialog is pressed while being dismissed.
return true;
}
switch (item.getItemId()) {
case ACTION_COPY:
String selectedText = getSelectedText();
terminalView.mTermSession.onCopyTextToClipboard(selectedText);
terminalView.stopTextSelectionMode();
break;
case ACTION_PASTE:
terminalView.stopTextSelectionMode();
terminalView.mTermSession.onPasteTextFromClipboard();
break;
case ACTION_MORE:
// We first store the selected text in case TerminalViewClient needs the
// selected text before MORE button was pressed since we are going to
// stop selection mode
mStoredSelectedText = getSelectedText();
// The text selection needs to be stopped before showing context menu,
// otherwise handles will show above popup
terminalView.stopTextSelectionMode();
terminalView.showContextMenu();
break;
}
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
}
};
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
mActionMode = terminalView.startActionMode(callback);
return;
}
//noinspection NewApi
mActionMode = terminalView.startActionMode(new ActionMode.Callback2() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
return callback.onCreateActionMode(mode, menu);
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
return callback.onActionItemClicked(mode, item);
}
@Override
public void onDestroyActionMode(ActionMode mode) {
// Ignore.
}
@Override
public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth());
int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth());
int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing());
int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing());
if (x1 > x2) {
int tmp = x1;
x1 = x2;
x2 = tmp;
}
int terminalBottom = terminalView.getBottom();
int top = y1 + mHandleHeight;
int bottom = y2 + mHandleHeight;
if (top > terminalBottom) top = terminalBottom;
if (bottom > terminalBottom) bottom = terminalBottom;
outRect.set(x1, top, x2, bottom);
}
}, ActionMode.TYPE_FLOATING);
}
@Override
public void updatePosition(TextSelectionHandleView handle, int x, int y) {
TerminalBuffer screen = terminalView.mEmulator.getScreen();
final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows;
if (handle == mStartHandle) {
mSelX1 = terminalView.getCursorX(x);
mSelY1 = terminalView.getCursorY(y);
if (mSelX1 < 0) {
mSelX1 = 0;
}
if (mSelY1 < -scrollRows) {
mSelY1 = -scrollRows;
} else if (mSelY1 > terminalView.mEmulator.mRows - 1) {
mSelY1 = terminalView.mEmulator.mRows - 1;
}
if (mSelY1 > mSelY2) {
mSelY1 = mSelY2;
}
if (mSelY1 == mSelY2 && mSelX1 > mSelX2) {
mSelX1 = mSelX2;
}
if (!terminalView.mEmulator.isAlternateBufferActive()) {
int topRow = terminalView.getTopRow();
if (mSelY1 <= topRow) {
topRow--;
if (topRow < -scrollRows) {
topRow = -scrollRows;
}
} else if (mSelY1 >= topRow + terminalView.mEmulator.mRows) {
topRow++;
if (topRow > 0) {
topRow = 0;
}
}
terminalView.setTopRow(topRow);
}
mSelX1 = getValidCurX(screen, mSelY1, mSelX1);
} else {
mSelX2 = terminalView.getCursorX(x);
mSelY2 = terminalView.getCursorY(y);
if (mSelX2 < 0) {
mSelX2 = 0;
}
if (mSelY2 < -scrollRows) {
mSelY2 = -scrollRows;
} else if (mSelY2 > terminalView.mEmulator.mRows - 1) {
mSelY2 = terminalView.mEmulator.mRows - 1;
}
if (mSelY1 > mSelY2) {
mSelY2 = mSelY1;
}
if (mSelY1 == mSelY2 && mSelX1 > mSelX2) {
mSelX2 = mSelX1;
}
if (!terminalView.mEmulator.isAlternateBufferActive()) {
int topRow = terminalView.getTopRow();
if (mSelY2 <= topRow) {
topRow--;
if (topRow < -scrollRows) {
topRow = -scrollRows;
}
} else if (mSelY2 >= topRow + terminalView.mEmulator.mRows) {
topRow++;
if (topRow > 0) {
topRow = 0;
}
}
terminalView.setTopRow(topRow);
}
mSelX2 = getValidCurX(screen, mSelY2, mSelX2);
}
terminalView.invalidate();
}
private int getValidCurX(TerminalBuffer screen, int cy, int cx) {
String line = screen.getSelectedText(0, cy, cx, cy);
if (!TextUtils.isEmpty(line)) {
int col = 0;
for (int i = 0, len = line.length(); i < len; i++) {
char ch1 = line.charAt(i);
if (ch1 == 0) {
break;
}
int wc;
if (Character.isHighSurrogate(ch1) && i + 1 < len) {
char ch2 = line.charAt(++i);
wc = WcWidth.width(Character.toCodePoint(ch1, ch2));
} else {
wc = WcWidth.width(ch1);
}
final int cend = col + wc;
if (cx > col && cx < cend) {
return cend;
}
if (cend == col) {
return col;
}
col = cend;
}
}
return cx;
}
public void decrementYTextSelectionCursors(int decrement) {
mSelY1 -= decrement;
mSelY2 -= decrement;
}
public boolean onTouchEvent(MotionEvent event) {
return false;
}
public void onTouchModeChanged(boolean isInTouchMode) {
if (!isInTouchMode) {
terminalView.stopTextSelectionMode();
}
}
@Override
public void onDetached() {
}
@Override
public boolean isActive() {
return mIsSelectingText;
}
public void getSelectors(int[] sel) {
if (sel == null || sel.length != 4) {
return;
}
sel[0] = mSelY1;
sel[1] = mSelY2;
sel[2] = mSelX1;
sel[3] = mSelX2;
}
/** Get the currently selected text. */
public String getSelectedText() {
return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2);
}
/** Get the selected text stored before "MORE" button was pressed on the context menu. */
@Nullable
public String getStoredSelectedText() {
return mStoredSelectedText;
}
/** Unset the selected text stored before "MORE" button was pressed on the context menu. */
public void unsetStoredSelectedText() {
mStoredSelectedText = null;
}
public ActionMode getActionMode() {
return mActionMode;
}
/**
* @return true if this controller is currently used to move the start selection.
*/
public boolean isSelectionStartDragged() {
return mStartHandle.isDragging();
}
/**
* @return true if this controller is currently used to move the end selection.
*/
public boolean isSelectionEndDragged() {
return mEndHandle.isDragging();
}
}
@@ -0,0 +1,352 @@
package com.termux.view.textselection;
import android.annotation.SuppressLint;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.WindowManager;
import android.widget.PopupWindow;
import com.termux.view.R;
import com.termux.view.TerminalView;
import com.termux.view.support.PopupWindowCompatGingerbread;
@SuppressLint("ViewConstructor")
public class TextSelectionHandleView extends View {
private final TerminalView terminalView;
private PopupWindow mHandle;
private final CursorController mCursorController;
private final Drawable mHandleLeftDrawable;
private final Drawable mHandleRightDrawable;
private Drawable mHandleDrawable;
private boolean mIsDragging;
final int[] mTempCoords = new int[2];
Rect mTempRect;
private int mPointX;
private int mPointY;
private float mTouchToWindowOffsetX;
private float mTouchToWindowOffsetY;
private float mHotspotX;
private float mHotspotY;
private float mTouchOffsetY;
private int mLastParentX;
private int mLastParentY;
private int mHandleHeight;
private int mHandleWidth;
private final int mInitialOrientation;
private int mOrientation;
public static final int LEFT = 0;
public static final int RIGHT = 2;
private long mLastTime;
public TextSelectionHandleView(TerminalView terminalView, CursorController cursorController, int initialOrientation) {
super(terminalView.getContext());
this.terminalView = terminalView;
mCursorController = cursorController;
mInitialOrientation = initialOrientation;
mHandleLeftDrawable = getContext().getDrawable(R.drawable.text_select_handle_left_material);
mHandleRightDrawable = getContext().getDrawable(R.drawable.text_select_handle_right_material);
setOrientation(mInitialOrientation);
}
private void initHandle() {
mHandle = new PopupWindow(terminalView.getContext(), null,
android.R.attr.textSelectHandleWindowStyle);
mHandle.setSplitTouchEnabled(true);
mHandle.setClippingEnabled(false);
mHandle.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
mHandle.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
mHandle.setBackgroundDrawable(null);
mHandle.setAnimationStyle(0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mHandle.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL);
mHandle.setEnterTransition(null);
mHandle.setExitTransition(null);
} else {
PopupWindowCompatGingerbread.setWindowLayoutType(mHandle, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL);
}
mHandle.setContentView(this);
}
public void setOrientation(int orientation) {
mOrientation = orientation;
int handleWidth = 0;
switch (orientation) {
case LEFT: {
mHandleDrawable = mHandleLeftDrawable;
handleWidth = mHandleDrawable.getIntrinsicWidth();
mHotspotX = (handleWidth * 3) / (float) 4;
break;
}
case RIGHT: {
mHandleDrawable = mHandleRightDrawable;
handleWidth = mHandleDrawable.getIntrinsicWidth();
mHotspotX = handleWidth / (float) 4;
break;
}
}
mHandleHeight = mHandleDrawable.getIntrinsicHeight();
mHandleWidth = handleWidth;
mTouchOffsetY = -mHandleHeight * 0.3f;
mHotspotY = 0;
invalidate();
}
public void show() {
if (!isPositionVisible()) {
hide();
return;
}
// We remove handle from its parent first otherwise the following exception may be thrown
// java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
removeFromParent();
initHandle(); // init the handle
invalidate(); // invalidate to make sure onDraw is called
final int[] coords = mTempCoords;
terminalView.getLocationInWindow(coords);
coords[0] += mPointX;
coords[1] += mPointY;
if (mHandle != null)
mHandle.showAtLocation(terminalView, 0, coords[0], coords[1]);
}
public void hide() {
mIsDragging = false;
if (mHandle != null) {
mHandle.dismiss();
// We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call
removeFromParent();
mHandle = null; // garbage collect the handle
}
invalidate();
}
public void removeFromParent() {
if (!isParentNull()) {
((ViewGroup)this.getParent()).removeView(this);
}
}
public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) {
int x = terminalView.getPointX(cx);
int y = terminalView.getPointY(cy + 1);
moveTo(x, y, forceOrientationCheck);
}
private void moveTo(int x, int y, boolean forceOrientationCheck) {
float oldHotspotX = mHotspotX;
checkChangedOrientation(x, forceOrientationCheck);
mPointX = (int) (x - (isShowing() ? oldHotspotX : mHotspotX));
mPointY = y;
if (isPositionVisible()) {
int[] coords = null;
if (isShowing()) {
coords = mTempCoords;
terminalView.getLocationInWindow(coords);
int x1 = coords[0] + mPointX;
int y1 = coords[1] + mPointY;
if (mHandle != null)
mHandle.update(x1, y1, getWidth(), getHeight());
} else {
show();
}
if (mIsDragging) {
if (coords == null) {
coords = mTempCoords;
terminalView.getLocationInWindow(coords);
}
if (coords[0] != mLastParentX || coords[1] != mLastParentY) {
mTouchToWindowOffsetX += coords[0] - mLastParentX;
mTouchToWindowOffsetY += coords[1] - mLastParentY;
mLastParentX = coords[0];
mLastParentY = coords[1];
}
}
} else {
hide();
}
}
public void changeOrientation(int orientation) {
if (mOrientation != orientation) {
setOrientation(orientation);
}
}
private void checkChangedOrientation(int posX, boolean force) {
if (!mIsDragging && !force) {
return;
}
long millis = SystemClock.currentThreadTimeMillis();
if (millis - mLastTime < 50 && !force) {
return;
}
mLastTime = millis;
final TerminalView hostView = terminalView;
final int left = hostView.getLeft();
final int right = hostView.getWidth();
final int top = hostView.getTop();
final int bottom = hostView.getHeight();
if (mTempRect == null) {
mTempRect = new Rect();
}
final Rect clip = mTempRect;
clip.left = left + terminalView.getPaddingLeft();
clip.top = top + terminalView.getPaddingTop();
clip.right = right - terminalView.getPaddingRight();
clip.bottom = bottom - terminalView.getPaddingBottom();
final ViewParent parent = hostView.getParent();
if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
return;
}
if (posX - mHandleWidth < clip.left) {
changeOrientation(RIGHT);
} else if (posX + mHandleWidth > clip.right) {
changeOrientation(LEFT);
} else {
changeOrientation(mInitialOrientation);
}
}
private boolean isPositionVisible() {
// Always show a dragging handle.
if (mIsDragging) {
return true;
}
final TerminalView hostView = terminalView;
final int left = 0;
final int right = hostView.getWidth();
final int top = 0;
final int bottom = hostView.getHeight();
if (mTempRect == null) {
mTempRect = new Rect();
}
final Rect clip = mTempRect;
clip.left = left + terminalView.getPaddingLeft();
clip.top = top + terminalView.getPaddingTop();
clip.right = right - terminalView.getPaddingRight();
clip.bottom = bottom - terminalView.getPaddingBottom();
final ViewParent parent = hostView.getParent();
if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
return false;
}
final int[] coords = mTempCoords;
hostView.getLocationInWindow(coords);
final int posX = coords[0] + mPointX + (int) mHotspotX;
final int posY = coords[1] + mPointY + (int) mHotspotY;
return posX >= clip.left && posX <= clip.right &&
posY >= clip.top && posY <= clip.bottom;
}
@Override
public void onDraw(Canvas c) {
final int width = mHandleDrawable.getIntrinsicWidth();
int height = mHandleDrawable.getIntrinsicHeight();
mHandleDrawable.setBounds(0, 0, width, height);
mHandleDrawable.draw(c);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
terminalView.updateFloatingToolbarVisibility(event);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
final float rawX = event.getRawX();
final float rawY = event.getRawY();
mTouchToWindowOffsetX = rawX - mPointX;
mTouchToWindowOffsetY = rawY - mPointY;
final int[] coords = mTempCoords;
terminalView.getLocationInWindow(coords);
mLastParentX = coords[0];
mLastParentY = coords[1];
mIsDragging = true;
break;
}
case MotionEvent.ACTION_MOVE: {
final float rawX = event.getRawX();
final float rawY = event.getRawY();
final float newPosX = rawX - mTouchToWindowOffsetX + mHotspotX;
final float newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY;
mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY));
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsDragging = false;
}
return true;
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(mHandleDrawable.getIntrinsicWidth(),
mHandleDrawable.getIntrinsicHeight());
}
public int getHandleHeight() {
return mHandleHeight;
}
public int getHandleWidth() {
return mHandleWidth;
}
public boolean isShowing() {
if (mHandle != null)
return mHandle.isShowing();
else
return false;
}
public boolean isParentNull() {
return this.getParent() == null;
}
public boolean isDragging() {
return mIsDragging;
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="24dp"
android:viewportWidth="132"
android:viewportHeight="66">
<path
android:pathData="M52.3,1.6c-5.7,2.1 -12.9,8.6 -16,14.8 -2.2,4.1 -2.8,6.9 -3.1,14.3 -0.6,12.6 1.3,17.8 9.3,25.8 8,8 13.2,9.9 25.8,9.3 11.1,-0.5 17.3,-3.2 23.5,-10.3 6.5,-7.4 7.2,-10.8 7.2,-34.7l0,-20.8 -21.2,0.1c-16.1,-0 -22.3,0.4 -25.5,1.5z"
android:fillColor="#2196F3"
android:strokeColor="#00000000"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="24dp"
android:viewportWidth="132"
android:viewportHeight="66">
<path
android:pathData="M33,20.8c0,23.9 0.7,27.3 7.2,34.7 6.2,7.1 12.4,9.8 23.5,10.3 12.6,0.6 17.8,-1.3 25.8,-9.3 8,-8 9.9,-13.2 9.3,-25.8 -0.5,-11.1 -3.2,-17.3 -10.3,-23.5 -7.4,-6.5 -10.8,-7.2 -34.7,-7.2l-20.8,-0 0,20.8z"
android:fillColor="#2196F3"
android:strokeColor="#00000000"/>
</vector>
@@ -0,0 +1,5 @@
<resources>
<string name="paste_text">Paste</string>
<string name="copy_text">Copy</string>
<string name="text_selection_more">More…</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+20
View File
@@ -0,0 +1,20 @@
<!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>
</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>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+3286
View File
File diff suppressed because it is too large Load Diff

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