chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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/**")
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -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.** { *; }
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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.0–1.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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+248
@@ -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" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+51
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+131
@@ -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 ", " ");
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+196
@@ -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\\");
|
||||
}
|
||||
|
||||
}
|
||||
+117
@@ -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("Q", "R", " ", " ");
|
||||
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, 'A');
|
||||
assertWidthIs(2, 'B');
|
||||
assertWidthIs(2, 'C');
|
||||
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 ⁠).
|
||||
// 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).
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+55
@@ -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();
|
||||
|
||||
}
|
||||
+407
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+352
@@ -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>
|
||||
@@ -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?
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+3286
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
Reference in New Issue
Block a user