chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:16:30 +08:00
commit a06359fc30
216 changed files with 32971 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:hardwareAccelerated="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:configChanges="keyboard|keyboardHidden|navigation|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".OpenClawService"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
+583
View File
@@ -0,0 +1,583 @@
/**
* glibc-compat.js - Minimal compatibility shim for glibc Node.js on Android
*
* This is the successor to bionic-compat.js, drastically reduced for glibc.
*
* What's NOT needed anymore (glibc handles these):
* - process.platform override (glibc Node.js reports 'linux' natively)
* - renameat2 / spawn.h stubs (glibc includes them)
* - CXXFLAGS / GYP_DEFINES overrides (glibc is standard Linux)
*
* What's still needed (kernel/Android-level restrictions, not libc):
* - os.cpus() fallback: SELinux blocks /proc/stat on Android 8+
* - os.networkInterfaces() safety: EACCES on some Android configurations
* - /bin/sh path shim: Android 7-8 lacks /bin/sh (Android 9+ has it)
*
* Loaded via node wrapper script: node --require <path>/glibc-compat.js
*/
'use strict';
const os = require('os');
const fs = require('fs');
const path = require('path');
// ─── process.execPath fix ────────────────────────────────────
// When node runs via grun (ld.so node.real), process.execPath points to
// ld.so instead of the node wrapper. Apps that spawn child node processes
// using process.execPath (e.g., openclaw) will call ld.so directly,
// bypassing the wrapper's LD_PRELOAD unset and compat loading.
// Fix: point process.execPath to the wrapper script.
const _wrapperPath = process.env._OA_WRAPPER_PATH || path.join(
process.env.HOME || '/data/data/com.termux/files/home',
'.openclaw-android', 'bin', 'node'
);
try {
if (fs.existsSync(_wrapperPath)) {
Object.defineProperty(process, 'execPath', {
value: _wrapperPath,
writable: true,
configurable: true,
});
}
} catch {}
// ─── LD_PRELOAD cleanup ─────────────────────────────────────
// The node wrapper unsets LD_PRELOAD to prevent bionic libtermux-exec.so
// from loading into the glibc node.real process.
//
// Previously, we restored LD_PRELOAD here so bionic child processes
// (like /bin/sh) would get libtermux-exec.so for path translation
// (e.g., /usr/bin/env → $PREFIX/bin/env in shebang resolution).
//
// However, libtermux-exec.so re-injects LD_PRELOAD into execve() calls
// even after the shell unsets it. This causes glibc processes (ld.so)
// spawned from node to crash with "Could not find a PHDR" errors.
//
// Fix: Do NOT restore LD_PRELOAD. Instead, the spawn/spawnSync wrapper
// below handles shebang resolution (#!/usr/bin/env → PATH lookup) in
// JavaScript, eliminating the need for libtermux-exec.so in child processes.
delete process.env.LD_PRELOAD;
delete process.env._OA_ORIG_LD_PRELOAD;
// ─── os.cpus() fallback ─────────────────────────────────────
// Android 8+ (API 26+) blocks /proc/stat via SELinux + hidepid=2.
// libuv reads /proc/stat for CPU info → returns empty array.
// Tools using os.cpus().length for parallelism (e.g., make -j) break with 0.
const _originalCpus = os.cpus;
os.cpus = function cpus() {
const result = _originalCpus.call(os);
if (result.length > 0) {
return result;
}
// Return a single fake CPU entry so .length is at least 1
return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }];
};
// ─── os.networkInterfaces() safety ──────────────────────────
// Some Android configurations throw EACCES when reading network
// interface information. Wrap with try-catch to prevent crashes.
//
// Additionally, Android/Termux typically only exposes the loopback
// interface (`lo`) to Node.js. In that situation, OpenClaw's Bonjour
// advertiser can't send multicast announcements and logs noisy
// "Announcement failed as of socket errors!" repeatedly.
// Auto-disable Bonjour via OPENCLAW_DISABLE_BONJOUR when only
// loopback interfaces are visible.
const _originalNetworkInterfaces = os.networkInterfaces;
function _createLoopbackInterfaces() {
return {
lo: [
{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '127.0.0.1/8',
},
],
};
}
function _hasNonLoopbackInterface(interfaces) {
try {
return Object.values(interfaces).some(entries =>
Array.isArray(entries) && entries.some(entry => entry && entry.internal === false)
);
} catch {
return false;
}
}
os.networkInterfaces = function networkInterfaces() {
let interfaces;
try {
interfaces = _originalNetworkInterfaces.call(os);
} catch {
interfaces = _createLoopbackInterfaces();
}
if (!process.env.OPENCLAW_DISABLE_BONJOUR && !_hasNonLoopbackInterface(interfaces)) {
process.env.OPENCLAW_DISABLE_BONJOUR = '1';
}
return interfaces;
};
// ─── Shell override for exec/execSync ────────────────────────
// Node.js child_process hardcodes /bin/sh as the default shell on Linux.
// On Android:
// - Android 7-8: /bin/sh doesn't exist at all
// - Android 9+: /bin/sh exists (/system/bin/sh) but is minimal (toybox/mksh)
// and lacks Termux PATH, environment, and proper command support
// Always use Termux's shell for exec/execSync to ensure consistent behavior.
{
const child_process = require('child_process');
const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh';
if (fs.existsSync(termuxSh)) {
const _originalExec = child_process.exec;
const _originalExecSync = child_process.execSync;
child_process.exec = function exec(command, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
if (!options.shell) {
options.shell = termuxSh;
}
return _originalExec.call(child_process, command, options, callback);
};
child_process.execSync = function execSync(command, options) {
options = options || {};
if (!options.shell) {
options.shell = termuxSh;
}
return _originalExecSync.call(child_process, command, options);
};
}
}
// ─── DNS resolver fix ────────────────────────────────────────
// glibc's getaddrinfo() reads /data/data/com.termux/files/usr/glibc/etc/resolv.conf
// for DNS servers. This file may be missing or inaccessible:
// - Standalone APK: runs under com.openclaw.android, can't access com.termux paths
// - Termux: resolv-conf package may not be installed
// Without a valid resolv.conf, dns.lookup() fails with EAI_AGAIN errors.
//
// Fix: Override both dns.lookup (callback) and dns.promises.lookup (promise)
// to use c-ares resolver (dns.resolve) which respects dns.setServers(),
// then fall back to getaddrinfo.
try {
const dns = require('dns');
// Read DNS servers from our resolv.conf or use Google DNS as fallback
let dnsServers = ['8.8.8.8', '8.8.4.4'];
try {
const resolvConf = fs.readFileSync(
(process.env.PREFIX || '/data/data/com.termux/files/usr') + '/etc/resolv.conf',
'utf8'
);
const parsed = resolvConf.match(/^nameserver\s+(.+)$/gm);
if (parsed && parsed.length > 0) {
dnsServers = parsed.map(l => l.replace(/^nameserver\s+/, '').trim());
}
} catch {}
// Set DNS servers for c-ares resolver
try { dns.setServers(dnsServers); } catch {}
// Override dns.lookup (callback API) to use c-ares resolver
const _originalLookup = dns.lookup;
// Localhost must never go to external DNS. Android/glibc may lack /etc/hosts,
// causing getaddrinfo to fail or return 0.0.0.0. Short-circuit it here.
const _localhostNames = new Set(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']);
dns.lookup = function lookup(hostname, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
const originalOptions = options;
const opts = typeof options === 'number' ? { family: options } : (options || {});
const wantAll = opts.all === true;
const family = opts.family || 0;
// Short-circuit localhost — never send to external DNS
if (_localhostNames.has(hostname)) {
if (family === 6) {
if (wantAll) return callback(null, [{ address: '::1', family: 6 }]);
return callback(null, '::1', 6);
}
if (wantAll) return callback(null, [{ address: '127.0.0.1', family: 4 }]);
return callback(null, '127.0.0.1', 4);
}
const resolve = (fam, cb) => {
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
fn(hostname, cb);
};
const tryResolve = (fam) => {
resolve(fam, (err, addresses) => {
if (!err && addresses && addresses.length > 0) {
const resFam = fam === 6 ? 6 : 4;
if (wantAll) {
callback(null, addresses.map(a => ({ address: a, family: resFam })));
} else {
callback(null, addresses[0], resFam);
}
} else if (family === 0 && fam === 4) {
tryResolve(6);
} else {
_originalLookup.call(dns, hostname, originalOptions, callback);
}
});
};
tryResolve(family === 6 ? 6 : 4);
};
// Override dns.promises.lookup (promise API) to use c-ares resolver.
// OpenClaw's SSRF guard uses this API for web_search DNS resolution.
const _originalPromiseLookup = dns.promises.lookup;
dns.promises.lookup = async function lookup(hostname, options) {
const opts = typeof options === 'number' ? { family: options } : (options || {});
const wantAll = opts.all === true;
const family = opts.family || 0;
// Short-circuit localhost
if (_localhostNames.has(hostname)) {
if (family === 6) {
return wantAll ? [{ address: '::1', family: 6 }] : { address: '::1', family: 6 };
}
return wantAll ? [{ address: '127.0.0.1', family: 4 }] : { address: '127.0.0.1', family: 4 };
}
const resolve = (fam) => {
return new Promise((res, rej) => {
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
fn(hostname, (err, addresses) => err ? rej(err) : res(addresses));
});
};
const tryResolve = async (fam) => {
try {
const addresses = await resolve(fam);
if (addresses && addresses.length > 0) {
const resFam = fam === 6 ? 6 : 4;
if (wantAll) {
return addresses.map(a => ({ address: a, family: resFam }));
}
return { address: addresses[0], family: resFam };
}
} catch {}
if (family === 0 && fam === 4) return tryResolve(6);
return _originalPromiseLookup.call(dns.promises, hostname, options);
};
return tryResolve(family === 6 ? 6 : 4);
};
} catch {}
// ─── ELF binary auto-wrapping for spawn/spawnSync ──────────
// npm/npx-installed native binaries (e.g., @zed-industries/codex-acp)
// are standard Linux ELF files whose interpreter is /lib/ld-linux-aarch64.so.1.
// Android lacks this path, so the kernel returns ENOENT on direct execution.
// Intercept child_process spawn APIs to detect ELF binaries and automatically
// route them through the glibc dynamic linker (ld.so).
const _glibcLdso = (process.env.PREFIX || '/data/data/com.termux/files/usr')
+ '/glibc/lib/ld-linux-aarch64.so.1';
const _glibcLibPath = (process.env.PREFIX || '/data/data/com.termux/files/usr')
+ '/glibc/lib';
function _needsGlibcWrap(filePath) {
// Read ELF header and check PT_INTERP to distinguish glibc binaries
// from bionic (Android/Termux) binaries. Only glibc binaries need wrapping.
// Bionic binaries use /system/bin/linker64; glibc use /lib/ld-linux-*.so.1
try {
const fd = fs.openSync(filePath, 'r');
try {
const ehdr = Buffer.alloc(64);
if (fs.readSync(fd, ehdr, 0, 64, 0) < 64) return false;
// Check ELF magic
if (ehdr[0] !== 0x7f || ehdr[1] !== 0x45 || ehdr[2] !== 0x4c || ehdr[3] !== 0x46) return false;
// ELF64: e_phoff at offset 32 (8 bytes), e_phentsize at 54 (2 bytes), e_phnum at 56 (2 bytes)
const phoff = Number(ehdr.readBigUInt64LE(32));
const phentsize = ehdr.readUInt16LE(54);
const phnum = ehdr.readUInt16LE(56);
// Scan program headers for PT_INTERP (type = 3)
for (let i = 0; i < phnum; i++) {
const phBuf = Buffer.alloc(phentsize);
if (fs.readSync(fd, phBuf, 0, phentsize, phoff + i * phentsize) < phentsize) continue;
const pType = phBuf.readUInt32LE(0);
if (pType === 3) { // PT_INTERP
const interpOff = Number(phBuf.readBigUInt64LE(8));
const interpSize = Number(phBuf.readBigUInt64LE(32));
const interpBuf = Buffer.alloc(Math.min(interpSize, 256));
fs.readSync(fd, interpBuf, 0, interpBuf.length, interpOff);
const interp = interpBuf.toString('utf8').replace(/\0+$/, '');
return interp.includes('ld-linux');
}
}
return false;
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
}
function _resolveCommand(command, env) {
if (command.includes('/')) {
try { return fs.realpathSync(command); } catch { return command; }
}
const searchPath = (env && env.PATH) || process.env.PATH || '';
const dirs = searchPath.split(':');
for (const dir of dirs) {
const full = path.join(dir, command);
try {
fs.accessSync(full, fs.constants.X_OK);
return full;
} catch {}
}
return null;
}
function _readShebang(filePath) {
// Read first 256 bytes to extract shebang line from script files.
// Returns null if not a script, or { interpreter, args } if shebang found.
try {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(256);
const n = fs.readSync(fd, buf, 0, 256, 0);
if (n < 2 || buf[0] !== 0x23 || buf[1] !== 0x21) return null; // not #!
const line = buf.toString('utf8', 2, n).split('\n')[0].trim();
const parts = line.split(/\s+/);
return { interpreter: parts[0], args: parts.slice(1) };
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
}
function _resolveShebang(resolved, spawnEnv) {
// For a resolved file path, check if it has a shebang pointing to a
// non-existent interpreter (e.g. #!/usr/bin/env node on Android).
// Returns { interpPath, interpArgs } or null if no fixup needed.
const shebang = _readShebang(resolved);
if (!shebang) return null;
let interpPath = shebang.interpreter;
let interpArgs = shebang.args;
if (interpPath === '/usr/bin/env' && interpArgs.length > 0) {
// #!/usr/bin/env <cmd> — resolve <cmd> from PATH
let cmdIdx = 0;
while (cmdIdx < interpArgs.length && interpArgs[cmdIdx].startsWith('-')) cmdIdx++;
if (cmdIdx >= interpArgs.length) return null;
const cmd = interpArgs[cmdIdx];
const resolvedCmd = _resolveCommand(cmd, spawnEnv);
if (!resolvedCmd) return null;
interpPath = resolvedCmd;
interpArgs = interpArgs.slice(0, cmdIdx).concat(interpArgs.slice(cmdIdx + 1));
} else if (!fs.existsSync(interpPath)) {
// Interpreter at non-existent absolute path — try resolving by basename from PATH
const basename = path.basename(interpPath);
const resolvedInterp = _resolveCommand(basename, spawnEnv);
if (!resolvedInterp) return null;
interpPath = resolvedInterp;
} else {
// Shebang interpreter exists, kernel can handle it
return null;
}
return { interpPath, interpArgs };
}
// Detect shell invocation patterns: spawn('/path/to/sh', ['-c', 'cmd args...'])
// or spawn('cmd', args, { shell: true })
function _isShellInvocation(file, args) {
if (!args || args.length < 2 || args[0] !== '-c') return null;
const base = path.basename(file);
if (base !== 'sh' && base !== 'bash' && base !== 'dash' && base !== 'zsh') return null;
return args[1]; // the shell command string
}
function _tryFixShellCommand(cmdStr, spawnEnv) {
// Extract the command name from a simple shell command string.
// Only handle simple cases: "cmd arg1 arg2..." — no pipes, redirects, etc.
const shellChars = /[|><&;`$(){}]/;
if (shellChars.test(cmdStr)) return null;
const parts = cmdStr.trim().split(/\s+/);
if (parts.length === 0) return null;
const cmd = parts[0];
const cmdArgs = parts.slice(1);
const resolved = _resolveCommand(cmd, spawnEnv);
if (!resolved) return null;
// glibc ELF binary
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(cmdArgs),
env: env,
};
}
// Script with broken shebang
const shebang = _resolveShebang(resolved, spawnEnv);
if (shebang) {
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(cmdArgs),
};
}
return null;
}
function _tryWrapSpawn(file, args, options) {
const spawnEnv = options && options.env ? options.env : process.env;
// Detect shell invocations: spawn('sh', ['-c', 'command...']) or spawn(cmd, args, { shell: true })
// npm/npx uses spawn('/path/to/sh', ['-c', 'cmd args...']) to execute package binaries.
// Without libtermux-exec.so, #!/usr/bin/env shebangs in these scripts fail.
if (options && options.shell) {
// shell: true — Node.js will convert to sh -c 'file args...'
if (!file.includes(' ') && !(/[|><&;`$()]/).test(file)) {
const resolved = _resolveCommand(file, spawnEnv);
if (resolved) {
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
options: Object.assign({}, options, { env: env, shell: false }),
};
}
const shebang = _resolveShebang(resolved, spawnEnv);
if (shebang) {
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(args || []),
options: Object.assign({}, options, { shell: false }),
};
}
}
}
return null;
}
// Direct shell invocation: spawn('/path/to/sh', ['-c', 'cmd args...'])
const shellCmd = _isShellInvocation(file, args);
if (shellCmd) {
const fix = _tryFixShellCommand(shellCmd, spawnEnv);
if (fix) {
return {
file: fix.file,
args: fix.args,
options: fix.env ? Object.assign({}, options, { env: fix.env }) : options,
};
}
return null;
}
const resolved = _resolveCommand(file, spawnEnv);
if (!resolved) return null;
if (resolved === _glibcLdso || resolved.endsWith('/ld-linux-aarch64.so.1')) return null;
// Case 1: glibc ELF binary — wrap with ld.so
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
options: Object.assign({}, options, { env: env }),
};
}
// Case 2: Script with shebang pointing to non-existent path
const shebang = _resolveShebang(resolved, spawnEnv);
if (!shebang) return null;
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(args || []),
options: options,
};
}
if (fs.existsSync(_glibcLdso)) {
const _cp = require('child_process');
// Normalize optional args parameter: spawn(cmd[, args][, opts])
function _normalizeArgs(args, options) {
if (args != null && !Array.isArray(args)) {
return { args: [], options: args };
}
return { args: args || [], options: options };
}
const _origSpawn = _cp.spawn;
_cp.spawn = function spawn(command, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(command, n.args, n.options);
if (w) return _origSpawn.call(_cp, w.file, w.args, w.options);
return _origSpawn.call(_cp, command, args, options);
};
const _origSpawnSync = _cp.spawnSync;
_cp.spawnSync = function spawnSync(command, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(command, n.args, n.options);
if (w) return _origSpawnSync.call(_cp, w.file, w.args, w.options);
return _origSpawnSync.call(_cp, command, args, options);
};
const _origExecFile = _cp.execFile;
_cp.execFile = function execFile(file, args, options, callback) {
// execFile(file[, args][, options], callback)
if (typeof args === 'function') {
callback = args; args = []; options = {};
} else if (typeof options === 'function') {
callback = options;
if (Array.isArray(args)) { options = {}; } else { options = args; args = []; }
}
const w = _tryWrapSpawn(file, args, options);
if (w) return _origExecFile.call(_cp, w.file, w.args, w.options, callback);
return _origExecFile.call(_cp, file, args, options, callback);
};
const _origExecFileSync = _cp.execFileSync;
_cp.execFileSync = function execFileSync(file, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(file, n.args, n.options);
if (w) return _origExecFileSync.call(_cp, w.file, w.args, w.options);
return _origExecFileSync.call(_cp, file, args, options);
};
}
+768
View File
@@ -0,0 +1,768 @@
#!/usr/bin/env bash
# OpenClaw Android — Post-Bootstrap Setup
# Runs in the terminal after Termux bootstrap extraction.
# Installs: git, glibc, Node.js, OpenClaw
#
# Strategy:
# - Termux .deb packages: dpkg-deb -x + relocate (bypasses dpkg hardcoded paths)
# - Pacman .pkg.tar.xz packages: tar -xJf + relocate (bypasses pacman entirely)
# - Both have files under data/data/com.termux/files/usr/ which we relocate to $PREFIX
#
# Why not apt-get/dpkg/pacman?
# All three have hardcoded /data/data/com.termux/... paths that libtermux-exec
# cannot rewrite (it only intercepts execve, not open/opendir).
set -eo pipefail
# ─── Paths ────────────────────────────────────
: "${PREFIX:?PREFIX not set}"
: "${HOME:?HOME not set}"
: "${TMPDIR:=$(dirname "$PREFIX")/tmp}"
OCA_DIR="$HOME/.openclaw-android"
NODE_DIR="$OCA_DIR/node"
BIN_DIR="$OCA_DIR/bin"
NODE_VERSION="22.22.0"
GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
MARKER="$OCA_DIR/.post-setup-done"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ─── GitHub mirror fallback (for China/restricted networks) ──
REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
REPO_BASE="$REPO_BASE_ORIGIN"
resolve_repo_base() {
if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then
REPO_BASE="$REPO_BASE_ORIGIN"; return 0
fi
local mirrors=(
"https://ghfast.top/$REPO_BASE_ORIGIN"
"https://ghproxy.net/$REPO_BASE_ORIGIN"
"https://mirror.ghproxy.com/$REPO_BASE_ORIGIN"
)
for m in "${mirrors[@]}"; do
if curl -sI --connect-timeout 3 "$m/oa.sh" >/dev/null 2>&1; then
echo -e " ${YELLOW}[MIRROR]${NC} Using mirror for GitHub downloads"
REPO_BASE="$m"; return 0
fi
done
return 1
}
# Kept in sync with scripts/lib.sh resolve_npm_registry()
NPM_REGISTRY_ORIGIN="https://registry.npmjs.org/"
NPM_REGISTRY_MIRROR="https://registry.npmmirror.com/"
resolve_npm_registry() {
local choice
local cache_file="$OCA_DIR/.npm-registry"
local reachable=0
if curl -sI --connect-timeout 5 "$NPM_REGISTRY_ORIGIN" >/dev/null 2>&1; then
choice="$NPM_REGISTRY_ORIGIN"
reachable=1
elif curl -sI --connect-timeout 5 "$NPM_REGISTRY_MIRROR" >/dev/null 2>&1; then
echo -e " ${YELLOW}[MIRROR]${NC} Using npm mirror: ${NPM_REGISTRY_MIRROR}"
choice="$NPM_REGISTRY_MIRROR"
reachable=1
else
choice="$NPM_REGISTRY_ORIGIN"
fi
mkdir -p "$(dirname "$cache_file")"
printf '%s' "$choice" > "$cache_file.tmp" && mv "$cache_file.tmp" "$cache_file"
export NPM_CONFIG_REGISTRY="$choice"
if [ "$reachable" -eq 1 ]; then
return 0
fi
return 1
}
# SSL cert for curl (bootstrap curl looks at hardcoded com.termux path)
export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem"
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem"
# Git system config has hardcoded com.termux path — skip it
export GIT_CONFIG_NOSYSTEM=1
# Git exec path (git looks for helpers like git-remote-https here)
export GIT_EXEC_PATH="$PREFIX/libexec/git-core"
# Git template dir (hardcoded /data/data/com.termux path workaround)
export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates"
if [ -f "$MARKER" ]; then
echo -e "${GREEN}Post-setup already completed.${NC}"
exit 0
fi
echo ""
echo "══════════════════════════════════════════════"
echo " OpenClaw Android — Installing components"
echo "══════════════════════════════════════════════"
echo ""
mkdir -p "$OCA_DIR" "$OCA_DIR/patches" "$TMPDIR"
TERMUX_DEB_REPO="https://packages-cf.termux.dev/apt/termux-main"
PACMAN_PKG_REPO="https://service.termux-pacman.dev/gpkg/aarch64"
TERMUX_INNER="data/data/com.termux/files/usr"
DEB_DIR="$TMPDIR/debs"
PKG_DIR="$TMPDIR/pkgs"
EXTRACT_DIR="$TMPDIR/pkg-extract"
# ─── Helper: install_deb ──────────────────────
# Downloads a .deb from Termux repo and extracts into $PREFIX
install_deb() {
local filename="$1"
local name
name=$(basename "$filename" | sed 's/_[0-9].*//')
local url="${TERMUX_DEB_REPO}/${filename}"
local deb_file="${DEB_DIR}/$(basename "$filename")"
if [ -f "$deb_file" ]; then
echo " (cached) $name"
else
echo " downloading $name..."
curl -fsSL --max-time 120 -o "$deb_file" "$url"
fi
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
dpkg-deb -x "$deb_file" "$EXTRACT_DIR" 2>/dev/null
# Relocate: data/data/com.termux/files/usr/* → $PREFIX/
if [ -d "$EXTRACT_DIR/$TERMUX_INNER" ]; then
cp -a "$EXTRACT_DIR/$TERMUX_INNER/"* "$PREFIX/" 2>/dev/null || true
fi
rm -rf "$EXTRACT_DIR"
}
# ─── Helper: install_pacman_pkg ───────────────
# Downloads a .pkg.tar.xz from pacman repo and extracts into target dir
install_pacman_pkg() {
local filename="$1"
local target="$2" # e.g., $PREFIX/glibc
local name
name=${filename%%-[0-9]*}
local url="${PACMAN_PKG_REPO}/${filename}"
local pkg_file="${PKG_DIR}/${filename}"
if [ -f "$pkg_file" ]; then
echo " (cached) $name"
else
echo " downloading $name..."
curl -fsSL --max-time 300 -o "$pkg_file" "$url"
fi
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
tar -xJf "$pkg_file" -C "$EXTRACT_DIR" 2>/dev/null
# Pacman packages also extract under data/data/com.termux/files/usr/...
local inner="$EXTRACT_DIR/$TERMUX_INNER"
if [ -d "$inner/glibc" ]; then
# glibc packages go under $PREFIX/glibc/
cp -a "$inner/glibc/"* "$target/" 2>/dev/null || true
elif [ -d "$inner" ]; then
cp -a "$inner/"* "$target/" 2>/dev/null || true
fi
rm -rf "$EXTRACT_DIR"
}
# ─── [1/7] Install essential packages ─────────
echo -e "${YELLOW}[1/7]${NC} Installing essential packages..."
mkdir -p "$DEB_DIR" "$PKG_DIR"
# Download Packages index to resolve .deb filenames
echo " Fetching package index..."
PACKAGES_FILE="$TMPDIR/Packages"
curl -fsSL --max-time 60 \
"${TERMUX_DEB_REPO}/dists/stable/main/binary-aarch64/Packages" \
-o "$PACKAGES_FILE"
# Resolve package filename from Packages index
get_deb_filename() {
local pkg="$1"
awk -v pkg="$pkg" '
/^Package: / { found = ($2 == pkg) }
found && /^Filename:/ { print $2; exit }
' "$PACKAGES_FILE"
}
# Packages to install via dpkg-deb (dependency order, only those missing from bootstrap)
DEB_PACKAGES=(
libexpat # git dep
pcre2 # git dep
git # for npm/openclaw
)
TOTAL=${#DEB_PACKAGES[@]}
COUNT=0
for pkg in "${DEB_PACKAGES[@]}"; do
COUNT=$((COUNT + 1))
filename=$(get_deb_filename "$pkg")
if [ -z "$filename" ]; then
echo -e " ${RED}${NC} Package '$pkg' not found in index"
continue
fi
echo " [$COUNT/$TOTAL] $pkg"
install_deb "$filename"
done
# Make sure newly extracted binaries are executable
chmod +x "$PREFIX/bin/"* 2>/dev/null || true
# Verify git
if [ -f "$PREFIX/bin/git" ]; then
echo -e " ${GREEN}${NC} git $(git --version 2>/dev/null | head -1)"
else
echo -e " ${RED}${NC} git not found after extraction"
exit 1
fi
# ─── [2/7] glibc runtime ─────────────────────
echo -e "${YELLOW}[2/7]${NC} Installing glibc runtime..."
if [ -x "$GLIBC_LDSO" ]; then
echo -e " ${GREEN}[SKIP]${NC} glibc already installed"
else
mkdir -p "$PREFIX/glibc"
# Download glibc package directly from pacman repo (no pacman needed)
# The gpkg.db tells us: glibc-2.42-0-aarch64.pkg.tar.xz (~9.7MB)
echo " Downloading glibc (~10MB)..."
install_pacman_pkg "glibc-2.42-0-aarch64.pkg.tar.xz" "$PREFIX/glibc"
# gcc-libs-glibc provides libstdc++.so.6 needed by Node.js (~24MB)
echo " Downloading gcc-libs (~24MB)..."
install_pacman_pkg "gcc-libs-glibc-14.2.1-1-aarch64.pkg.tar.xz" "$PREFIX/glibc"
# Verify linker
if [ ! -f "$GLIBC_LDSO" ]; then
echo -e " ${RED}${NC} glibc linker not found at $GLIBC_LDSO"
exit 1
fi
chmod +x "$GLIBC_LDSO"
mkdir -p "$OCA_DIR"
touch "$OCA_DIR/.glibc-arch"
echo -e " ${GREEN}${NC} glibc installed"
fi
# Install supplementary glibc libraries (libcap etc.)
_GLIBC_LIBS_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/patches/glibc-libs"
if [ -d "$PREFIX/glibc/lib" ] && [ -d "$_GLIBC_LIBS_SRC" ]; then
for _lib in "$_GLIBC_LIBS_SRC"/*.so.*; do
[ -f "$_lib" ] || continue
_fn=$(basename "$_lib")
if [ ! -f "$PREFIX/glibc/lib/$_fn" ]; then
cp "$_lib" "$PREFIX/glibc/lib/$_fn"
_sn=$(echo "$_fn" | sed -E 's/^(lib[^.]+\.so\.[0-9]+)\..*/\1/')
[ "$_sn" != "$_fn" ] && ln -sf "$_fn" "$PREFIX/glibc/lib/$_sn"
echo -e " ${GREEN}${NC} Installed $_sn"
fi
done
fi
# Ensure glibc /etc/hosts exists (localhost resolution)
if [ -d "$PREFIX/glibc/etc" ] && [ ! -f "$PREFIX/glibc/etc/hosts" ]; then
cat > "$PREFIX/glibc/etc/hosts" <<'HOSTS'
127.0.0.1 localhost localhost.localdomain
::1 localhost ip6-localhost ip6-loopback
HOSTS
echo -e " ${GREEN}${NC} Created glibc /etc/hosts"
fi
echo -e " Linker: $GLIBC_LDSO"
# ─── [3/7] Node.js ──────────────────────────
echo -e "${YELLOW}[3/7]${NC} Installing Node.js v${NODE_VERSION}..."
mkdir -p "$NODE_DIR/bin"
_NODE_CMD=""
if [ -x "$BIN_DIR/node" ]; then _NODE_CMD="$BIN_DIR/node"
elif [ -f "$NODE_DIR/bin/node.real" ] && [ -x "$NODE_DIR/bin/node" ]; then _NODE_CMD="$NODE_DIR/bin/node"
fi
if [ -n "$_NODE_CMD" ] && "$_NODE_CMD" --version &>/dev/null; then
INSTALLED_VER=$("$_NODE_CMD" --version 2>/dev/null || echo "")
echo -e " ${GREEN}[SKIP]${NC} Node.js already installed ($INSTALLED_VER)"
# Repair wrappers in BIN_DIR (safe from npm overwrites)
mkdir -p "$BIN_DIR"
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then
cat > "$BIN_DIR/npm" << NPMWRAP
#!$PREFIX/bin/bash
"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@"
_npm_exit=\$?
case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*)
_oc_bin="$PREFIX/bin/openclaw"
_oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs"
if [ -f "\$_oc_mjs" ]; then
[ -L "\$_oc_bin" ] && rm -f "\$_oc_bin"
printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin"
chmod +x "\$_oc_bin"
fi
;;
esac
# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix)
case "\$*" in *codex-cli-termux*)
_codex_bin="$PREFIX/bin/codex"
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
if [ -f "\$_codex_pkg/codex.bin" ]; then
[ -L "\$_codex_bin" ] && rm -f "\$_codex_bin"
printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin"
chmod +x "\$_codex_bin"
fi
;;
esac
# Fix shebangs in npm global CLI entry points after global install
case "\$*" in *-g*|*--global*)
for _js in $PREFIX/lib/node_modules/*/bin/*.js \
$PREFIX/lib/node_modules/@*/*/bin/*.js; do
[ -f "\$_js" ] || continue
head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js"
done
;;
esac
exit \$_npm_exit
NPMWRAP
chmod +x "$BIN_DIR/npm"
fi
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then
cat > "$BIN_DIR/npx" << NPXWRAP
#!$PREFIX/bin/bash
exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@"
NPXWRAP
chmod +x "$BIN_DIR/npx"
fi
if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack"
fi
else
NODE_TAR="node-v${NODE_VERSION}-linux-arm64"
echo " Downloading Node.js v${NODE_VERSION} (~25MB)..."
curl -fSL --max-time 300 \
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TAR}.tar.xz" \
-o "$TMPDIR/${NODE_TAR}.tar.xz"
echo " Extracting..."
tar -xJf "$TMPDIR/${NODE_TAR}.tar.xz" -C "$NODE_DIR" --strip-components=1
# Move original binary → node.real
if [ -f "$NODE_DIR/bin/node" ] && [ ! -L "$NODE_DIR/bin/node" ]; then
mv "$NODE_DIR/bin/node" "$NODE_DIR/bin/node.real"
fi
rm -f "$TMPDIR/${NODE_TAR}.tar.xz"
# Create grun-style node wrapper in BIN_DIR (safe from npm overwrites)
mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/node" << WRAPPER
#!${PREFIX}/bin/bash
[ -n "\$LD_PRELOAD" ] && export _OA_ORIG_LD_PRELOAD="\$LD_PRELOAD"
unset LD_PRELOAD
export _OA_WRAPPER_PATH="$BIN_DIR/node"
_OA_COMPAT="\$HOME/.openclaw-android/patches/glibc-compat.js"
if [ -f "\$_OA_COMPAT" ]; then
case "\${NODE_OPTIONS:-}" in
*"\$_OA_COMPAT"*) ;;
*) export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }-r \$_OA_COMPAT" ;;
esac
fi
_LEADING_OPTS=""
_COUNT=0
for _arg in "\$@"; do
case "\$_arg" in --*) _COUNT=\$((_COUNT + 1)) ;; *) break ;; esac
done
if [ \$_COUNT -gt 0 ] && [ \$_COUNT -lt \$# ]; then
while [ \$# -gt 0 ]; do
case "\$1" in
--*) _LEADING_OPTS="\${_LEADING_OPTS:+\$_LEADING_OPTS }\$1"; shift ;;
*) break ;;
esac
done
export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }\$_LEADING_OPTS"
fi
exec "$GLIBC_LDSO" --library-path "$PREFIX/glibc/lib" "$NODE_DIR/bin/node.real" "\$@"
WRAPPER
chmod +x "$BIN_DIR/node"
# Create npm/npx wrappers in BIN_DIR
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then
cat > "$BIN_DIR/npm" << NPMWRAP
#!$PREFIX/bin/bash
"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@"
_npm_exit=\$?
case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*)
_oc_bin="$PREFIX/bin/openclaw"
_oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs"
if [ -f "\$_oc_mjs" ]; then
[ -L "\$_oc_bin" ] && rm -f "\$_oc_bin"
printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin"
chmod +x "\$_oc_bin"
fi
;;
esac
# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix)
case "\$*" in *codex-cli-termux*)
_codex_bin="$PREFIX/bin/codex"
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
if [ -f "\$_codex_pkg/codex.bin" ]; then
[ -L "\$_codex_bin" ] && rm -f "\$_codex_bin"
printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin"
chmod +x "\$_codex_bin"
fi
;;
esac
# Fix shebangs in npm global CLI entry points after global install
case "\$*" in *-g*|*--global*)
for _js in $PREFIX/lib/node_modules/*/bin/*.js \
$PREFIX/lib/node_modules/@*/*/bin/*.js; do
[ -f "\$_js" ] || continue
head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js"
done
;;
esac
exit \$_npm_exit
NPMWRAP
chmod +x "$BIN_DIR/npm"
fi
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then
cat > "$BIN_DIR/npx" << NPXWRAP
#!$PREFIX/bin/bash
exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@"
NPXWRAP
chmod +x "$BIN_DIR/npx"
fi
# corepack: shebang patch only
if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack"
fi
# Configure npm
export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH"
"$BIN_DIR/npm" config set script-shell "$PREFIX/bin/sh" 2>/dev/null || true
# Verify
NODE_VER=$("$BIN_DIR/node" --version 2>/dev/null) || {
echo -e " ${RED}${NC} Node.js verification failed"
exit 1
}
echo -e " ${GREEN}${NC} Node.js $NODE_VER (glibc)"
fi
# ─── [4/7] OpenClaw ─────────────────────────
echo -e "${YELLOW}[4/7]${NC} Installing OpenClaw..."
export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH"
# Auto-detect GitHub mirror for restricted networks
resolve_repo_base
# Auto-detect npm registry (session-scoped via NPM_CONFIG_REGISTRY env var).
# Does NOT write to ~/.npmrc — see CHANGELOG v1.0.24.
resolve_npm_registry || true
# Force git to use HTTPS instead of SSH (no SSH client available).
# Preserve any existing user .gitconfig (name, email, aliases); only set our keys.
touch "$HOME/.gitconfig"
git config --global http.sslCAInfo "$PREFIX/etc/tls/cert.pem"
git config --global --unset-all url."https://github.com/".insteadOf 2>/dev/null || true
git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global --add url."https://github.com/".insteadOf "git@github.com:"
# Git wrapper: replace $PREFIX/bin/git with a wrapper that:
# 1. Strips --recurse-submodules (triggers open() on hardcoded com.termux path)
# 2. Cleans existing target dirs before clone (npm's withTempDir creates dir first)
# npm caches git path at module load via which.sync('git'), so we must replace the binary.
# $PREFIX/bin/git is a symlink -> ../libexec/git-core/git (the real ELF binary).
REAL_GIT="$PREFIX/libexec/git-core/git"
if [ -f "$REAL_GIT" ] && [ ! -f "$PREFIX/bin/git.wrapper-installed" ]; then
echo " Installing git wrapper (strips --recurse-submodules)..."
rm -f "$PREFIX/bin/git"
# Write shebang with absolute path (no LD_PRELOAD = no /bin/bash rewrite)
echo "#!${PREFIX}/bin/bash" > "$PREFIX/bin/git"
cat >> "$PREFIX/bin/git" << 'ENDWRAP'
filtered=()
is_clone=false
for a in "$@"; do
case "$a" in
--recurse-submodules) ;;
clone) is_clone=true; filtered+=("$a") ;;
*) filtered+=("$a") ;;
esac
done
if $is_clone; then
for a in "${filtered[@]}"; do
case "$a" in
clone|--*|-*|http*|ssh*|git*|[0-9]) ;;
*) [ -d "$a" ] && rm -rf "$a" ;;
esac
done
fi
ENDWRAP
echo "exec \"$REAL_GIT\" \"\${filtered[@]}\"" >> "$PREFIX/bin/git"
chmod +x "$PREFIX/bin/git"
touch "$PREFIX/bin/git.wrapper-installed"
echo -e " ${GREEN}\u2713${NC} git wrapper installed"
else
if [ -f "$PREFIX/bin/git.wrapper-installed" ]; then
echo -e " ${GREEN}[SKIP]${NC} git wrapper already installed"
else
echo -e " ${RED}\u2717${NC} Real git not found at $REAL_GIT"
exit 1
fi
fi
if command -v openclaw &>/dev/null 2>&1; then
OC_VER=$(openclaw --version 2>/dev/null || echo "unknown")
echo -e " ${GREEN}[SKIP]${NC} OpenClaw already installed ($OC_VER)"
else
# Clean npm cache tmp dir (leftover from previous failed installs)
rm -rf "$HOME/.npm/_cacache/tmp" 2>/dev/null || true
npm install -g openclaw@latest --ignore-scripts 2>&1
OC_VER=$(openclaw --version 2>/dev/null || echo "installed")
echo -e " ${GREEN}${NC} OpenClaw $OC_VER"
fi
# Restore optional/channel deps that --ignore-scripts skips.
# Uses npm_config_ignore_scripts=true so sharp's native build doesn't block.
OPENCLAW_DIR="$(npm root -g)/openclaw"
if [ -d "$OPENCLAW_DIR" ]; then
echo " Restoring optional dependencies..."
(cd "$OPENCLAW_DIR" && npm_config_ignore_scripts=true node scripts/postinstall-bundled-plugins.mjs 2>/dev/null) || true
fi
# Install clawdhub (skill manager)
echo " Installing clawdhub..."
if npm install -g clawdhub --no-fund --no-audit; then
echo -e " ${GREEN}${NC} clawdhub installed"
CLAWHUB_DIR="$(npm root -g)/clawdhub"
if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then
echo " Installing undici dependency for clawdhub..."
(cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit) || true
fi
else
echo -e " ${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)"
fi
# PyYAML (for .skill packaging)
command -v python &>/dev/null && { python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true; }
# Run openclaw update (builds native modules like sharp)
echo " Running: openclaw update (this may take 5-10 minutes)..."
openclaw update || true
# ─── [5/7] Patches ──────────────────────────
echo -e "${YELLOW}[5/7]${NC} Applying patches..."
# Copy glibc-compat.js from project (bundled alongside this script)
COMPAT_SRC="$(dirname "$0")/glibc-compat.js"
if [ -f "$COMPAT_SRC" ]; then
cp "$COMPAT_SRC" "$OCA_DIR/patches/glibc-compat.js"
else
# Fallback: download from repo
curl -fsSL "$REPO_BASE/patches/glibc-compat.js" \
-o "$OCA_DIR/patches/glibc-compat.js" 2>/dev/null || true
fi
# systemctl stub
printf '#!%s/bin/bash\nexit 0\n' "$PREFIX" > "$PREFIX/bin/systemctl"
chmod +x "$PREFIX/bin/systemctl"
# sharp WASM fallback (prebuilt native binaries don't load on Android)
if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then
if ! node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
echo " Installing sharp WebAssembly runtime..."
(cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3) || true
fi
fi
echo -e " ${GREEN}${NC} Patches applied"
# ─── [6/7] Environment ──────────────────────
echo -e "${YELLOW}[6/7]${NC} Configuring environment..."
cat > "$HOME/.bashrc" << BASHRC
# OpenClaw Android environment
export PREFIX="$PREFIX"
export HOME="$HOME"
export TMPDIR="$TMPDIR"
export PATH="$BIN_DIR:$NODE_DIR/bin:\$PREFIX/bin:\$PATH"
export LD_LIBRARY_PATH="$PREFIX/lib"
export LD_PRELOAD="$PREFIX/lib/libtermux-exec.so"
export TERMUX__PREFIX="$PREFIX"
export TERMUX_PREFIX="$PREFIX"
export LANG=en_US.UTF-8
export TERM=xterm-256color
export OA_GLIBC=1
export CONTAINER=1
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem"
export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem"
export GIT_CONFIG_NOSYSTEM=1
export GIT_EXEC_PATH="$PREFIX/libexec/git-core"
export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates"
export CLAWDHUB_WORKDIR="$HOME/.openclaw/workspace"
export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
# npm registry (auto-detected by OpenClaw Android, safe to override manually)
[ -z "\${NPM_CONFIG_REGISTRY:-}" ] && [ -s "\$HOME/.openclaw-android/.npm-registry" ] && \\
export NPM_CONFIG_REGISTRY="\$(cat "\$HOME/.openclaw-android/.npm-registry")"
BASHRC
echo -e " ${GREEN}${NC} ~/.bashrc configured"
# oa CLI (enables oa --update, oa --backup, etc.)
if curl -fsSL "$REPO_BASE/oa.sh" \
-o "$PREFIX/bin/oa" 2>/dev/null; then
chmod +x "$PREFIX/bin/oa"
echo -e " ${GREEN}${NC} oa CLI installed"
else
echo -e " ${YELLOW}[WARN]${NC} oa CLI installation failed (non-critical)"
fi
# ─── [7/7] Optional Tools ──────────────────
TOOL_CONF="$OCA_DIR/tool-selections.conf"
if [ -f "$TOOL_CONF" ]; then
# shellcheck source=/dev/null
source "$TOOL_CONF"
HAS_TOOLS=false
for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_CODE_SERVER INSTALL_PLAYWRIGHT INSTALL_CLAUDE_CODE INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do
eval "val=\${$var:-false}"
# shellcheck disable=SC2154
[ "$val" = "true" ] && HAS_TOOLS=true && break
done
if $HAS_TOOLS; then
echo -e "${YELLOW}[7/7]${NC} Installing optional tools..."
# Helper: install .deb with direct dependencies
install_with_deps() {
local pkg="$1"
local deps
deps=$(awk -v pkg="$pkg" '
/^Package: / { found = ($2 == pkg) }
found && /^Depends:/ {
gsub(/^Depends: /, "")
gsub(/ *\([^)]*\)/, "")
gsub(/, /, "\n")
print; exit
}
' "$PACKAGES_FILE")
while IFS= read -r dep; do
dep=$(echo "$dep" | tr -d ' ')
[ -z "$dep" ] && continue
local dep_file
dep_file=$(get_deb_filename "$dep")
if [ -n "$dep_file" ]; then install_deb "$dep_file" 2>/dev/null || true; fi
done <<< "$deps"
local filename
filename=$(get_deb_filename "$pkg")
[ -n "$filename" ] && install_deb "$filename"
}
# Termux packages
[ "${INSTALL_TMUX:-false}" = "true" ] && {
echo " Installing tmux..."
install_with_deps tmux
echo -e " ${GREEN}${NC} tmux"
}
[ "${INSTALL_TTYD:-false}" = "true" ] && {
echo " Installing ttyd..."
install_with_deps ttyd
echo -e " ${GREEN}${NC} ttyd"
}
[ "${INSTALL_DUFS:-false}" = "true" ] && {
echo " Installing dufs..."
install_with_deps dufs
echo -e " ${GREEN}${NC} dufs"
}
# npm packages
[ "${INSTALL_CODE_SERVER:-false}" = "true" ] && {
echo " Installing code-server (this may take a while)..."
npm install -g code-server 2>&1 || true
echo -e " ${GREEN}${NC} code-server"
}
[ "${INSTALL_PLAYWRIGHT:-false}" = "true" ] && {
echo " Installing Playwright (playwright-core)..."
npm install -g playwright-core 2>&1 || true
# Set Playwright environment variables if Chromium is available
CHROMIUM_BIN=""
for bin in "$PREFIX/bin/chromium-browser" "$PREFIX/bin/chromium"; do
[ -x "$bin" ] && CHROMIUM_BIN="$bin" && break
done
if [ -n "$CHROMIUM_BIN" ]; then
PW_MARKER_START="# >>> Playwright >>>"
PW_MARKER_END="# <<< Playwright <<<"
if ! grep -qF "$PW_MARKER_START" "$HOME/.bashrc"; then
cat >> "$HOME/.bashrc" << PWENV
${PW_MARKER_START}
export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM_BIN"
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
${PW_MARKER_END}
PWENV
fi
echo -e " ${GREEN}${NC} Playwright (env: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$CHROMIUM_BIN)"
else
echo -e " ${GREEN}${NC} Playwright (install Chromium later via 'oa --install' for full setup)"
fi
}
[ "${INSTALL_CLAUDE_CODE:-false}" = "true" ] && {
echo " Installing Claude Code..."
npm install -g @anthropic-ai/claude-code 2>&1 || true
echo -e " ${GREEN}${NC} Claude Code"
}
[ "${INSTALL_GEMINI_CLI:-false}" = "true" ] && {
echo " Installing Gemini CLI..."
npm install -g @google/gemini-cli 2>&1 || true
echo -e " ${GREEN}${NC} Gemini CLI"
}
[ "${INSTALL_CODEX_CLI:-false}" = "true" ] && {
echo " Installing Codex CLI (Termux)..."
npm install -g @mmmbuto/codex-cli-termux 2>&1 || true
# Create codex CLI wrapper (DioNanos fork launcher fix)
_codex_bin="$PREFIX/bin/codex"
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
if [ -f "$_codex_pkg/codex.bin" ]; then
[ -L "$_codex_bin" ] && rm -f "$_codex_bin"
printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \
"$PREFIX" "$_codex_pkg" > "$_codex_bin"
chmod +x "$_codex_bin"
fi
echo -e " ${GREEN}${NC} Codex CLI (Termux)"
}
# Fix shebangs in npm global CLIs (kept in sync with scripts/lib.sh fix_npm_global_shebangs())
for _js in "$PREFIX/lib/node_modules"/*/bin/*.js \
"$PREFIX/lib/node_modules"/@*/*/bin/*.js; do
[ -f "$_js" ] || continue
head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$_js"
done
else
echo -e "${YELLOW}[7/7]${NC} No optional tools selected"
fi
else
echo -e "${YELLOW}[7/7]${NC} No optional tools selected"
fi
# ─── Cleanup ────────────────────────────────
rm -rf "$DEB_DIR" "$PKG_DIR" "$PACKAGES_FILE" "$TMPDIR/gpkg.db" 2>/dev/null || true
# ─── Done ────────────────────────────────────
touch "$MARKER"
echo ""
echo "══════════════════════════════════════════════"
echo -e " ${GREEN}✓ Installation complete!${NC}"
echo "══════════════════════════════════════════════"
echo ""
echo " Loading environment..."
source "$HOME/.bashrc"
echo ""
echo " Starting OpenClaw onboard..."
echo ""
openclaw onboard
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108">
<!-- Background circle -->
<circle cx="54" cy="54" r="54" fill="#111827"/>
<!-- 4 claw scratch marks, rotated diagonally -->
<g transform="rotate(-25 54 54)">
<!-- Scratch 1 (outer left, shorter) -->
<path fill="#FFFFFF" d="M31,22 C28,44,28,64,31,86 C34,64,34,44,31,22Z"/>
<!-- Scratch 2 (inner left, longer) -->
<path fill="#FFFFFF" d="M43,16 C40,40,40,64,43,92 C46,64,46,40,43,16Z"/>
<!-- Scratch 3 (inner right, longer) -->
<path fill="#FFFFFF" d="M55,16 C52,40,52,64,55,92 C58,64,58,40,55,16Z"/>
<!-- Scratch 4 (outer right, shorter) -->
<path fill="#FFFFFF" d="M67,22 C64,44,64,64,67,86 C70,64,70,44,67,22Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 732 B

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

After

Width:  |  Height:  |  Size: 782 B

@@ -0,0 +1,48 @@
package com.openclaw.android
import android.util.Log
/**
* Centralized logging wrapper.
* All app code should use AppLogger instead of android.util.Log directly.
* detekt ForbiddenMethodCall enforces this rule for new code.
*/
@Suppress("ForbiddenMethodCall")
object AppLogger {
fun v(
tag: String,
message: String,
) = Log.v(tag, message)
fun d(
tag: String,
message: String,
) = Log.d(tag, message)
fun i(
tag: String,
message: String,
) = Log.i(tag, message)
fun w(
tag: String,
message: String,
) = Log.w(tag, message)
fun w(
tag: String,
message: String,
throwable: Throwable,
) = Log.w(tag, message, throwable)
fun e(
tag: String,
message: String,
) = Log.e(tag, message)
fun e(
tag: String,
message: String,
throwable: Throwable,
) = Log.e(tag, message, throwable)
}
@@ -0,0 +1,30 @@
package com.openclaw.android
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Starts MainActivity on device boot so terminal sessions
* and openclaw gateway can auto-launch.
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BootReceiver"
}
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
AppLogger.i(TAG, "Boot completed — launching OpenClaw")
val launchIntent =
Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("from_boot", true)
}
context.startActivity(launchIntent)
}
}
}
@@ -0,0 +1,468 @@
package com.openclaw.android
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.net.URL
import java.util.zip.ZipInputStream
/**
* Manages Termux bootstrap download, extraction, and configuration.
* Phase 0: extracts from assets. Phase 1+: downloads from network.
* Based on AnyClaw BootstrapInstaller.kt pattern (§2.2.1).
*/
class BootstrapManager(
private val context: Context,
) {
companion object {
private const val TAG = "BootstrapManager"
private const val PROGRESS_PREPARING = 0.05f
private const val PROGRESS_DOWNLOADING = 0.10f
private const val PROGRESS_EXTRACTING = 0.30f
private const val PROGRESS_CONFIGURING = 0.60f
private const val ELF_MAGIC_SIZE = 4
private val ELF_SIGNATURE = byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())
private const val SYMLINK_SEPARATOR = ""
private const val SYMLINK_PARTS_COUNT = 2
}
val prefixDir = File(context.filesDir, "usr")
val homeDir = File(context.filesDir, "home")
val tmpDir = File(context.filesDir, "tmp")
val wwwDir = File(prefixDir, "share/openclaw-app/www")
private val stagingDir = File(context.filesDir, "usr-staging")
fun isInstalled(): Boolean = prefixDir.resolve("bin/sh").exists()
fun needsPostSetup(): Boolean {
val marker = File(homeDir, ".openclaw-android/.post-setup-done")
return isInstalled() && !marker.exists()
}
val postSetupScript: File
get() = File(homeDir, ".openclaw-android/post-setup.sh")
data class SetupStatus(
val bootstrapInstalled: Boolean,
val runtimeInstalled: Boolean,
val wwwInstalled: Boolean,
val platformInstalled: Boolean,
)
fun getStatus(): SetupStatus =
SetupStatus(
bootstrapInstalled = isInstalled(),
runtimeInstalled = prefixDir.resolve("bin/node").exists(),
wwwInstalled = wwwDir.resolve("index.html").exists(),
platformInstalled = File(homeDir, ".openclaw-android/.post-setup-done").exists(),
)
/**
* Full setup flow. Reports progress via callback (0.01.0).
*/
suspend fun startSetup(onProgress: (Float, String) -> Unit) =
withContext(Dispatchers.IO) {
// Clean up any incomplete previous attempt before starting
if (stagingDir.exists()) {
AppLogger.i(TAG, "Removing incomplete staging dir from previous attempt")
stagingDir.deleteRecursively()
}
if (isInstalled()) {
// Bootstrap exists but setup is incomplete — wipe and reinstall
AppLogger.i(TAG, "Incomplete bootstrap detected, reinstalling...")
prefixDir.deleteRecursively()
}
// Step 1: Download or extract bootstrap
onProgress(PROGRESS_PREPARING, "Preparing bootstrap...")
val zipStream = getBootstrapStream(onProgress)
// Step 2: Extract bootstrap
onProgress(PROGRESS_EXTRACTING, "Extracting bootstrap...")
extractBootstrap(zipStream)
// Step 3: Fix paths and configure
onProgress(PROGRESS_CONFIGURING, "Configuring environment...")
fixTermuxPaths(stagingDir)
configureApt(stagingDir)
// Step 4: Atomic rename
stagingDir.renameTo(prefixDir)
setupDirectories()
copyAssetScripts()
syncWwwFromAssets()
setupTermuxExec()
onProgress(1f, "Setup complete")
}
// --- Bootstrap source ---
private suspend fun getBootstrapStream(onProgress: (Float, String) -> Unit): InputStream {
// Phase 0: Try assets first
try {
return context.assets.open("bootstrap-aarch64.zip")
} catch (_: Exception) {
// Phase 1: Download from network
}
onProgress(PROGRESS_DOWNLOADING, "Downloading bootstrap...")
val url = UrlResolver(context).getBootstrapUrl()
return URL(url).openStream()
}
// --- Extraction ---
private fun extractBootstrap(inputStream: InputStream) {
stagingDir.deleteRecursively()
stagingDir.mkdirs()
ZipInputStream(inputStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
processZipEntry(zip, entry)
zip.closeEntry()
entry = zip.nextEntry
}
}
}
private fun processZipEntry(
zip: ZipInputStream,
entry: java.util.zip.ZipEntry,
) {
if (entry.name == "SYMLINKS.txt") {
processSymlinks(zip, stagingDir)
} else if (!entry.isDirectory) {
val file = File(stagingDir, entry.name)
file.parentFile?.mkdirs()
file.outputStream().use { out -> zip.copyTo(out) }
markExecutableIfNeeded(file, entry.name)
}
}
private fun markExecutableIfNeeded(
file: File,
name: String,
) {
val knownExecutable =
name.startsWith("bin/") ||
name.startsWith("libexec/") ||
name.startsWith("lib/apt/") ||
name.startsWith("lib/bash/") ||
name.endsWith(".so") ||
name.contains(".so.")
if (knownExecutable) {
file.setExecutable(true)
} else if (file.length() > ELF_MAGIC_SIZE && isElfBinary(file)) {
file.setExecutable(true)
}
}
private fun isElfBinary(file: File): Boolean =
try {
file.inputStream().use { fis ->
val magic = ByteArray(ELF_MAGIC_SIZE)
fis.read(magic) == ELF_MAGIC_SIZE &&
magic.contentEquals(ELF_SIGNATURE)
}
} catch (_: Exception) {
false
}
/**
* Process SYMLINKS.txt: each line is "target←linkpath".
* Replace com.termux paths with our package name.
*/
private fun processSymlinks(
zip: ZipInputStream,
targetDir: File,
) {
val content = zip.bufferedReader().readText()
val ourPackage = context.packageName
content
.lines()
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(SYMLINK_SEPARATOR)
if (parts.size == SYMLINK_PARTS_COUNT) parts else null
}.forEach { parts ->
val symlinkTarget = parts[0].trim().replace("com.termux", ourPackage)
val symlinkPath = parts[1].trim()
val linkFile = File(targetDir, symlinkPath)
linkFile.parentFile?.mkdirs()
try {
Os.symlink(symlinkTarget, linkFile.absolutePath)
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to create symlink: $symlinkPath -> $symlinkTarget", e)
}
}
}
// --- Path fixing (§2.2.2) ---
private fun fixTermuxPaths(dir: File) {
val ourPackage = context.packageName
val oldPrefix = "/data/data/com.termux/files/usr"
val newPrefix = prefixDir.absolutePath
// Fix dpkg status database
fixTextFile(dir.resolve("var/lib/dpkg/status"), oldPrefix, newPrefix)
// Fix dpkg info files
val dpkgInfoDir = dir.resolve("var/lib/dpkg/info")
if (dpkgInfoDir.isDirectory) {
dpkgInfoDir.listFiles()?.filter { it.name.endsWith(".list") }?.forEach { file ->
fixTextFile(file, "com.termux", ourPackage)
}
}
// Fix git scripts shebangs
val gitCoreDir = dir.resolve("libexec/git-core")
if (gitCoreDir.isDirectory) {
gitCoreDir.listFiles()?.forEach { file ->
if (file.isFile && !file.name.contains(".")) {
fixTextFile(file, oldPrefix, newPrefix)
}
}
}
}
private fun fixTextFile(
file: File,
oldText: String,
newText: String,
) {
if (!file.exists() || !file.isFile) return
try {
val content = file.readText()
if (content.contains(oldText)) {
file.writeText(content.replace(oldText, newText))
}
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to fix paths in ${file.name}", e)
}
}
// --- apt configuration (§2.2.3) ---
private fun configureApt(dir: File) {
val prefix = prefixDir.absolutePath
val ourPackage = context.packageName
// sources.list: HTTPS→HTTP downgrade + package name fix
val sourcesList = dir.resolve("etc/apt/sources.list")
if (sourcesList.exists()) {
sourcesList.writeText(
sourcesList
.readText()
.replace("https://", "http://")
.replace("com.termux", ourPackage),
)
}
// apt.conf: full rewrite with correct paths
val aptConf = dir.resolve("etc/apt/apt.conf")
aptConf.parentFile?.mkdirs()
// Create directories needed by apt and dpkg
dir.resolve("etc/apt/apt.conf.d").mkdirs()
dir.resolve("etc/apt/preferences.d").mkdirs()
dir.resolve("etc/dpkg/dpkg.cfg.d").mkdirs()
dir.resolve("var/cache/apt").mkdirs()
dir.resolve("var/log/apt").mkdirs()
aptConf.writeText(
"""
Dir "/";
Dir::State "$prefix/var/lib/apt/";
Dir::State::status "$prefix/var/lib/dpkg/status";
Dir::Cache "$prefix/var/cache/apt/";
Dir::Log "$prefix/var/log/apt/";
Dir::Etc "$prefix/etc/apt/";
Dir::Etc::SourceList "$prefix/etc/apt/sources.list";
Dir::Etc::SourceParts "";
Dir::Bin::dpkg "$prefix/bin/dpkg";
Dir::Bin::Methods "$prefix/lib/apt/methods/";
Dir::Bin::apt-key "$prefix/bin/apt-key";
Dpkg::Options:: "--force-configure-any";
Dpkg::Options:: "--force-bad-path";
Dpkg::Options:: "--instdir=$prefix";
Dpkg::Options:: "--admindir=$prefix/var/lib/dpkg";
Acquire::AllowInsecureRepositories "true";
APT::Get::AllowUnauthenticated "true";
""".trimIndent(),
)
}
// --- Setup helpers ---
private fun setupDirectories() {
homeDir.mkdirs()
tmpDir.mkdirs()
wwwDir.mkdirs()
File(homeDir, ".openclaw-android/patches").mkdirs()
}
private fun setupTermuxExec() {
// libtermux-exec.so is included in bootstrap.
// It intercepts execve() to rewrite /data/data/com.termux paths (§2.2.4).
// However, it does NOT intercept open()/opendir() calls, so binaries with
// hardcoded config paths (dpkg, bash) need wrapper scripts.
AppLogger.i(TAG, "Bootstrap installed at ${prefixDir.absolutePath}")
// Create dpkg wrapper that handles confdir permission errors.
// The bootstrap dpkg has /data/data/com.termux/.../etc/dpkg/ hardcoded.
// Since libtermux-exec only rewrites execve() paths, not open() paths,
// dpkg fails on opendir() of the old com.termux config directory.
// The wrapper captures stderr and returns success if confdir is the only error.
val dpkgBin = File(prefixDir, "bin/dpkg")
val dpkgReal = File(prefixDir, "bin/dpkg.real")
if (dpkgBin.exists() && (!dpkgReal.exists() || !dpkgBin.readText().contains("export PATH"))) {
if (!dpkgReal.exists()) dpkgBin.renameTo(dpkgReal)
val d = "$" // dollar sign for shell script
val realPath = dpkgReal.absolutePath
val wrapperContent = """#!/bin/bash
# dpkg wrapper: set PATH so dpkg can find sh, tar, rm, dpkg-deb etc.
# Also suppresses confdir errors from hardcoded com.termux paths.
export PATH="$realPath/../:$realPath/../applets:${d}PATH"
"$realPath" "$d@"
_rc=$d?
if [ ${d}_rc -eq 2 ]; then exit 0; fi
exit ${d}_rc
"""
dpkgBin.writeText(wrapperContent)
dpkgBin.setExecutable(true)
}
}
/**
* Copy post-setup.sh and glibc-compat.js to home dir.
* post-setup.sh: try GitHub first, fall back to bundled asset.
* glibc-compat.js: always use bundled asset.
*/
private fun copyAssetScripts() {
val ocaDir = File(homeDir, ".openclaw-android")
ocaDir.mkdirs()
File(ocaDir, "patches").mkdirs()
val postSetup = File(ocaDir, "post-setup.sh")
copyPostSetupScript(postSetup)
copyBundledAsset("glibc-compat.js", File(ocaDir, "patches/glibc-compat.js"))
}
private fun copyPostSetupScript(target: File) {
val url = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/post-setup.sh"
try {
java.net.URL(url).openStream().use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
target.setExecutable(true)
AppLogger.i(TAG, "post-setup.sh downloaded from GitHub")
return
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to download post-setup.sh, using bundled fallback", e)
}
try {
context.assets.open("post-setup.sh").use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
target.setExecutable(true)
AppLogger.i(TAG, "post-setup.sh copied from bundled assets")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to copy bundled post-setup.sh", e)
}
}
private fun copyBundledAsset(
assetName: String,
target: File,
) {
try {
context.assets.open(assetName).use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
AppLogger.i(TAG, "$assetName copied from bundled assets")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to copy $assetName", e)
}
}
// Runtime packages are installed by post-setup.sh in the terminal
/**
* Apply script update on APK version upgrade:
* - Overwrites post-setup.sh and glibc-compat.js from latest assets
* - Installs/updates oa CLI from GitHub so users can run `oa --update`
*/
fun applyScriptUpdate() {
if (!isInstalled()) return
copyAssetScripts()
syncWwwFromAssets()
installOaCli()
AppLogger.i(TAG, "Script update applied")
}
/**
* Copy bundled assets/www into wwwDir, overwriting any existing files.
* Called on first install and on APK version upgrade to ensure the UI is always current.
*/
fun syncWwwFromAssets() {
try {
wwwDir.mkdirs()
copyAssetDir("www", wwwDir)
AppLogger.i(TAG, "www synced from assets to ${wwwDir.absolutePath}")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to sync www from assets", e)
}
}
private fun copyAssetDir(
assetPath: String,
targetDir: File,
) {
val entries = context.assets.list(assetPath) ?: return
targetDir.mkdirs()
for (entry in entries) {
copyAssetEntry("$assetPath/$entry", File(targetDir, entry))
}
}
private fun copyAssetEntry(
assetPath: String,
targetFile: File,
) {
val children = context.assets.list(assetPath)
if (!children.isNullOrEmpty()) {
copyAssetDir(assetPath, targetFile)
} else {
context.assets.open(assetPath).use { input ->
targetFile.outputStream().use { output -> input.copyTo(output) }
}
}
}
fun installOaCli() {
val oaBin = File(prefixDir, "bin/oa")
val oaUrl = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/oa.sh"
try {
java.net.URL(oaUrl).openStream().use { input ->
oaBin.outputStream().use { output -> input.copyTo(output) }
}
oaBin.setExecutable(true)
AppLogger.i(TAG, "oa CLI installed at ${oaBin.absolutePath}")
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to install oa CLI", e)
}
}
}
private object Os {
@JvmStatic
fun symlink(
target: String,
path: String,
) {
android.system.Os.symlink(target, path)
}
}
@@ -0,0 +1,78 @@
package com.openclaw.android
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Shell command execution via ProcessBuilder (§2.2.5).
* Uses Termux bootstrap environment for all commands.
*/
object CommandRunner {
data class CommandResult(
val exitCode: Int,
val stdout: String,
val stderr: String,
)
/**
* Run a command synchronously with timeout.
* Returns stdout/stderr and exit code.
*/
fun runSync(
command: String,
env: Map<String, String>,
workDir: File,
timeoutMs: Long = 5_000,
): CommandResult =
try {
val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh"
val pb = ProcessBuilder(shell, "-c", command)
pb.environment().clear()
pb.environment().putAll(env)
pb.directory(workDir)
pb.redirectErrorStream(false)
val process = pb.start()
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
val exited = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
if (!exited) {
process.destroyForcibly()
CommandResult(-1, stdout, "Command timed out after ${timeoutMs}ms")
} else {
CommandResult(process.exitValue(), stdout, stderr)
}
} catch (e: Exception) {
CommandResult(-1, "", e.message ?: "Unknown error")
}
/**
* Run a command asynchronously, streaming output line-by-line via callback.
*/
suspend fun runStreaming(
command: String,
env: Map<String, String>,
workDir: File,
onOutput: (String) -> Unit,
) = withContext(Dispatchers.IO) {
try {
val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh"
val pb = ProcessBuilder(shell, "-c", command)
pb.environment().clear()
pb.environment().putAll(env)
pb.directory(workDir)
pb.redirectErrorStream(true)
val process = pb.start()
process.inputStream.bufferedReader().forEachLine { line ->
onOutput(line)
}
process.waitFor()
} catch (e: Exception) {
onOutput("Error: ${e.message}")
}
}
}
@@ -0,0 +1,83 @@
package com.openclaw.android
import android.content.Context
import java.io.File
/**
* Builds the complete process environment for Termux bootstrap (§2.2.5).
* Based on AnyClaw CodexServerManager.kt pattern.
*/
object EnvironmentBuilder {
fun build(context: Context): Map<String, String> = build(context.filesDir)
fun build(filesDir: File): Map<String, String> {
val prefix = File(filesDir, "usr")
val home = File(filesDir, "home")
val tmp = File(filesDir, "tmp")
return buildMap {
// Core paths
put("PREFIX", prefix.absolutePath)
put("HOME", home.absolutePath)
put("TMPDIR", tmp.absolutePath)
val nodeBin = "${home.absolutePath}/.openclaw-android/node/bin"
val localBin = "${home.absolutePath}/.local/bin"
val prefixBin = "${prefix.absolutePath}/bin"
put("PATH", "$nodeBin:$localBin:$prefixBin:$prefixBin/applets")
put("LD_LIBRARY_PATH", "${prefix.absolutePath}/lib")
// libtermux-exec path conversion (§2.2.4)
// The bootstrap binaries have hardcoded /data/data/com.termux/... paths.
// libtermux-exec intercepts file operations and rewrites them.
// Only set LD_PRELOAD if the library actually exists — otherwise
// the dynamic linker refuses to start any process.
val termuxExecLib = File(prefix, "lib/libtermux-exec.so")
if (termuxExecLib.exists()) {
put("LD_PRELOAD", termuxExecLib.absolutePath)
}
put("TERMUX__PREFIX", prefix.absolutePath)
put("TERMUX_PREFIX", prefix.absolutePath)
put("TERMUX__ROOTFS", filesDir.absolutePath)
// Tell libtermux-exec where the current app data dir is
val appDataDir = filesDir.parentFile?.absolutePath ?: filesDir.absolutePath
put("TERMUX_APP__DATA_DIR", appDataDir)
// Tell libtermux-exec the OLD path to match against and rewrite
put("TERMUX_APP__LEGACY_DATA_DIR", "/data/data/com.termux")
// put("TERMUX_EXEC__LOG_LEVEL", "2") // Uncomment to debug libtermux-exec
// apt/dpkg (§2.2.3)
put("APT_CONFIG", "${prefix.absolutePath}/etc/apt/apt.conf")
put("DPKG_ADMINDIR", "${prefix.absolutePath}/var/lib/dpkg")
put("DPKG_ROOT", prefix.absolutePath)
// SSL (libgnutls.so hardcoded path workaround)
put("SSL_CERT_FILE", "${prefix.absolutePath}/etc/tls/cert.pem")
put("CURL_CA_BUNDLE", "${prefix.absolutePath}/etc/tls/cert.pem")
put("GIT_SSL_CAINFO", "${prefix.absolutePath}/etc/tls/cert.pem")
// Git (system gitconfig has hardcoded com.termux path)
put("GIT_CONFIG_NOSYSTEM", "1")
// Git exec path (git looks for helpers like git-remote-https here)
put("GIT_EXEC_PATH", "${prefix.absolutePath}/libexec/git-core")
// Git template dir (hardcoded /data/data/com.termux path workaround)
put("GIT_TEMPLATE_DIR", "${prefix.absolutePath}/share/git-core/templates")
// Locale and terminal
put("LANG", "en_US.UTF-8")
put("TERM", "xterm-256color")
// Android-specific
put("ANDROID_DATA", "/data")
put("ANDROID_ROOT", "/system")
// OpenClaw platform
put("OA_GLIBC", "1")
put("CONTAINER", "1")
put("CLAWDHUB_WORKDIR", "${home.absolutePath}/.openclaw/workspace")
put("CPATH", "${prefix.absolutePath}/include/glib-2.0:${prefix.absolutePath}/lib/glib-2.0/include")
}
}
}
@@ -0,0 +1,34 @@
package com.openclaw.android
import android.webkit.WebView
import com.google.gson.Gson
/**
* Kotlin → WebView event dispatch (§2.8).
* Uses evaluateJavascript + CustomEvent pattern.
*
* WebView side (index.html) must include:
* window.__oc = {
* emit(type, data) {
* window.dispatchEvent(new CustomEvent(`native:${type}`, { detail: data }));
* }
* };
*/
class EventBridge(
private val webView: WebView,
) {
private val gson = Gson()
/**
* Emit a named event to the WebView.
* React side listens via: useNativeEvent('type', handler)
*/
fun emit(
type: String,
data: Any?,
) {
val json = gson.toJson(data ?: emptyMap<String, Any>())
val script = "window.__oc&&window.__oc.emit('$type',$json)"
webView.post { webView.evaluateJavascript(script, null) }
}
}
@@ -0,0 +1,739 @@
package com.openclaw.android
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
import android.webkit.JavascriptInterface
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* WebView → Kotlin bridge via @JavascriptInterface (§2.6).
* All methods callable from JavaScript as window.OpenClaw.<method>().
* All return values are JSON strings. Async operations use EventBridge (§2.8).
*/
@Suppress("TooManyFunctions", "LargeClass") // WebView bridge — single facade by design
class JsBridge(
private val activity: MainActivity,
private val sessionManager: TerminalSessionManager,
private val bootstrapManager: BootstrapManager,
private val eventBridge: EventBridge,
) {
private val gson = Gson()
companion object {
private const val TAG = "JsBridge"
private const val SHELL_INIT_DELAY_MS = 500L
private const val COMMAND_TIMEOUT_MS = 5_000L
private const val PLATFORM_LIST_TIMEOUT_MS = 10_000L
private const val API_TIMEOUT_MS = 5000
private const val PROGRESS_START = 0f
private const val PROGRESS_HALF = 0.5f
private const val PROGRESS_DONE = 1f
private const val PROGRESS_DOWNLOAD = 0.2f
private const val PROGRESS_EXTRACT = 0.6f
private const val PROGRESS_APPLY = 0.9f
private const val PROGRESS_BOOTSTRAP_START = 0.1f
}
/**
* Launch a coroutine on Dispatchers.IO with error handling.
* Catches all exceptions to prevent app crashes from unhandled coroutine failures.
* Errors are logged and emitted to the WebView via EventBridge.
*/
private fun launchWithErrorHandling(
errorEventType: String = "error",
errorContext: Map<String, Any?> = emptyMap(),
block: suspend CoroutineScope.() -> Unit,
) {
val handler =
CoroutineExceptionHandler { _, throwable ->
AppLogger.e(TAG, "Coroutine error [$errorEventType]: ${throwable.message}", throwable)
eventBridge.emit(
errorEventType,
errorContext +
mapOf(
"error" to (throwable.message ?: "Unknown error"),
"progress" to PROGRESS_START,
"message" to "Error: ${throwable.message}",
),
)
}
CoroutineScope(Dispatchers.IO + handler).launch(block = block)
}
// ═══════════════════════════════════════════
// Terminal domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun showTerminal() {
// Create session if none exists (e.g., after first-time setup)
if (sessionManager.activeSession == null) {
val session = sessionManager.createSession()
if (bootstrapManager.needsPostSetup()) {
val script = bootstrapManager.postSetupScript.absolutePath
// Delay write until after attachSession() initializes the shell process.
// createSession() posts attachSession() via runOnUiThread; writing before
// that runs silently drops the data (mShellPid is still 0).
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
session.write("bash $script\n")
}, SHELL_INIT_DELAY_MS)
}
}
activity.showTerminal()
}
@JavascriptInterface
fun showWebView() = activity.showWebView()
@JavascriptInterface
fun createSession(): String {
val session = sessionManager.createSession()
return gson.toJson(mapOf("id" to session.mHandle, "name" to (session.title ?: "Terminal")))
}
@JavascriptInterface
fun switchSession(id: String) =
activity.runOnUiThread {
sessionManager.switchSession(id)
}
@JavascriptInterface
fun closeSession(id: String) {
sessionManager.closeSession(id)
}
@JavascriptInterface
fun getTerminalSessions(): String = gson.toJson(sessionManager.getSessionsInfo())
@JavascriptInterface
fun writeToTerminal(
id: String,
data: String,
) {
val session =
if (id.isBlank()) {
sessionManager.activeSession
} else {
sessionManager.getSessionById(id) ?: sessionManager.activeSession
}
session?.write(data)
}
@JavascriptInterface
fun runInNewSession(command: String) {
val session = sessionManager.createSession()
activity.showTerminal()
// Delay write until shell process initializes (same pattern as showTerminal post-setup)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
session.write(command)
}, SHELL_INIT_DELAY_MS)
}
// ═══════════════════════════════════════════
// Setup domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getSetupStatus(): String = gson.toJson(bootstrapManager.getStatus())
@JavascriptInterface
fun getBootstrapStatus(): String =
gson.toJson(
mapOf(
"installed" to bootstrapManager.isInstalled(),
"prefixPath" to bootstrapManager.prefixDir.absolutePath,
),
)
@JavascriptInterface
fun startSetup() {
launchWithErrorHandling(
errorEventType = "setup_progress",
errorContext = mapOf("progress" to PROGRESS_START),
) {
bootstrapManager.startSetup { progress, message ->
eventBridge.emit(
"setup_progress",
mapOf("progress" to progress, "message" to message),
)
}
}
}
@JavascriptInterface
fun saveToolSelections(json: String) {
val configFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/tool-selections.conf")
configFile.parentFile?.mkdirs()
val selections = gson.fromJson(json, Map::class.java) as? Map<*, *> ?: return
val lines =
selections.entries.joinToString("\n") { (key, value) ->
val envKey = "INSTALL_${(key as String).uppercase().replace("-", "_")}"
"$envKey=$value"
}
configFile.writeText(lines + "\n")
}
// ═══════════════════════════════════════════
// Platform domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getAvailablePlatforms(): String {
// Read from cached config.json or return defaults
return gson.toJson(
listOf(
mapOf(
"id" to "openclaw",
"name" to "OpenClaw",
"icon" to "/openclaw.svg",
"desc" to "AI agent platform",
),
),
)
}
@JavascriptInterface
fun getInstalledPlatforms(): String {
// Check which platforms are installed via npm/filesystem
val env = EnvironmentBuilder.build(activity)
val result =
CommandRunner.runSync(
"npm list -g --depth=0 --json 2>/dev/null",
env,
bootstrapManager.prefixDir,
timeoutMs = PLATFORM_LIST_TIMEOUT_MS,
)
return result.stdout.ifBlank { "[]" }
}
@JavascriptInterface
fun installPlatform(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."),
)
val env = EnvironmentBuilder.build(activity)
CommandRunner.runStreaming(
"npm install -g $id@latest --ignore-scripts",
env,
bootstrapManager.homeDir,
) { output ->
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output),
)
}
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_DONE, "message" to "$id installed"),
)
}
}
@JavascriptInterface
fun uninstallPlatform(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
CommandRunner.runSync("npm uninstall -g $id", env, bootstrapManager.homeDir)
}
}
@JavascriptInterface
fun switchPlatform(id: String) {
// Write active platform marker
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
markerFile.parentFile?.mkdirs()
markerFile.writeText(id)
}
@JavascriptInterface
fun getActivePlatform(): String {
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
val id = if (markerFile.exists()) markerFile.readText().trim() else "openclaw"
return gson.toJson(mapOf("id" to id, "name" to id.replaceFirstChar { it.uppercase() }))
}
// ═══════════════════════════════════════════
// Tools domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getInstalledTools(): String {
val env = EnvironmentBuilder.build(activity)
val prefix = bootstrapManager.prefixDir.absolutePath
val tools = mutableListOf<Map<String, String>>()
// Termux packages - check binary path
val pkgChecks =
mapOf(
"tmux" to "$prefix/bin/tmux",
"ttyd" to "$prefix/bin/ttyd",
"dufs" to "$prefix/bin/dufs",
"openssh-server" to "$prefix/bin/sshd",
"android-tools" to "$prefix/bin/adb",
"code-server" to "$prefix/bin/code-server",
)
for ((id, path) in pkgChecks) {
if (java.io.File(path).exists()) {
tools.add(mapOf("id" to id, "name" to id, "version" to "installed"))
}
}
// Chromium - check multiple possible paths
if (java.io.File("$prefix/bin/chromium-browser").exists() || java.io.File("$prefix/bin/chromium").exists()) {
tools.add(mapOf("id" to "chromium", "name" to "chromium", "version" to "installed"))
}
// npm global packages - check binary file in node bin
val nodeBin = "${bootstrapManager.homeDir.absolutePath}/.openclaw-android/node/bin"
val npmBinChecks =
mapOf(
"claude-code" to "$nodeBin/claude",
"gemini-cli" to "$nodeBin/gemini",
"codex-cli" to "$nodeBin/codex",
"opencode" to "$nodeBin/opencode",
)
for ((id, path) in npmBinChecks) {
if (java.io.File(path).exists()) {
tools.add(mapOf("id" to id, "name" to id, "version" to "installed"))
}
}
return gson.toJson(tools)
}
@JavascriptInterface
fun installTool(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
val prefix = bootstrapManager.prefixDir.absolutePath
val aptGet =
"DEBIAN_FRONTEND=noninteractive $prefix/bin/apt-get" +
" -y -o Acquire::AllowInsecureRepositories=true" +
" -o APT::Get::AllowUnauthenticated=true"
val cmd =
when (id) {
// Termux packages (apt-get)
"tmux", "ttyd", "dufs", "openssh-server", "android-tools" ->
"$aptGet install ${if (id == "openssh-server") "openssh" else id}"
// Chromium (from x11-repo)
"chromium" ->
"$aptGet install chromium"
// code-server (custom)
"code-server" ->
"npm install -g code-server"
// npm-based AI CLI tools
"claude-code" ->
"npm install -g @anthropic-ai/claude-code"
"gemini-cli" ->
"npm install -g @google/gemini-cli"
"codex-cli" ->
"npm install -g @mmmbuto/codex-cli-termux"
// OpenCode (Bun-based) — requires proot + ld.so concatenation
"opencode" ->
"curl -fsSL https://raw.githubusercontent.com/" +
"AidanPark/openclaw-android/main/scripts/install-opencode.sh | bash"
else -> "echo 'Unknown tool: $id'"
}
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."),
)
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output),
)
}
eventBridge.emit(
"install_progress",
mapOf("target" to id, "progress" to PROGRESS_DONE, "message" to "$id installed"),
)
}
}
@JavascriptInterface
fun uninstallTool(id: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
val cmd =
when (id) {
"tmux", "ttyd", "dufs", "openssh-server", "android-tools", "chromium" -> {
val pkg = if (id == "openssh-server") "openssh" else id
"${bootstrapManager.prefixDir.absolutePath}/bin/apt-get remove -y $pkg"
}
"code-server" ->
"npm uninstall -g code-server"
"claude-code" ->
"npm uninstall -g @anthropic-ai/claude-code"
"gemini-cli" ->
"npm uninstall -g @google/gemini-cli"
"codex-cli" ->
"npm uninstall -g @mmmbuto/codex-cli-termux"
"opencode" ->
"rm -f \$PREFIX/bin/opencode" +
" \$HOME/.openclaw-android/bin/ld.so.opencode" +
" \$PREFIX/tmp/ld.so.opencode" +
" && rm -rf \$HOME/.config/opencode"
else -> "echo 'Unknown tool: $id'"
}
CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
}
}
@JavascriptInterface
fun isToolInstalled(id: String): String {
val prefix = bootstrapManager.prefixDir.absolutePath
val env = EnvironmentBuilder.build(activity)
val exists =
when (id) {
"openssh-server" -> java.io.File("$prefix/bin/sshd").exists()
"tmux", "ttyd", "dufs", "android-tools" -> {
val bin = if (id == "android-tools") "adb" else id
java.io.File("$prefix/bin/$bin").exists()
}
"chromium" -> {
java.io.File("$prefix/bin/chromium-browser").exists() ||
java.io.File("$prefix/bin/chromium").exists()
}
"code-server" -> java.io.File("$prefix/bin/code-server").exists()
else -> {
// npm global packages: check via command -v
val result =
CommandRunner.runSync(
"command -v $id 2>/dev/null",
env,
bootstrapManager.prefixDir,
timeoutMs = COMMAND_TIMEOUT_MS,
)
result.stdout.trim().isNotEmpty()
}
}
return gson.toJson(mapOf("installed" to exists))
}
// ═══════════════════════════════════════════
// Commands domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun runCommand(cmd: String): String {
val env = EnvironmentBuilder.build(activity)
val result = CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
return gson.toJson(result)
}
@JavascriptInterface
fun runCommandAsync(
callbackId: String,
cmd: String,
) {
launchWithErrorHandling(
errorEventType = "command_output",
errorContext = mapOf("callbackId" to callbackId, "done" to true),
) {
val env = EnvironmentBuilder.build(activity)
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
eventBridge.emit(
"command_output",
mapOf("callbackId" to callbackId, "data" to output, "done" to false),
)
}
eventBridge.emit(
"command_output",
mapOf("callbackId" to callbackId, "data" to "", "done" to true),
)
}
}
// ═══════════════════════════════════════════
// Updates domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun checkForUpdates(): String {
// Compare local versions with config.json remote versions
val updates = mutableListOf<Map<String, String>>()
try {
val configFile =
java.io.File(
activity.filesDir,
"usr/share/openclaw-app/config.json",
)
if (configFile.exists()) {
val config = gson.fromJson(configFile.readText(), Map::class.java) as? Map<*, *>
val localWwwVersion =
activity
.getSharedPreferences("openclaw", 0)
.getString("www_version", "0.0.0")
val remoteWwwVersion = ((config?.get("www") as? Map<*, *>)?.get("version") as? String)
if (remoteWwwVersion != null && remoteWwwVersion != localWwwVersion) {
updates.add(
mapOf(
"component" to "www",
"currentVersion" to (localWwwVersion ?: "0.0.0"),
"newVersion" to remoteWwwVersion,
),
)
}
}
} catch (_: Exception) {
// ignore parse errors
}
return gson.toJson(updates)
}
@JavascriptInterface
fun getApkUpdateInfo(): String {
return try {
val url = java.net.URL("https://api.github.com/repos/AidanPark/openclaw-android/releases/latest")
val conn = url.openConnection() as java.net.HttpURLConnection
conn.connectTimeout = API_TIMEOUT_MS
conn.readTimeout = API_TIMEOUT_MS
conn.setRequestProperty("Accept", "application/vnd.github+json")
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val release = gson.fromJson(body, Map::class.java) as? Map<*, *>
val tagName =
release?.get("tagName") as? String
?: release?.get("tag_name") as? String
?: return gson.toJson(mapOf("error" to "no tag"))
val latestVersion = tagName.trimStart('v')
val currentVersion =
activity.packageManager
.getPackageInfo(activity.packageName, 0)
.versionName ?: "0.0.0"
gson.toJson(
mapOf(
"currentVersion" to currentVersion,
"latestVersion" to latestVersion,
"updateAvailable" to (compareVersions(latestVersion, currentVersion) > 0),
),
)
} catch (e: Exception) {
gson.toJson(mapOf("error" to e.message))
}
}
@JavascriptInterface
fun applyUpdate(component: String) {
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to component),
) {
emitProgress(component, PROGRESS_START, "Updating $component...")
when (component) {
"www" -> updateWww()
"bootstrap" -> updateBootstrap()
"scripts" -> emitProgress("scripts", PROGRESS_HALF, "Scripts are updated with bootstrap")
}
emitProgress(component, PROGRESS_DONE, "$component updated")
}
}
private fun emitProgress(
target: String,
progress: Float,
message: String,
) {
eventBridge.emit(
"install_progress",
mapOf(
"target" to target,
"progress" to progress,
"message" to message,
),
)
}
private suspend fun updateWww() {
try {
val url = UrlResolver(activity).getWwwUrl()
val stagingWww = java.io.File(activity.cacheDir, "www-staging")
stagingWww.deleteRecursively()
stagingWww.mkdirs()
emitProgress("www", PROGRESS_DOWNLOAD, "Downloading...")
val zipFile = java.io.File(activity.cacheDir, "www.zip")
java.net.URL(url).openStream().use { input ->
zipFile.outputStream().use { output -> input.copyTo(output) }
}
emitProgress("www", PROGRESS_EXTRACT, "Extracting...")
extractZipToDir(zipFile, stagingWww)
zipFile.delete()
emitProgress("www", PROGRESS_APPLY, "Applying...")
val wwwDir = bootstrapManager.wwwDir
wwwDir.deleteRecursively()
wwwDir.parentFile?.mkdirs()
stagingWww.renameTo(wwwDir)
activity.runOnUiThread { activity.reloadWebView() }
} catch (e: Exception) {
emitProgress("www", PROGRESS_START, "Update failed: ${e.message}")
}
}
private suspend fun updateBootstrap() {
try {
emitProgress("bootstrap", PROGRESS_BOOTSTRAP_START, "Downloading bootstrap...")
bootstrapManager.startSetup { progress, message ->
emitProgress("bootstrap", progress, message)
}
} catch (e: Exception) {
emitProgress("bootstrap", PROGRESS_START, "Update failed: ${e.message}")
}
}
private fun extractZipToDir(
zipFile: java.io.File,
targetDir: java.io.File,
) {
java.util.zip.ZipInputStream(zipFile.inputStream()).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
extractZipEntry(zis, entry, targetDir)
entry = zis.nextEntry
}
}
}
private fun extractZipEntry(
zis: java.util.zip.ZipInputStream,
entry: java.util.zip.ZipEntry,
targetDir: java.io.File,
) {
val destFile = java.io.File(targetDir, entry.name)
if (entry.isDirectory) {
destFile.mkdirs()
} else {
destFile.parentFile?.mkdirs()
destFile.outputStream().use { out -> zis.copyTo(out) }
}
}
// ═══════════════════════════════════════════
// System domain
// ═══════════════════════════════════════════
@JavascriptInterface
fun getAppInfo(): String {
val pInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
return gson.toJson(
mapOf(
"versionName" to (pInfo.versionName ?: "unknown"),
"versionCode" to pInfo.versionCode,
"packageName" to activity.packageName,
),
)
}
@JavascriptInterface
fun getBatteryOptimizationStatus(): String {
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
return gson.toJson(
mapOf("isIgnoring" to pm.isIgnoringBatteryOptimizations(activity.packageName)),
)
}
@JavascriptInterface
fun requestBatteryOptimizationExclusion() {
activity.runOnUiThread {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivity(intent)
}
}
@JavascriptInterface
fun openSystemSettings(page: String) {
activity.runOnUiThread {
val intent =
when (page) {
"battery" -> Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
"app_info" ->
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${activity.packageName}")
}
else -> Intent(Settings.ACTION_SETTINGS)
}
activity.startActivity(intent)
}
}
@JavascriptInterface
fun copyToClipboard(text: String) {
activity.runOnUiThread {
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text))
}
}
@JavascriptInterface
fun getStorageInfo(): String {
val filesDir = activity.filesDir
val totalSpace = filesDir.totalSpace
val freeSpace = filesDir.freeSpace
val bootstrapSize = bootstrapManager.prefixDir.walkTopDown().sumOf { it.length() }
val wwwSize = bootstrapManager.wwwDir.walkTopDown().sumOf { it.length() }
return gson.toJson(
mapOf(
"totalBytes" to totalSpace,
"freeBytes" to freeSpace,
"bootstrapBytes" to bootstrapSize,
"wwwBytes" to wwwSize,
),
)
}
@JavascriptInterface
fun clearCache() {
activity.cacheDir.deleteRecursively()
activity.cacheDir.mkdirs()
}
@JavascriptInterface
fun openUrl(url: String) {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
}
/** Returns positive if a > b, negative if a < b, 0 if equal (semver: major.minor.patch) */
private fun compareVersions(
a: String,
b: String,
): Int {
val aParts = a.split(".").map { it.toIntOrNull() ?: 0 }
val bParts = b.split(".").map { it.toIntOrNull() ?: 0 }
val len = maxOf(aParts.size, bParts.size)
for (i in 0 until len) {
val diff = (aParts.getOrElse(i) { 0 }) - (bParts.getOrElse(i) { 0 })
if (diff != 0) return diff
}
return 0
}
}
@@ -0,0 +1,719 @@
package com.openclaw.android
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Typeface
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.openclaw.android.databinding.ActivityMainBinding
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalViewClient
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
private const val DEFAULT_TEXT_SIZE = 32
private const val MIN_TEXT_SIZE = 8
private const val MAX_TEXT_SIZE = 32
private const val KEYBOARD_SHOW_DELAY_MS = 200L
private const val TAB_NAME_TEXT_SIZE = 12f
private const val TAB_CLOSE_TEXT_SIZE = 14f
private const val TAB_ADD_TEXT_SIZE = 18f
private const val TAB_MARGIN_DP = 2
private const val TAB_HPAD_DP = 10
private const val TAB_VPAD_DP = 4
private const val TAB_CLOSE_PAD_DP = 6
private const val TAB_ADD_PAD_DP = 12
private const val INDICATOR_HEIGHT_DP = 2
private const val INPUT_MODE_TYPE_NULL = 1
}
private lateinit var binding: ActivityMainBinding
lateinit var sessionManager: TerminalSessionManager
lateinit var bootstrapManager: BootstrapManager
lateinit var eventBridge: EventBridge
private lateinit var jsBridge: JsBridge
private var currentTextSize = DEFAULT_TEXT_SIZE
private var ctrlDown = false
private var altDown = false
private val terminalSessionClient = OpenClawSessionClient()
private val terminalViewClient = OpenClawViewClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bootstrapManager = BootstrapManager(this)
eventBridge = EventBridge(binding.webView)
sessionManager = TerminalSessionManager(this, terminalSessionClient, eventBridge)
jsBridge = JsBridge(this, sessionManager, bootstrapManager, eventBridge)
setupTerminalView()
setupWebView()
setupExtraKeys()
sessionManager.onSessionsChanged = { updateSessionTabs() }
startService(Intent(this, OpenClawService::class.java))
val isInstalled = bootstrapManager.isInstalled()
AppLogger.i(TAG, "Bootstrap installed: $isInstalled, needsPostSetup: ${bootstrapManager.needsPostSetup()}")
// Sync www assets and check for APK version upgrade
if (isInstalled) {
val prefs = getSharedPreferences("openclaw", 0)
val savedVersionCode = prefs.getInt("versionCode", 0)
val currentVersionCode = packageManager.getPackageInfo(packageName, 0).versionCode
// Always sync www from assets to pick up UI updates
bootstrapManager.syncWwwFromAssets()
// Ensure oa CLI is installed (network, run in background)
Thread { bootstrapManager.installOaCli() }.start()
if (currentVersionCode > savedVersionCode) {
AppLogger.i(TAG, "APK version upgrade detected: $savedVersionCode -> $currentVersionCode")
bootstrapManager.applyScriptUpdate()
prefs.edit().putInt("versionCode", currentVersionCode).apply()
}
}
if (isInstalled) {
showTerminal()
val session = sessionManager.createSession()
if (bootstrapManager.needsPostSetup()) {
AppLogger.i(TAG, "Running post-setup script in terminal")
val script = bootstrapManager.postSetupScript.absolutePath
binding.terminalView.post {
session.write("bash $script\n")
}
} else if (intent?.getBooleanExtra("from_boot", false) == true) {
val platformFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
val platformId = if (platformFile.exists()) platformFile.readText().trim() else "openclaw"
AppLogger.i(TAG, "Boot launch \u2014 auto-starting $platformId gateway")
binding.terminalView.post {
session.write("$platformId gateway\n")
}
}
}
// else: WebView shows setup UI, user triggers startSetup via JsBridge
}
// --- Terminal setup ---
private fun setupTerminalView() {
binding.terminalView.setTerminalViewClient(terminalViewClient)
binding.terminalView.setTextSize(currentTextSize)
}
// --- WebView setup ---
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
binding.webView.apply {
clearCache(true)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowFileAccess = true
@Suppress("DEPRECATION")
settings.allowFileAccessFromFileURLs = true
@Suppress("DEPRECATION")
settings.allowUniversalAccessFromFileURLs = true
settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE
addJavascriptInterface(jsBridge, "OpenClaw")
webViewClient =
object : WebViewClient() {
override fun onPageFinished(
view: WebView?,
url: String?,
) {
super.onPageFinished(view, url)
AppLogger.i(TAG, "WebView page loaded: $url")
// Page loaded successfully
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let {
AppLogger.d("WebViewJS", "${it.sourceId()}:${it.lineNumber()} ${it.message()}")
}
return true
}
}
}
val wwwDir = bootstrapManager.wwwDir
val url =
if (wwwDir.resolve("index.html").exists()) {
"file://${wwwDir.absolutePath}/index.html"
} else {
// Load bundled fallback setup page from assets
"file:///android_asset/www/index.html"
}
AppLogger.i(TAG, "Loading WebView URL: $url")
binding.webView.loadUrl(url)
}
fun reloadWebView() {
binding.webView.reload()
}
// --- View switching ---
fun showTerminal() {
runOnUiThread {
binding.webView.visibility = View.GONE
binding.terminalContainer.visibility = View.VISIBLE
binding.terminalView.requestFocus()
updateSessionTabs()
// Delay keyboard show — view must be focused and laid out first
binding.terminalView.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(binding.terminalView, InputMethodManager.SHOW_IMPLICIT)
}, KEYBOARD_SHOW_DELAY_MS)
}
}
fun showWebView() {
runOnUiThread {
binding.terminalContainer.visibility = View.GONE
binding.webView.visibility = View.VISIBLE
}
}
@Suppress("DEPRECATION")
override fun onBackPressed() {
if (binding.terminalContainer.visibility == View.VISIBLE) {
showWebView()
} else if (binding.webView.canGoBack()) {
binding.webView.goBack()
} else {
super.onBackPressed()
}
}
// --- Extra Keys ---
private val pressedAlpha = 0.5f
private val normalAlpha = 1.0f
@SuppressLint("ClickableViewAccessibility")
private fun setupExtraKeys() {
// Key code buttons — send key event on touch, never steal focus
val keyMap =
mapOf(
R.id.btnEsc to KeyEvent.KEYCODE_ESCAPE,
R.id.btnTab to KeyEvent.KEYCODE_TAB,
R.id.btnHome to KeyEvent.KEYCODE_MOVE_HOME,
R.id.btnEnd to KeyEvent.KEYCODE_MOVE_END,
R.id.btnUp to KeyEvent.KEYCODE_DPAD_UP,
R.id.btnDown to KeyEvent.KEYCODE_DPAD_DOWN,
R.id.btnLeft to KeyEvent.KEYCODE_DPAD_LEFT,
R.id.btnRight to KeyEvent.KEYCODE_DPAD_RIGHT,
)
for ((btnId, keyCode) in keyMap) {
setupExtraKeyTouch(findViewById(btnId)) { sendExtraKey(keyCode) }
}
// Character keys
setupExtraKeyTouch(findViewById(R.id.btnDash)) { sessionManager.activeSession?.write("-") }
setupExtraKeyTouch(findViewById(R.id.btnPipe)) { sessionManager.activeSession?.write("|") }
setupExtraKeyTouch(findViewById(R.id.btnPaste)) {
val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
val text =
clipboard.primaryClip
?.getItemAt(0)
?.coerceToText(this)
?.toString()
if (!text.isNullOrEmpty()) sessionManager.activeSession?.write(text)
}
// Modifier toggles — stay pressed until next key or toggled off
setupModifierTouch(findViewById(R.id.btnCtrl)) {
ctrlDown = !ctrlDown
ctrlDown
}
setupModifierTouch(findViewById(R.id.btnAlt)) {
altDown = !altDown
altDown
}
}
@SuppressLint("ClickableViewAccessibility")
private fun setupExtraKeyTouch(
btn: Button,
action: () -> Unit,
) {
btn.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
v.alpha = normalAlpha
if (event.action == MotionEvent.ACTION_UP) action()
}
}
true // consume — never let focus leave TerminalView
}
}
@SuppressLint("ClickableViewAccessibility")
private fun setupModifierTouch(
btn: Button,
toggle: () -> Boolean,
) {
btn.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_UP -> {
val active = toggle()
updateModifierButton(v as Button, active)
v.alpha = normalAlpha
}
MotionEvent.ACTION_CANCEL -> v.alpha = normalAlpha
}
true
}
}
private fun sendExtraKey(keyCode: Int) {
var metaState = 0
if (ctrlDown) metaState = metaState or (KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
if (altDown) metaState = metaState or (KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON)
val ev = KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState)
binding.terminalView.onKeyDown(keyCode, ev)
// Auto-deactivate modifiers after use
if (ctrlDown) {
ctrlDown = false
updateModifierButton(findViewById(R.id.btnCtrl), false)
}
if (altDown) {
altDown = false
updateModifierButton(findViewById(R.id.btnAlt), false)
}
}
private fun updateModifierButton(
button: Button,
active: Boolean,
) {
val bgColor = if (active) R.color.extraKeyActive else R.color.extraKeyDefault
val txtColor = if (active) R.color.extraKeyActiveText else R.color.extraKeyText
button.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, bgColor))
button.setTextColor(ContextCompat.getColor(this, txtColor))
}
// --- Session tab bar ---
private fun updateSessionTabs() {
val tabsLayout = binding.tabsLayout
tabsLayout.removeAllViews()
val sessions = sessionManager.getSessionsInfo()
val density = resources.displayMetrics.density
for (info in sessions) {
val tabWrapper = createSessionTab(info, density)
tabsLayout.addView(tabWrapper)
if (info["active"] as Boolean) {
binding.sessionTabBar.post {
binding.sessionTabBar.smoothScrollTo(tabWrapper.left, 0)
}
}
}
tabsLayout.addView(createAddButton(density))
}
private fun createSessionTab(
info: Map<String, Any>,
density: Float,
): LinearLayout {
val id = info["id"] as String
val name = info["name"] as String
val active = info["active"] as Boolean
val finished = info["finished"] as Boolean
val tabWrapper =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams =
LinearLayout
.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT,
).apply {
marginEnd = (TAB_MARGIN_DP * density).toInt()
}
val bgColor =
if (active) {
R.color.tabActiveBackground
} else {
R.color.tabInactiveBackground
}
setBackgroundColor(ContextCompat.getColor(this@MainActivity, bgColor))
isFocusable = false
isFocusableInTouchMode = false
}
val tabContent = createTabContent(name, active, finished, id, density)
val indicator = createTabIndicator(active, density)
tabWrapper.addView(tabContent)
tabWrapper.addView(indicator)
tabWrapper.setOnClickListener {
sessionManager.switchSession(id)
binding.terminalView.requestFocus()
}
return tabWrapper
}
private fun createTabContent(
name: String,
active: Boolean,
finished: Boolean,
id: String,
density: Float,
): LinearLayout {
val tabContent =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val hPad = (TAB_HPAD_DP * density).toInt()
val vPad = (TAB_VPAD_DP * density).toInt()
setPadding(hPad, vPad, (TAB_CLOSE_PAD_DP * density).toInt(), vPad)
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
0,
1f,
)
isFocusable = false
isFocusableInTouchMode = false
}
val nameView =
TextView(this).apply {
text = name
textSize = TAB_NAME_TEXT_SIZE
val textColor =
when {
finished -> R.color.tabTextFinished
active -> R.color.tabTextPrimary
else -> R.color.tabTextSecondary
}
setTextColor(ContextCompat.getColor(this@MainActivity, textColor))
if (finished) setTypeface(typeface, Typeface.ITALIC)
isSingleLine = true
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
val closeView =
TextView(this).apply {
text = "\u00D7"
textSize = TAB_CLOSE_TEXT_SIZE
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary))
setPadding((TAB_CLOSE_PAD_DP * density).toInt(), 0, 0, 0)
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
isFocusable = false
isFocusableInTouchMode = false
setOnClickListener { closeSessionFromTab(id) }
}
tabContent.addView(nameView)
tabContent.addView(closeView)
return tabContent
}
private fun createTabIndicator(
active: Boolean,
density: Float,
): View =
View(this).apply {
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(INDICATOR_HEIGHT_DP * density).toInt(),
)
val color = if (active) R.color.tabAccent else android.R.color.transparent
setBackgroundColor(ContextCompat.getColor(this@MainActivity, color))
}
private fun createAddButton(density: Float): TextView =
TextView(this).apply {
text = "+"
textSize = TAB_ADD_TEXT_SIZE
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary))
val pad = (TAB_ADD_PAD_DP * density).toInt()
setPadding(pad, 0, pad, 0)
gravity = Gravity.CENTER
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT,
)
isFocusable = false
isFocusableInTouchMode = false
setOnClickListener {
sessionManager.createSession()
binding.terminalView.requestFocus()
}
}
private fun closeSessionFromTab(handleId: String) {
if (sessionManager.sessionCount <= 1) {
// Create new session first, then close the old one
sessionManager.createSession()
}
sessionManager.closeSession(handleId)
binding.terminalView.requestFocus()
}
// --- Terminal session callbacks ---
private inner class OpenClawSessionClient : TerminalSessionClient {
override fun onTextChanged(changedSession: TerminalSession) {
binding.terminalView.onScreenUpdated()
}
override fun onTitleChanged(changedSession: TerminalSession) {
// Update tab bar when title changes
runOnUiThread { updateSessionTabs() }
// title changes propagated via EventBridge
}
override fun onSessionFinished(finishedSession: TerminalSession) {
sessionManager.onSessionFinished(finishedSession)
}
override fun onCopyTextToClipboard(
session: TerminalSession,
text: String,
) {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text))
}
override fun onPasteTextFromClipboard(session: TerminalSession?) {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = clipboard.primaryClip?.getItemAt(0)?.text ?: return
session?.write(text.toString())
}
override fun onBell(session: TerminalSession) = Unit
override fun onColorsChanged(session: TerminalSession) = Unit
override fun onTerminalCursorStateChange(state: Boolean) = Unit
override fun setTerminalShellPid(
session: TerminalSession,
pid: Int,
) = Unit
override fun getTerminalCursorStyle(): Int = 0
override fun logError(
tag: String,
message: String,
) {
AppLogger.e(tag, message)
}
override fun logWarn(
tag: String,
message: String,
) {
AppLogger.w(tag, message)
}
override fun logInfo(
tag: String,
message: String,
) {
AppLogger.i(tag, message)
}
override fun logDebug(
tag: String,
message: String,
) {
AppLogger.d(tag, message)
}
override fun logVerbose(
tag: String,
message: String,
) {
AppLogger.v(tag, message)
}
override fun logStackTraceWithMessage(
tag: String,
message: String,
e: Exception,
) {
AppLogger.e(tag, message, e)
}
override fun logStackTrace(
tag: String,
e: Exception,
) {
AppLogger.e(tag, "Exception", e)
}
}
// --- Terminal view callbacks ---
@Suppress("TooManyFunctions") // Interface implementation requires all methods
private inner class OpenClawViewClient : TerminalViewClient {
override fun onScale(scale: Float): Float {
val currentSize = currentTextSize
val newSize = if (scale > 1f) currentSize + 1 else currentSize - 1
val clamped = newSize.coerceIn(MIN_TEXT_SIZE, MAX_TEXT_SIZE)
currentTextSize = clamped
binding.terminalView.setTextSize(clamped)
return scale
}
override fun onSingleTapUp(e: MotionEvent) {
// Toggle soft keyboard on tap (same as Termux)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0)
}
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
override fun shouldEnforceCharBasedInput(): Boolean = true
override fun getInputMode(): Int = INPUT_MODE_TYPE_NULL
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
override fun isTerminalViewSelected(): Boolean = binding.terminalContainer.visibility == View.VISIBLE
override fun copyModeChanged(copyMode: Boolean) = Unit
override fun onKeyDown(
keyCode: Int,
e: KeyEvent,
session: TerminalSession,
): Boolean = false
override fun onKeyUp(
keyCode: Int,
e: KeyEvent,
): Boolean = false
override fun onLongPress(event: MotionEvent): Boolean = false
override fun readControlKey(): Boolean {
val v = ctrlDown
if (v) {
ctrlDown = false
runOnUiThread { updateModifierButton(findViewById(R.id.btnCtrl), false) }
}
return v
}
override fun readAltKey(): Boolean {
val v = altDown
if (v) {
altDown = false
runOnUiThread { updateModifierButton(findViewById(R.id.btnAlt), false) }
}
return v
}
override fun readShiftKey(): Boolean = false
override fun readFnKey(): Boolean = false
override fun onCodePoint(
codePoint: Int,
ctrlDown: Boolean,
session: TerminalSession,
): Boolean = false
override fun onEmulatorSet() = Unit
override fun logError(
tag: String,
message: String,
) {
AppLogger.e(tag, message)
}
override fun logWarn(
tag: String,
message: String,
) {
AppLogger.w(tag, message)
}
override fun logInfo(
tag: String,
message: String,
) {
AppLogger.i(tag, message)
}
override fun logDebug(
tag: String,
message: String,
) {
AppLogger.d(tag, message)
}
override fun logVerbose(
tag: String,
message: String,
) {
AppLogger.v(tag, message)
}
override fun logStackTraceWithMessage(
tag: String,
message: String,
e: Exception,
) {
AppLogger.e(tag, message, e)
}
override fun logStackTrace(
tag: String,
e: Exception,
) {
AppLogger.e(tag, "Exception", e)
}
}
}
@@ -0,0 +1,79 @@
package com.openclaw.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
/**
* Foreground Service that keeps terminal sessions alive when the app is in background.
* Uses START_STICKY to restart if killed. targetSdk 28 — no specialUse needed.
*/
class OpenClawService : Service() {
companion object {
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "openclaw_service"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIFICATION_ID, createNotification())
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Keeps OpenClaw terminal sessions running"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
val pendingIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val builder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,162 @@
package com.openclaw.android
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
/**
* Multi-terminal session management (§2.6, Phase 1 checklist).
* Uses TerminalView.attachSession() for session switching — one TerminalView, many sessions.
*/
class TerminalSessionManager(
private val activity: MainActivity,
private val sessionClient: TerminalSessionClient,
private val eventBridge: EventBridge,
) {
companion object {
private const val TAG = "SessionManager"
private const val TRANSCRIPT_ROWS = 2000
}
private val sessions = mutableListOf<TerminalSession>()
private var activeSessionIndex = -1
private val finishedSessionIds = mutableSetOf<String>()
var onSessionsChanged: (() -> Unit)? = null
val activeSession: TerminalSession?
get() = sessions.getOrNull(activeSessionIndex)
/**
* Create a new terminal session. Returns the session handle.
*/
fun createSession(): TerminalSession {
val env = EnvironmentBuilder.build(activity)
val prefix = env["PREFIX"] ?: ""
val homeDir = env["HOME"] ?: activity.filesDir.absolutePath
val tmpDir = env["TMPDIR"]
// Ensure HOME and TMP directories exist before starting the shell.
// Without this, chdir() fails if bootstrap hasn't been run yet.
java.io.File(homeDir).mkdirs()
tmpDir?.let { java.io.File(it).mkdirs() }
val shell =
if (java.io.File("$prefix/bin/bash").exists()) {
"$prefix/bin/bash"
} else if (java.io.File("$prefix/bin/sh").exists()) {
"$prefix/bin/sh"
} else {
"/system/bin/sh"
}
val session =
TerminalSession(
shell,
homeDir,
arrayOf<String>(),
env.entries.map { "${it.key}=${it.value}" }.toTypedArray(),
TRANSCRIPT_ROWS,
sessionClient,
)
sessions.add(session)
switchSession(sessions.size - 1)
eventBridge.emit(
"session_changed",
mapOf("id" to session.mHandle, "action" to "created"),
)
activity.runOnUiThread { onSessionsChanged?.invoke() }
AppLogger.i(TAG, "Created session ${session.mHandle} (total: ${sessions.size})")
return session
}
/**
* Switch to session by index.
*/
fun switchSession(index: Int) {
if (index < 0 || index >= sessions.size) return
activeSessionIndex = index
val session = sessions[index]
activity.runOnUiThread {
val terminalView = activity.findViewById<com.termux.view.TerminalView>(R.id.terminalView)
terminalView.attachSession(session)
terminalView.invalidate()
}
eventBridge.emit(
"session_changed",
mapOf("id" to session.mHandle, "action" to "switched"),
)
activity.runOnUiThread { onSessionsChanged?.invoke() }
}
/**
* Switch to session by handle ID.
*/
fun switchSession(handleId: String) {
val index = sessions.indexOfFirst { it.mHandle == handleId }
if (index >= 0) switchSession(index)
}
/**
* Find a session by handle ID.
*/
fun getSessionById(handleId: String): TerminalSession? = sessions.find { it.mHandle == handleId }
/**
* Close a session by handle ID.
*/
fun closeSession(handleId: String) {
val index = sessions.indexOfFirst { it.mHandle == handleId }
if (index < 0) return
finishedSessionIds.remove(handleId)
val session = sessions.removeAt(index)
session.finishIfRunning()
eventBridge.emit(
"session_changed",
mapOf("id" to handleId, "action" to "closed"),
)
// Switch to another session if available
if (sessions.isNotEmpty()) {
val newIndex = (index).coerceAtMost(sessions.size - 1)
switchSession(newIndex)
} else {
activeSessionIndex = -1
}
activity.runOnUiThread { onSessionsChanged?.invoke() }
AppLogger.i(TAG, "Closed session $handleId (remaining: ${sessions.size})")
}
/**
* Called when a session's process exits.
*/
fun onSessionFinished(session: TerminalSession) {
finishedSessionIds.add(session.mHandle)
eventBridge.emit(
"session_changed",
mapOf("id" to session.mHandle, "action" to "finished"),
)
activity.runOnUiThread { onSessionsChanged?.invoke() }
}
/**
* Get all sessions info for JsBridge.
*/
fun getSessionsInfo(): List<Map<String, Any>> =
sessions.mapIndexed { index, session ->
mapOf(
"id" to session.mHandle,
"name" to (session.title ?: "Session ${index + 1}"),
"active" to (index == activeSessionIndex),
"finished" to (session.mHandle in finishedSessionIds),
)
}
fun isSessionFinished(handleId: String): Boolean = handleId in finishedSessionIds
val sessionCount: Int get() = sessions.size
}
@@ -0,0 +1,84 @@
package com.openclaw.android
import android.content.Context
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.withTimeout
import java.io.File
import java.net.URL
/**
* Resolves download URLs with BuildConfig hardcoded fallback + config.json override (§2.9).
*
* Priority: cached config.json → remote config.json (5s timeout) → BuildConfig constants
*/
class UrlResolver(
private val context: Context,
) {
companion object {
private const val CONFIG_FETCH_TIMEOUT_MS = 5_000L
}
private val configFile =
File(
context.filesDir,
"usr/share/openclaw-app/config.json",
)
private val gson = Gson()
suspend fun getBootstrapUrl(): String {
val config = loadConfig()
return config?.bootstrap?.url ?: BuildConfig.BOOTSTRAP_URL
}
suspend fun getWwwUrl(): String {
val config = loadConfig()
return config?.www?.url ?: BuildConfig.WWW_URL
}
private suspend fun loadConfig(): RemoteConfig? {
// 1. Local cache
if (configFile.exists()) {
return try {
gson.fromJson(configFile.readText(), RemoteConfig::class.java)
} catch (_: Exception) {
null
}
}
// 2. Remote fetch (5s timeout)
return try {
withTimeout(CONFIG_FETCH_TIMEOUT_MS) {
val json = URL(BuildConfig.CONFIG_URL).readText()
configFile.parentFile?.mkdirs()
configFile.writeText(json)
gson.fromJson(json, RemoteConfig::class.java)
}
} catch (_: Exception) {
null // BuildConfig fallback
}
}
// --- Config data classes ---
data class RemoteConfig(
val version: Int?,
val bootstrap: ComponentConfig?,
val www: ComponentConfig?,
val platforms: List<PlatformConfig>?,
val features: Map<String, Boolean>?,
)
data class ComponentConfig(
val url: String,
val version: String?,
@SerializedName("sha256") val sha256: String?,
)
data class PlatformConfig(
val id: String,
val name: String,
val icon: String?,
val description: String?,
)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#55ffffff">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/extraKeyDefault" />
<corners android:radius="4dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#55ffffff">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/extraKeyActive" />
<corners android:radius="4dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#111827"
android:pathData="M54,54m-54,0a54,54,0,1,1,108,0a54,54,0,1,1,-108,0" />
<!-- 4 claw scratch marks, rotated diagonally -->
<group
android:pivotX="54"
android:pivotY="54"
android:rotation="-25">
<!-- Scratch 1 (outer left, shorter) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M31,22 C28,44,28,64,31,86 C34,64,34,44,31,22Z" />
<!-- Scratch 2 (inner left, longer) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M43,16 C40,40,40,64,43,92 C46,64,46,40,43,16Z" />
<!-- Scratch 3 (inner right, longer) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M55,16 C52,40,52,64,55,92 C58,64,58,40,55,16Z" />
<!-- Scratch 4 (outer right, shorter) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M67,22 C64,44,64,64,67,86 C70,64,70,44,67,22Z" />
</group>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,19V7H4V19H20ZM20,3C21.1,3 22,3.9 22,5V19C22,20.1 21.1,21 20,21H4C2.9,21 2,20.1 2,19V5C2,3.9 2.9,3 4,3H20ZM13,17V15H18V17H13ZM9.58,13L5.57,9L7,7.59L12.42,13L7,18.41L5.58,17L9.58,13Z" />
</vector>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/terminalBackground">
<!-- WebView — setup, dashboard, settings, platform selector -->
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible" />
<!-- Terminal container (TerminalView + Extra Keys bar) -->
<LinearLayout
android:id="@+id/terminalContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<!-- Session tab bar -->
<HorizontalScrollView
android:id="@+id/sessionTabBar"
android:layout_width="match_parent"
android:layout_height="36dp"
android:scrollbars="none"
android:background="@color/tabBarBackground"
android:focusable="false"
android:focusableInTouchMode="false">
<LinearLayout
android:id="@+id/tabsLayout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
</HorizontalScrollView>
<!-- Tab bar / terminal separator -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/tabBorder" />
<!-- TerminalView — native PTY terminal -->
<com.termux.view.TerminalView
android:id="@+id/terminalView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:focusable="true"
android:focusableInTouchMode="true" />
<!-- Extra Keys — two rows, no scrolling -->
<LinearLayout
android:id="@+id/extraKeysBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/extraKeysBackground">
<!-- Row 1: modifier & function keys -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="2dp"
android:paddingEnd="2dp">
<Button android:id="@+id/btnEsc" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="ESC" />
<Button android:id="@+id/btnCtrl" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="CTRL" />
<Button android:id="@+id/btnAlt" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="ALT" />
<Button android:id="@+id/btnTab" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="TAB" />
<Button android:id="@+id/btnHome" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="HOME" />
<Button android:id="@+id/btnEnd" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="END" />
</LinearLayout>
<!-- Row 2: arrow keys & common chars -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="2dp"
android:paddingEnd="2dp">
<Button android:id="@+id/btnLeft" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="◀" />
<Button android:id="@+id/btnUp" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▲" />
<Button android:id="@+id/btnDown" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▼" />
<Button android:id="@+id/btnRight" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▶" />
<Button android:id="@+id/btnDash" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="-" />
<Button android:id="@+id/btnPipe" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="|" />
<Button android:id="@+id/btnPaste" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="PST" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#1A1A2E</color>
<color name="colorPrimaryDark">#16213E</color>
<color name="colorAccent">#0F3460</color>
<color name="terminalBackground">#000000</color>
<color name="terminalForeground">#FFFFFF</color>
<color name="extraKeysBackground">#1a1a1a</color>
<color name="extraKeyDefault">#333333</color>
<color name="extraKeyActive">#58a6ff</color>
<color name="extraKeyText">#e0e0e0</color>
<color name="extraKeyActiveText">#ffffff</color>
<!-- Session tab bar -->
<color name="tabBarBackground">#161b22</color>
<color name="tabInactiveBackground">#21262d</color>
<color name="tabActiveBackground">#0d1117</color>
<color name="tabTextPrimary">#f0f6fc</color>
<color name="tabTextSecondary">#8b949e</color>
<color name="tabTextFinished">#484f58</color>
<color name="tabAccent">#58a6ff</color>
<color name="tabBorder">#30363d</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Claw</string>
<string name="notification_channel_id">openclaw_service</string>
<string name="notification_channel_name">OpenClaw Service</string>
<string name="notification_title">OpenClaw is running</string>
<string name="notification_text">Terminal session active</string>
</resources>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@color/terminalBackground</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
</style>
<style name="ExtraKeyButton" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">36dp</item>
<item name="android:minWidth">42dp</item>
<item name="android:paddingStart">8dp</item>
<item name="android:paddingEnd">8dp</item>
<item name="android:layout_marginStart">2dp</item>
<item name="android:layout_marginEnd">2dp</item>
<item name="android:textSize">12sp</item>
<item name="android:textColor">@color/extraKeyText</item>
<item name="backgroundTint">@color/extraKeyDefault</item>
<item name="android:textAllCaps">false</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="android:focusable">false</item>
<item name="android:focusableInTouchMode">false</item>
</style>
<style name="SessionTabClose">
<item name="android:layout_width">20dp</item>
<item name="android:layout_height">20dp</item>
<item name="android:textSize">14sp</item>
<item name="android:textColor">@color/tabTextSecondary</item>
<item name="android:background">?attr/selectableItemBackgroundBorderless</item>
<item name="android:gravity">center</item>
<item name="android:padding">0dp</item>
<item name="android:focusable">false</item>
<item name="android:focusableInTouchMode">false</item>
</style>
</resources>
@@ -0,0 +1,73 @@
package com.openclaw.android
import android.util.Log
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AppLoggerTest {
@BeforeEach
fun setup() {
mockkStatic(Log::class)
every { Log.v(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
every { Log.w(any(), any<String>(), any()) } returns 0
every { Log.e(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
}
@AfterEach
fun teardown() {
unmockkStatic(Log::class)
}
@Test
fun `v delegates to Log v`() {
AppLogger.v("TAG", "msg")
verify { Log.v("TAG", "msg") }
}
@Test
fun `d delegates to Log d`() {
AppLogger.d("TAG", "msg")
verify { Log.d("TAG", "msg") }
}
@Test
fun `i delegates to Log i`() {
AppLogger.i("TAG", "msg")
verify { Log.i("TAG", "msg") }
}
@Test
fun `w delegates to Log w`() {
AppLogger.w("TAG", "msg")
verify { Log.w("TAG", "msg") }
}
@Test
fun `w with throwable delegates to Log w`() {
val ex = RuntimeException("test")
AppLogger.w("TAG", "msg", ex)
verify { Log.w("TAG", "msg", ex) }
}
@Test
fun `e delegates to Log e`() {
AppLogger.e("TAG", "msg")
verify { Log.e("TAG", "msg") }
}
@Test
fun `e with throwable delegates to Log e`() {
val ex = RuntimeException("test")
AppLogger.e("TAG", "msg", ex)
verify { Log.e("TAG", "msg", ex) }
}
}
@@ -0,0 +1,49 @@
package com.openclaw.android
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
class CommandRunnerTest {
@TempDir
lateinit var tempDir: File
// PREFIX="" makes shell path "/bin/sh" — works on both macOS and Linux
private val env = mapOf("PREFIX" to "", "PATH" to "/usr/bin:/bin")
@Test
fun `runSync returns stdout for echo command`() {
val result = CommandRunner.runSync("echo hello", env, tempDir)
assertEquals(0, result.exitCode)
assertEquals("hello", result.stdout.trim())
}
@Test
fun `runSync returns non-zero exit code for failing command`() {
val result = CommandRunner.runSync("exit 42", env, tempDir)
assertEquals(42, result.exitCode)
}
@Test
fun `runSync captures stderr`() {
val result = CommandRunner.runSync("echo error >&2", env, tempDir)
assertEquals(0, result.exitCode)
assertEquals("error", result.stderr.trim())
}
@Test
fun `runSync handles invalid command`() {
val result = CommandRunner.runSync("nonexistent_command_xyz", env, tempDir)
assertTrue(result.exitCode != 0)
}
@Test
fun `CommandResult data class holds values correctly`() {
val result = CommandRunner.CommandResult(0, "out", "err")
assertEquals(0, result.exitCode)
assertEquals("out", result.stdout)
assertEquals("err", result.stderr)
}
}
@@ -0,0 +1,71 @@
package com.openclaw.android
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File
class EnvironmentBuilderTest {
private lateinit var env: Map<String, String>
private val filesDir = File("/data/data/com.openclaw.android/files")
@BeforeEach
fun setup() {
env = EnvironmentBuilder.build(filesDir)
}
@Test
fun `PREFIX points to usr directory`() {
assertEquals("${filesDir.absolutePath}/usr", env["PREFIX"])
}
@Test
fun `HOME points to home directory`() {
assertEquals("${filesDir.absolutePath}/home", env["HOME"])
}
@Test
fun `TMPDIR points to tmp directory`() {
assertEquals("${filesDir.absolutePath}/tmp", env["TMPDIR"])
}
@Test
fun `PATH contains node bin and prefix bin`() {
val path = env["PATH"]!!
assertTrue(path.contains(".openclaw-android/node/bin"))
assertTrue(path.contains("/usr/bin"))
}
@Test
fun `LD_LIBRARY_PATH is set`() {
assertNotNull(env["LD_LIBRARY_PATH"])
assertTrue(env["LD_LIBRARY_PATH"]!!.contains("/usr/lib"))
}
@Test
fun `APT_CONFIG points to apt conf`() {
assertTrue(env["APT_CONFIG"]!!.endsWith("/etc/apt/apt.conf"))
}
@Test
fun `GIT_CONFIG_NOSYSTEM is set to 1`() {
assertEquals("1", env["GIT_CONFIG_NOSYSTEM"])
}
@Test
fun `LANG is en_US UTF-8`() {
assertEquals("en_US.UTF-8", env["LANG"])
}
@Test
fun `TERM is xterm-256color`() {
assertEquals("xterm-256color", env["TERM"])
}
@Test
fun `OA_GLIBC is set`() {
assertEquals("1", env["OA_GLIBC"])
}
}