chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".OpenClawService"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* glibc-compat.js - Minimal compatibility shim for glibc Node.js on Android
|
||||
*
|
||||
* This is the successor to bionic-compat.js, drastically reduced for glibc.
|
||||
*
|
||||
* What's NOT needed anymore (glibc handles these):
|
||||
* - process.platform override (glibc Node.js reports 'linux' natively)
|
||||
* - renameat2 / spawn.h stubs (glibc includes them)
|
||||
* - CXXFLAGS / GYP_DEFINES overrides (glibc is standard Linux)
|
||||
*
|
||||
* What's still needed (kernel/Android-level restrictions, not libc):
|
||||
* - os.cpus() fallback: SELinux blocks /proc/stat on Android 8+
|
||||
* - os.networkInterfaces() safety: EACCES on some Android configurations
|
||||
* - /bin/sh path shim: Android 7-8 lacks /bin/sh (Android 9+ has it)
|
||||
*
|
||||
* Loaded via node wrapper script: node --require <path>/glibc-compat.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ─── process.execPath fix ────────────────────────────────────
|
||||
// When node runs via grun (ld.so node.real), process.execPath points to
|
||||
// ld.so instead of the node wrapper. Apps that spawn child node processes
|
||||
// using process.execPath (e.g., openclaw) will call ld.so directly,
|
||||
// bypassing the wrapper's LD_PRELOAD unset and compat loading.
|
||||
// Fix: point process.execPath to the wrapper script.
|
||||
|
||||
const _wrapperPath = process.env._OA_WRAPPER_PATH || path.join(
|
||||
process.env.HOME || '/data/data/com.termux/files/home',
|
||||
'.openclaw-android', 'bin', 'node'
|
||||
);
|
||||
try {
|
||||
if (fs.existsSync(_wrapperPath)) {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: _wrapperPath,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
|
||||
// ─── LD_PRELOAD cleanup ─────────────────────────────────────
|
||||
// The node wrapper unsets LD_PRELOAD to prevent bionic libtermux-exec.so
|
||||
// from loading into the glibc node.real process.
|
||||
//
|
||||
// Previously, we restored LD_PRELOAD here so bionic child processes
|
||||
// (like /bin/sh) would get libtermux-exec.so for path translation
|
||||
// (e.g., /usr/bin/env → $PREFIX/bin/env in shebang resolution).
|
||||
//
|
||||
// However, libtermux-exec.so re-injects LD_PRELOAD into execve() calls
|
||||
// even after the shell unsets it. This causes glibc processes (ld.so)
|
||||
// spawned from node to crash with "Could not find a PHDR" errors.
|
||||
//
|
||||
// Fix: Do NOT restore LD_PRELOAD. Instead, the spawn/spawnSync wrapper
|
||||
// below handles shebang resolution (#!/usr/bin/env → PATH lookup) in
|
||||
// JavaScript, eliminating the need for libtermux-exec.so in child processes.
|
||||
|
||||
delete process.env.LD_PRELOAD;
|
||||
delete process.env._OA_ORIG_LD_PRELOAD;
|
||||
|
||||
|
||||
// ─── os.cpus() fallback ─────────────────────────────────────
|
||||
// Android 8+ (API 26+) blocks /proc/stat via SELinux + hidepid=2.
|
||||
// libuv reads /proc/stat for CPU info → returns empty array.
|
||||
// Tools using os.cpus().length for parallelism (e.g., make -j) break with 0.
|
||||
|
||||
const _originalCpus = os.cpus;
|
||||
|
||||
os.cpus = function cpus() {
|
||||
const result = _originalCpus.call(os);
|
||||
if (result.length > 0) {
|
||||
return result;
|
||||
}
|
||||
// Return a single fake CPU entry so .length is at least 1
|
||||
return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }];
|
||||
};
|
||||
|
||||
// ─── os.networkInterfaces() safety ──────────────────────────
|
||||
// Some Android configurations throw EACCES when reading network
|
||||
// interface information. Wrap with try-catch to prevent crashes.
|
||||
//
|
||||
// Additionally, Android/Termux typically only exposes the loopback
|
||||
// interface (`lo`) to Node.js. In that situation, OpenClaw's Bonjour
|
||||
// advertiser can't send multicast announcements and logs noisy
|
||||
// "Announcement failed as of socket errors!" repeatedly.
|
||||
// Auto-disable Bonjour via OPENCLAW_DISABLE_BONJOUR when only
|
||||
// loopback interfaces are visible.
|
||||
|
||||
const _originalNetworkInterfaces = os.networkInterfaces;
|
||||
|
||||
function _createLoopbackInterfaces() {
|
||||
return {
|
||||
lo: [
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
netmask: '255.0.0.0',
|
||||
family: 'IPv4',
|
||||
mac: '00:00:00:00:00:00',
|
||||
internal: true,
|
||||
cidr: '127.0.0.1/8',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function _hasNonLoopbackInterface(interfaces) {
|
||||
try {
|
||||
return Object.values(interfaces).some(entries =>
|
||||
Array.isArray(entries) && entries.some(entry => entry && entry.internal === false)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
os.networkInterfaces = function networkInterfaces() {
|
||||
let interfaces;
|
||||
try {
|
||||
interfaces = _originalNetworkInterfaces.call(os);
|
||||
} catch {
|
||||
interfaces = _createLoopbackInterfaces();
|
||||
}
|
||||
if (!process.env.OPENCLAW_DISABLE_BONJOUR && !_hasNonLoopbackInterface(interfaces)) {
|
||||
process.env.OPENCLAW_DISABLE_BONJOUR = '1';
|
||||
}
|
||||
return interfaces;
|
||||
};
|
||||
|
||||
// ─── Shell override for exec/execSync ────────────────────────
|
||||
// Node.js child_process hardcodes /bin/sh as the default shell on Linux.
|
||||
// On Android:
|
||||
// - Android 7-8: /bin/sh doesn't exist at all
|
||||
// - Android 9+: /bin/sh exists (/system/bin/sh) but is minimal (toybox/mksh)
|
||||
// and lacks Termux PATH, environment, and proper command support
|
||||
// Always use Termux's shell for exec/execSync to ensure consistent behavior.
|
||||
|
||||
{
|
||||
const child_process = require('child_process');
|
||||
const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh';
|
||||
|
||||
if (fs.existsSync(termuxSh)) {
|
||||
const _originalExec = child_process.exec;
|
||||
const _originalExecSync = child_process.execSync;
|
||||
|
||||
child_process.exec = function exec(command, options, callback) {
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
options = options || {};
|
||||
if (!options.shell) {
|
||||
options.shell = termuxSh;
|
||||
}
|
||||
return _originalExec.call(child_process, command, options, callback);
|
||||
};
|
||||
|
||||
child_process.execSync = function execSync(command, options) {
|
||||
options = options || {};
|
||||
if (!options.shell) {
|
||||
options.shell = termuxSh;
|
||||
}
|
||||
return _originalExecSync.call(child_process, command, options);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DNS resolver fix ────────────────────────────────────────
|
||||
// glibc's getaddrinfo() reads /data/data/com.termux/files/usr/glibc/etc/resolv.conf
|
||||
// for DNS servers. This file may be missing or inaccessible:
|
||||
// - Standalone APK: runs under com.openclaw.android, can't access com.termux paths
|
||||
// - Termux: resolv-conf package may not be installed
|
||||
// Without a valid resolv.conf, dns.lookup() fails with EAI_AGAIN errors.
|
||||
//
|
||||
// Fix: Override both dns.lookup (callback) and dns.promises.lookup (promise)
|
||||
// to use c-ares resolver (dns.resolve) which respects dns.setServers(),
|
||||
// then fall back to getaddrinfo.
|
||||
|
||||
try {
|
||||
const dns = require('dns');
|
||||
|
||||
// Read DNS servers from our resolv.conf or use Google DNS as fallback
|
||||
let dnsServers = ['8.8.8.8', '8.8.4.4'];
|
||||
try {
|
||||
const resolvConf = fs.readFileSync(
|
||||
(process.env.PREFIX || '/data/data/com.termux/files/usr') + '/etc/resolv.conf',
|
||||
'utf8'
|
||||
);
|
||||
const parsed = resolvConf.match(/^nameserver\s+(.+)$/gm);
|
||||
if (parsed && parsed.length > 0) {
|
||||
dnsServers = parsed.map(l => l.replace(/^nameserver\s+/, '').trim());
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Set DNS servers for c-ares resolver
|
||||
try { dns.setServers(dnsServers); } catch {}
|
||||
|
||||
// Override dns.lookup (callback API) to use c-ares resolver
|
||||
const _originalLookup = dns.lookup;
|
||||
|
||||
// Localhost must never go to external DNS. Android/glibc may lack /etc/hosts,
|
||||
// causing getaddrinfo to fail or return 0.0.0.0. Short-circuit it here.
|
||||
const _localhostNames = new Set(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']);
|
||||
|
||||
dns.lookup = function lookup(hostname, options, callback) {
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
const originalOptions = options;
|
||||
const opts = typeof options === 'number' ? { family: options } : (options || {});
|
||||
const wantAll = opts.all === true;
|
||||
const family = opts.family || 0;
|
||||
|
||||
// Short-circuit localhost — never send to external DNS
|
||||
if (_localhostNames.has(hostname)) {
|
||||
if (family === 6) {
|
||||
if (wantAll) return callback(null, [{ address: '::1', family: 6 }]);
|
||||
return callback(null, '::1', 6);
|
||||
}
|
||||
if (wantAll) return callback(null, [{ address: '127.0.0.1', family: 4 }]);
|
||||
return callback(null, '127.0.0.1', 4);
|
||||
}
|
||||
|
||||
const resolve = (fam, cb) => {
|
||||
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
|
||||
fn(hostname, cb);
|
||||
};
|
||||
|
||||
const tryResolve = (fam) => {
|
||||
resolve(fam, (err, addresses) => {
|
||||
if (!err && addresses && addresses.length > 0) {
|
||||
const resFam = fam === 6 ? 6 : 4;
|
||||
if (wantAll) {
|
||||
callback(null, addresses.map(a => ({ address: a, family: resFam })));
|
||||
} else {
|
||||
callback(null, addresses[0], resFam);
|
||||
}
|
||||
} else if (family === 0 && fam === 4) {
|
||||
tryResolve(6);
|
||||
} else {
|
||||
_originalLookup.call(dns, hostname, originalOptions, callback);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryResolve(family === 6 ? 6 : 4);
|
||||
};
|
||||
|
||||
// Override dns.promises.lookup (promise API) to use c-ares resolver.
|
||||
// OpenClaw's SSRF guard uses this API for web_search DNS resolution.
|
||||
const _originalPromiseLookup = dns.promises.lookup;
|
||||
dns.promises.lookup = async function lookup(hostname, options) {
|
||||
const opts = typeof options === 'number' ? { family: options } : (options || {});
|
||||
const wantAll = opts.all === true;
|
||||
const family = opts.family || 0;
|
||||
|
||||
// Short-circuit localhost
|
||||
if (_localhostNames.has(hostname)) {
|
||||
if (family === 6) {
|
||||
return wantAll ? [{ address: '::1', family: 6 }] : { address: '::1', family: 6 };
|
||||
}
|
||||
return wantAll ? [{ address: '127.0.0.1', family: 4 }] : { address: '127.0.0.1', family: 4 };
|
||||
}
|
||||
|
||||
const resolve = (fam) => {
|
||||
return new Promise((res, rej) => {
|
||||
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
|
||||
fn(hostname, (err, addresses) => err ? rej(err) : res(addresses));
|
||||
});
|
||||
};
|
||||
|
||||
const tryResolve = async (fam) => {
|
||||
try {
|
||||
const addresses = await resolve(fam);
|
||||
if (addresses && addresses.length > 0) {
|
||||
const resFam = fam === 6 ? 6 : 4;
|
||||
if (wantAll) {
|
||||
return addresses.map(a => ({ address: a, family: resFam }));
|
||||
}
|
||||
return { address: addresses[0], family: resFam };
|
||||
}
|
||||
} catch {}
|
||||
if (family === 0 && fam === 4) return tryResolve(6);
|
||||
return _originalPromiseLookup.call(dns.promises, hostname, options);
|
||||
};
|
||||
|
||||
return tryResolve(family === 6 ? 6 : 4);
|
||||
};
|
||||
} catch {}
|
||||
|
||||
// ─── ELF binary auto-wrapping for spawn/spawnSync ──────────
|
||||
// npm/npx-installed native binaries (e.g., @zed-industries/codex-acp)
|
||||
// are standard Linux ELF files whose interpreter is /lib/ld-linux-aarch64.so.1.
|
||||
// Android lacks this path, so the kernel returns ENOENT on direct execution.
|
||||
// Intercept child_process spawn APIs to detect ELF binaries and automatically
|
||||
// route them through the glibc dynamic linker (ld.so).
|
||||
|
||||
const _glibcLdso = (process.env.PREFIX || '/data/data/com.termux/files/usr')
|
||||
+ '/glibc/lib/ld-linux-aarch64.so.1';
|
||||
const _glibcLibPath = (process.env.PREFIX || '/data/data/com.termux/files/usr')
|
||||
+ '/glibc/lib';
|
||||
|
||||
function _needsGlibcWrap(filePath) {
|
||||
// Read ELF header and check PT_INTERP to distinguish glibc binaries
|
||||
// from bionic (Android/Termux) binaries. Only glibc binaries need wrapping.
|
||||
// Bionic binaries use /system/bin/linker64; glibc use /lib/ld-linux-*.so.1
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const ehdr = Buffer.alloc(64);
|
||||
if (fs.readSync(fd, ehdr, 0, 64, 0) < 64) return false;
|
||||
// Check ELF magic
|
||||
if (ehdr[0] !== 0x7f || ehdr[1] !== 0x45 || ehdr[2] !== 0x4c || ehdr[3] !== 0x46) return false;
|
||||
// ELF64: e_phoff at offset 32 (8 bytes), e_phentsize at 54 (2 bytes), e_phnum at 56 (2 bytes)
|
||||
const phoff = Number(ehdr.readBigUInt64LE(32));
|
||||
const phentsize = ehdr.readUInt16LE(54);
|
||||
const phnum = ehdr.readUInt16LE(56);
|
||||
// Scan program headers for PT_INTERP (type = 3)
|
||||
for (let i = 0; i < phnum; i++) {
|
||||
const phBuf = Buffer.alloc(phentsize);
|
||||
if (fs.readSync(fd, phBuf, 0, phentsize, phoff + i * phentsize) < phentsize) continue;
|
||||
const pType = phBuf.readUInt32LE(0);
|
||||
if (pType === 3) { // PT_INTERP
|
||||
const interpOff = Number(phBuf.readBigUInt64LE(8));
|
||||
const interpSize = Number(phBuf.readBigUInt64LE(32));
|
||||
const interpBuf = Buffer.alloc(Math.min(interpSize, 256));
|
||||
fs.readSync(fd, interpBuf, 0, interpBuf.length, interpOff);
|
||||
const interp = interpBuf.toString('utf8').replace(/\0+$/, '');
|
||||
return interp.includes('ld-linux');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveCommand(command, env) {
|
||||
if (command.includes('/')) {
|
||||
try { return fs.realpathSync(command); } catch { return command; }
|
||||
}
|
||||
const searchPath = (env && env.PATH) || process.env.PATH || '';
|
||||
const dirs = searchPath.split(':');
|
||||
for (const dir of dirs) {
|
||||
const full = path.join(dir, command);
|
||||
try {
|
||||
fs.accessSync(full, fs.constants.X_OK);
|
||||
return full;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _readShebang(filePath) {
|
||||
// Read first 256 bytes to extract shebang line from script files.
|
||||
// Returns null if not a script, or { interpreter, args } if shebang found.
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(256);
|
||||
const n = fs.readSync(fd, buf, 0, 256, 0);
|
||||
if (n < 2 || buf[0] !== 0x23 || buf[1] !== 0x21) return null; // not #!
|
||||
const line = buf.toString('utf8', 2, n).split('\n')[0].trim();
|
||||
const parts = line.split(/\s+/);
|
||||
return { interpreter: parts[0], args: parts.slice(1) };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveShebang(resolved, spawnEnv) {
|
||||
// For a resolved file path, check if it has a shebang pointing to a
|
||||
// non-existent interpreter (e.g. #!/usr/bin/env node on Android).
|
||||
// Returns { interpPath, interpArgs } or null if no fixup needed.
|
||||
const shebang = _readShebang(resolved);
|
||||
if (!shebang) return null;
|
||||
|
||||
let interpPath = shebang.interpreter;
|
||||
let interpArgs = shebang.args;
|
||||
|
||||
if (interpPath === '/usr/bin/env' && interpArgs.length > 0) {
|
||||
// #!/usr/bin/env <cmd> — resolve <cmd> from PATH
|
||||
let cmdIdx = 0;
|
||||
while (cmdIdx < interpArgs.length && interpArgs[cmdIdx].startsWith('-')) cmdIdx++;
|
||||
if (cmdIdx >= interpArgs.length) return null;
|
||||
const cmd = interpArgs[cmdIdx];
|
||||
const resolvedCmd = _resolveCommand(cmd, spawnEnv);
|
||||
if (!resolvedCmd) return null;
|
||||
interpPath = resolvedCmd;
|
||||
interpArgs = interpArgs.slice(0, cmdIdx).concat(interpArgs.slice(cmdIdx + 1));
|
||||
} else if (!fs.existsSync(interpPath)) {
|
||||
// Interpreter at non-existent absolute path — try resolving by basename from PATH
|
||||
const basename = path.basename(interpPath);
|
||||
const resolvedInterp = _resolveCommand(basename, spawnEnv);
|
||||
if (!resolvedInterp) return null;
|
||||
interpPath = resolvedInterp;
|
||||
} else {
|
||||
// Shebang interpreter exists, kernel can handle it
|
||||
return null;
|
||||
}
|
||||
|
||||
return { interpPath, interpArgs };
|
||||
}
|
||||
|
||||
// Detect shell invocation patterns: spawn('/path/to/sh', ['-c', 'cmd args...'])
|
||||
// or spawn('cmd', args, { shell: true })
|
||||
function _isShellInvocation(file, args) {
|
||||
if (!args || args.length < 2 || args[0] !== '-c') return null;
|
||||
const base = path.basename(file);
|
||||
if (base !== 'sh' && base !== 'bash' && base !== 'dash' && base !== 'zsh') return null;
|
||||
return args[1]; // the shell command string
|
||||
}
|
||||
|
||||
function _tryFixShellCommand(cmdStr, spawnEnv) {
|
||||
// Extract the command name from a simple shell command string.
|
||||
// Only handle simple cases: "cmd arg1 arg2..." — no pipes, redirects, etc.
|
||||
const shellChars = /[|><&;`$(){}]/;
|
||||
if (shellChars.test(cmdStr)) return null;
|
||||
|
||||
const parts = cmdStr.trim().split(/\s+/);
|
||||
if (parts.length === 0) return null;
|
||||
const cmd = parts[0];
|
||||
const cmdArgs = parts.slice(1);
|
||||
|
||||
const resolved = _resolveCommand(cmd, spawnEnv);
|
||||
if (!resolved) return null;
|
||||
|
||||
// glibc ELF binary
|
||||
if (_needsGlibcWrap(resolved)) {
|
||||
const env = Object.assign({}, spawnEnv);
|
||||
delete env.LD_PRELOAD;
|
||||
return {
|
||||
file: _glibcLdso,
|
||||
args: ['--library-path', _glibcLibPath, resolved].concat(cmdArgs),
|
||||
env: env,
|
||||
};
|
||||
}
|
||||
|
||||
// Script with broken shebang
|
||||
const shebang = _resolveShebang(resolved, spawnEnv);
|
||||
if (shebang) {
|
||||
return {
|
||||
file: shebang.interpPath,
|
||||
args: shebang.interpArgs.concat([resolved]).concat(cmdArgs),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function _tryWrapSpawn(file, args, options) {
|
||||
const spawnEnv = options && options.env ? options.env : process.env;
|
||||
|
||||
// Detect shell invocations: spawn('sh', ['-c', 'command...']) or spawn(cmd, args, { shell: true })
|
||||
// npm/npx uses spawn('/path/to/sh', ['-c', 'cmd args...']) to execute package binaries.
|
||||
// Without libtermux-exec.so, #!/usr/bin/env shebangs in these scripts fail.
|
||||
if (options && options.shell) {
|
||||
// shell: true — Node.js will convert to sh -c 'file args...'
|
||||
if (!file.includes(' ') && !(/[|><&;`$()]/).test(file)) {
|
||||
const resolved = _resolveCommand(file, spawnEnv);
|
||||
if (resolved) {
|
||||
if (_needsGlibcWrap(resolved)) {
|
||||
const env = Object.assign({}, spawnEnv);
|
||||
delete env.LD_PRELOAD;
|
||||
return {
|
||||
file: _glibcLdso,
|
||||
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
|
||||
options: Object.assign({}, options, { env: env, shell: false }),
|
||||
};
|
||||
}
|
||||
const shebang = _resolveShebang(resolved, spawnEnv);
|
||||
if (shebang) {
|
||||
return {
|
||||
file: shebang.interpPath,
|
||||
args: shebang.interpArgs.concat([resolved]).concat(args || []),
|
||||
options: Object.assign({}, options, { shell: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Direct shell invocation: spawn('/path/to/sh', ['-c', 'cmd args...'])
|
||||
const shellCmd = _isShellInvocation(file, args);
|
||||
if (shellCmd) {
|
||||
const fix = _tryFixShellCommand(shellCmd, spawnEnv);
|
||||
if (fix) {
|
||||
return {
|
||||
file: fix.file,
|
||||
args: fix.args,
|
||||
options: fix.env ? Object.assign({}, options, { env: fix.env }) : options,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = _resolveCommand(file, spawnEnv);
|
||||
if (!resolved) return null;
|
||||
if (resolved === _glibcLdso || resolved.endsWith('/ld-linux-aarch64.so.1')) return null;
|
||||
|
||||
// Case 1: glibc ELF binary — wrap with ld.so
|
||||
if (_needsGlibcWrap(resolved)) {
|
||||
const env = Object.assign({}, spawnEnv);
|
||||
delete env.LD_PRELOAD;
|
||||
return {
|
||||
file: _glibcLdso,
|
||||
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
|
||||
options: Object.assign({}, options, { env: env }),
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Script with shebang pointing to non-existent path
|
||||
const shebang = _resolveShebang(resolved, spawnEnv);
|
||||
if (!shebang) return null;
|
||||
|
||||
return {
|
||||
file: shebang.interpPath,
|
||||
args: shebang.interpArgs.concat([resolved]).concat(args || []),
|
||||
options: options,
|
||||
};
|
||||
}
|
||||
|
||||
if (fs.existsSync(_glibcLdso)) {
|
||||
const _cp = require('child_process');
|
||||
|
||||
// Normalize optional args parameter: spawn(cmd[, args][, opts])
|
||||
function _normalizeArgs(args, options) {
|
||||
if (args != null && !Array.isArray(args)) {
|
||||
return { args: [], options: args };
|
||||
}
|
||||
return { args: args || [], options: options };
|
||||
}
|
||||
|
||||
const _origSpawn = _cp.spawn;
|
||||
_cp.spawn = function spawn(command, args, options) {
|
||||
const n = _normalizeArgs(args, options);
|
||||
const w = _tryWrapSpawn(command, n.args, n.options);
|
||||
if (w) return _origSpawn.call(_cp, w.file, w.args, w.options);
|
||||
return _origSpawn.call(_cp, command, args, options);
|
||||
};
|
||||
|
||||
const _origSpawnSync = _cp.spawnSync;
|
||||
_cp.spawnSync = function spawnSync(command, args, options) {
|
||||
const n = _normalizeArgs(args, options);
|
||||
const w = _tryWrapSpawn(command, n.args, n.options);
|
||||
if (w) return _origSpawnSync.call(_cp, w.file, w.args, w.options);
|
||||
return _origSpawnSync.call(_cp, command, args, options);
|
||||
};
|
||||
|
||||
const _origExecFile = _cp.execFile;
|
||||
_cp.execFile = function execFile(file, args, options, callback) {
|
||||
// execFile(file[, args][, options], callback)
|
||||
if (typeof args === 'function') {
|
||||
callback = args; args = []; options = {};
|
||||
} else if (typeof options === 'function') {
|
||||
callback = options;
|
||||
if (Array.isArray(args)) { options = {}; } else { options = args; args = []; }
|
||||
}
|
||||
const w = _tryWrapSpawn(file, args, options);
|
||||
if (w) return _origExecFile.call(_cp, w.file, w.args, w.options, callback);
|
||||
return _origExecFile.call(_cp, file, args, options, callback);
|
||||
};
|
||||
|
||||
const _origExecFileSync = _cp.execFileSync;
|
||||
_cp.execFileSync = function execFileSync(file, args, options) {
|
||||
const n = _normalizeArgs(args, options);
|
||||
const w = _tryWrapSpawn(file, n.args, n.options);
|
||||
if (w) return _origExecFileSync.call(_cp, w.file, w.args, w.options);
|
||||
return _origExecFileSync.call(_cp, file, args, options);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenClaw Android — Post-Bootstrap Setup
|
||||
# Runs in the terminal after Termux bootstrap extraction.
|
||||
# Installs: git, glibc, Node.js, OpenClaw
|
||||
#
|
||||
# Strategy:
|
||||
# - Termux .deb packages: dpkg-deb -x + relocate (bypasses dpkg hardcoded paths)
|
||||
# - Pacman .pkg.tar.xz packages: tar -xJf + relocate (bypasses pacman entirely)
|
||||
# - Both have files under data/data/com.termux/files/usr/ which we relocate to $PREFIX
|
||||
#
|
||||
# Why not apt-get/dpkg/pacman?
|
||||
# All three have hardcoded /data/data/com.termux/... paths that libtermux-exec
|
||||
# cannot rewrite (it only intercepts execve, not open/opendir).
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
# ─── Paths ────────────────────────────────────
|
||||
: "${PREFIX:?PREFIX not set}"
|
||||
: "${HOME:?HOME not set}"
|
||||
: "${TMPDIR:=$(dirname "$PREFIX")/tmp}"
|
||||
|
||||
OCA_DIR="$HOME/.openclaw-android"
|
||||
NODE_DIR="$OCA_DIR/node"
|
||||
BIN_DIR="$OCA_DIR/bin"
|
||||
NODE_VERSION="22.22.0"
|
||||
GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
|
||||
MARKER="$OCA_DIR/.post-setup-done"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ─── GitHub mirror fallback (for China/restricted networks) ──
|
||||
REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
|
||||
REPO_BASE="$REPO_BASE_ORIGIN"
|
||||
resolve_repo_base() {
|
||||
if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then
|
||||
REPO_BASE="$REPO_BASE_ORIGIN"; return 0
|
||||
fi
|
||||
local mirrors=(
|
||||
"https://ghfast.top/$REPO_BASE_ORIGIN"
|
||||
"https://ghproxy.net/$REPO_BASE_ORIGIN"
|
||||
"https://mirror.ghproxy.com/$REPO_BASE_ORIGIN"
|
||||
)
|
||||
for m in "${mirrors[@]}"; do
|
||||
if curl -sI --connect-timeout 3 "$m/oa.sh" >/dev/null 2>&1; then
|
||||
echo -e " ${YELLOW}[MIRROR]${NC} Using mirror for GitHub downloads"
|
||||
REPO_BASE="$m"; return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Kept in sync with scripts/lib.sh resolve_npm_registry()
|
||||
NPM_REGISTRY_ORIGIN="https://registry.npmjs.org/"
|
||||
NPM_REGISTRY_MIRROR="https://registry.npmmirror.com/"
|
||||
resolve_npm_registry() {
|
||||
local choice
|
||||
local cache_file="$OCA_DIR/.npm-registry"
|
||||
local reachable=0
|
||||
if curl -sI --connect-timeout 5 "$NPM_REGISTRY_ORIGIN" >/dev/null 2>&1; then
|
||||
choice="$NPM_REGISTRY_ORIGIN"
|
||||
reachable=1
|
||||
elif curl -sI --connect-timeout 5 "$NPM_REGISTRY_MIRROR" >/dev/null 2>&1; then
|
||||
echo -e " ${YELLOW}[MIRROR]${NC} Using npm mirror: ${NPM_REGISTRY_MIRROR}"
|
||||
choice="$NPM_REGISTRY_MIRROR"
|
||||
reachable=1
|
||||
else
|
||||
choice="$NPM_REGISTRY_ORIGIN"
|
||||
fi
|
||||
mkdir -p "$(dirname "$cache_file")"
|
||||
printf '%s' "$choice" > "$cache_file.tmp" && mv "$cache_file.tmp" "$cache_file"
|
||||
export NPM_CONFIG_REGISTRY="$choice"
|
||||
if [ "$reachable" -eq 1 ]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# SSL cert for curl (bootstrap curl looks at hardcoded com.termux path)
|
||||
export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem"
|
||||
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
|
||||
export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem"
|
||||
|
||||
# Git system config has hardcoded com.termux path — skip it
|
||||
export GIT_CONFIG_NOSYSTEM=1
|
||||
|
||||
# Git exec path (git looks for helpers like git-remote-https here)
|
||||
export GIT_EXEC_PATH="$PREFIX/libexec/git-core"
|
||||
|
||||
# Git template dir (hardcoded /data/data/com.termux path workaround)
|
||||
export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates"
|
||||
|
||||
if [ -f "$MARKER" ]; then
|
||||
echo -e "${GREEN}Post-setup already completed.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo " OpenClaw Android — Installing components"
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
mkdir -p "$OCA_DIR" "$OCA_DIR/patches" "$TMPDIR"
|
||||
|
||||
TERMUX_DEB_REPO="https://packages-cf.termux.dev/apt/termux-main"
|
||||
PACMAN_PKG_REPO="https://service.termux-pacman.dev/gpkg/aarch64"
|
||||
TERMUX_INNER="data/data/com.termux/files/usr"
|
||||
DEB_DIR="$TMPDIR/debs"
|
||||
PKG_DIR="$TMPDIR/pkgs"
|
||||
EXTRACT_DIR="$TMPDIR/pkg-extract"
|
||||
|
||||
# ─── Helper: install_deb ──────────────────────
|
||||
# Downloads a .deb from Termux repo and extracts into $PREFIX
|
||||
install_deb() {
|
||||
local filename="$1"
|
||||
local name
|
||||
name=$(basename "$filename" | sed 's/_[0-9].*//')
|
||||
local url="${TERMUX_DEB_REPO}/${filename}"
|
||||
local deb_file="${DEB_DIR}/$(basename "$filename")"
|
||||
|
||||
if [ -f "$deb_file" ]; then
|
||||
echo " (cached) $name"
|
||||
else
|
||||
echo " downloading $name..."
|
||||
curl -fsSL --max-time 120 -o "$deb_file" "$url"
|
||||
fi
|
||||
|
||||
rm -rf "$EXTRACT_DIR"
|
||||
mkdir -p "$EXTRACT_DIR"
|
||||
dpkg-deb -x "$deb_file" "$EXTRACT_DIR" 2>/dev/null
|
||||
|
||||
# Relocate: data/data/com.termux/files/usr/* → $PREFIX/
|
||||
if [ -d "$EXTRACT_DIR/$TERMUX_INNER" ]; then
|
||||
cp -a "$EXTRACT_DIR/$TERMUX_INNER/"* "$PREFIX/" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$EXTRACT_DIR"
|
||||
}
|
||||
|
||||
# ─── Helper: install_pacman_pkg ───────────────
|
||||
# Downloads a .pkg.tar.xz from pacman repo and extracts into target dir
|
||||
install_pacman_pkg() {
|
||||
local filename="$1"
|
||||
local target="$2" # e.g., $PREFIX/glibc
|
||||
local name
|
||||
name=${filename%%-[0-9]*}
|
||||
local url="${PACMAN_PKG_REPO}/${filename}"
|
||||
local pkg_file="${PKG_DIR}/${filename}"
|
||||
|
||||
if [ -f "$pkg_file" ]; then
|
||||
echo " (cached) $name"
|
||||
else
|
||||
echo " downloading $name..."
|
||||
curl -fsSL --max-time 300 -o "$pkg_file" "$url"
|
||||
fi
|
||||
|
||||
rm -rf "$EXTRACT_DIR"
|
||||
mkdir -p "$EXTRACT_DIR"
|
||||
tar -xJf "$pkg_file" -C "$EXTRACT_DIR" 2>/dev/null
|
||||
|
||||
# Pacman packages also extract under data/data/com.termux/files/usr/...
|
||||
local inner="$EXTRACT_DIR/$TERMUX_INNER"
|
||||
if [ -d "$inner/glibc" ]; then
|
||||
# glibc packages go under $PREFIX/glibc/
|
||||
cp -a "$inner/glibc/"* "$target/" 2>/dev/null || true
|
||||
elif [ -d "$inner" ]; then
|
||||
cp -a "$inner/"* "$target/" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$EXTRACT_DIR"
|
||||
}
|
||||
|
||||
# ─── [1/7] Install essential packages ─────────
|
||||
echo -e "▸ ${YELLOW}[1/7]${NC} Installing essential packages..."
|
||||
mkdir -p "$DEB_DIR" "$PKG_DIR"
|
||||
|
||||
# Download Packages index to resolve .deb filenames
|
||||
echo " Fetching package index..."
|
||||
PACKAGES_FILE="$TMPDIR/Packages"
|
||||
curl -fsSL --max-time 60 \
|
||||
"${TERMUX_DEB_REPO}/dists/stable/main/binary-aarch64/Packages" \
|
||||
-o "$PACKAGES_FILE"
|
||||
|
||||
# Resolve package filename from Packages index
|
||||
get_deb_filename() {
|
||||
local pkg="$1"
|
||||
awk -v pkg="$pkg" '
|
||||
/^Package: / { found = ($2 == pkg) }
|
||||
found && /^Filename:/ { print $2; exit }
|
||||
' "$PACKAGES_FILE"
|
||||
}
|
||||
|
||||
# Packages to install via dpkg-deb (dependency order, only those missing from bootstrap)
|
||||
DEB_PACKAGES=(
|
||||
libexpat # git dep
|
||||
pcre2 # git dep
|
||||
git # for npm/openclaw
|
||||
)
|
||||
|
||||
TOTAL=${#DEB_PACKAGES[@]}
|
||||
COUNT=0
|
||||
for pkg in "${DEB_PACKAGES[@]}"; do
|
||||
COUNT=$((COUNT + 1))
|
||||
filename=$(get_deb_filename "$pkg")
|
||||
if [ -z "$filename" ]; then
|
||||
echo -e " ${RED}✗${NC} Package '$pkg' not found in index"
|
||||
continue
|
||||
fi
|
||||
echo " [$COUNT/$TOTAL] $pkg"
|
||||
install_deb "$filename"
|
||||
done
|
||||
|
||||
# Make sure newly extracted binaries are executable
|
||||
chmod +x "$PREFIX/bin/"* 2>/dev/null || true
|
||||
|
||||
# Verify git
|
||||
if [ -f "$PREFIX/bin/git" ]; then
|
||||
echo -e " ${GREEN}✓${NC} git $(git --version 2>/dev/null | head -1)"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} git not found after extraction"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── [2/7] glibc runtime ─────────────────────
|
||||
echo -e "▸ ${YELLOW}[2/7]${NC} Installing glibc runtime..."
|
||||
|
||||
if [ -x "$GLIBC_LDSO" ]; then
|
||||
echo -e " ${GREEN}[SKIP]${NC} glibc already installed"
|
||||
else
|
||||
mkdir -p "$PREFIX/glibc"
|
||||
|
||||
# Download glibc package directly from pacman repo (no pacman needed)
|
||||
# The gpkg.db tells us: glibc-2.42-0-aarch64.pkg.tar.xz (~9.7MB)
|
||||
echo " Downloading glibc (~10MB)..."
|
||||
install_pacman_pkg "glibc-2.42-0-aarch64.pkg.tar.xz" "$PREFIX/glibc"
|
||||
|
||||
# gcc-libs-glibc provides libstdc++.so.6 needed by Node.js (~24MB)
|
||||
echo " Downloading gcc-libs (~24MB)..."
|
||||
install_pacman_pkg "gcc-libs-glibc-14.2.1-1-aarch64.pkg.tar.xz" "$PREFIX/glibc"
|
||||
|
||||
# Verify linker
|
||||
if [ ! -f "$GLIBC_LDSO" ]; then
|
||||
echo -e " ${RED}✗${NC} glibc linker not found at $GLIBC_LDSO"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$GLIBC_LDSO"
|
||||
mkdir -p "$OCA_DIR"
|
||||
touch "$OCA_DIR/.glibc-arch"
|
||||
echo -e " ${GREEN}✓${NC} glibc installed"
|
||||
fi
|
||||
|
||||
# Install supplementary glibc libraries (libcap etc.)
|
||||
_GLIBC_LIBS_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/patches/glibc-libs"
|
||||
if [ -d "$PREFIX/glibc/lib" ] && [ -d "$_GLIBC_LIBS_SRC" ]; then
|
||||
for _lib in "$_GLIBC_LIBS_SRC"/*.so.*; do
|
||||
[ -f "$_lib" ] || continue
|
||||
_fn=$(basename "$_lib")
|
||||
if [ ! -f "$PREFIX/glibc/lib/$_fn" ]; then
|
||||
cp "$_lib" "$PREFIX/glibc/lib/$_fn"
|
||||
_sn=$(echo "$_fn" | sed -E 's/^(lib[^.]+\.so\.[0-9]+)\..*/\1/')
|
||||
[ "$_sn" != "$_fn" ] && ln -sf "$_fn" "$PREFIX/glibc/lib/$_sn"
|
||||
echo -e " ${GREEN}✓${NC} Installed $_sn"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Ensure glibc /etc/hosts exists (localhost resolution)
|
||||
if [ -d "$PREFIX/glibc/etc" ] && [ ! -f "$PREFIX/glibc/etc/hosts" ]; then
|
||||
cat > "$PREFIX/glibc/etc/hosts" <<'HOSTS'
|
||||
127.0.0.1 localhost localhost.localdomain
|
||||
::1 localhost ip6-localhost ip6-loopback
|
||||
HOSTS
|
||||
echo -e " ${GREEN}✓${NC} Created glibc /etc/hosts"
|
||||
fi
|
||||
echo -e " Linker: $GLIBC_LDSO"
|
||||
|
||||
# ─── [3/7] Node.js ──────────────────────────
|
||||
echo -e "▸ ${YELLOW}[3/7]${NC} Installing Node.js v${NODE_VERSION}..."
|
||||
mkdir -p "$NODE_DIR/bin"
|
||||
|
||||
_NODE_CMD=""
|
||||
if [ -x "$BIN_DIR/node" ]; then _NODE_CMD="$BIN_DIR/node"
|
||||
elif [ -f "$NODE_DIR/bin/node.real" ] && [ -x "$NODE_DIR/bin/node" ]; then _NODE_CMD="$NODE_DIR/bin/node"
|
||||
fi
|
||||
if [ -n "$_NODE_CMD" ] && "$_NODE_CMD" --version &>/dev/null; then
|
||||
INSTALLED_VER=$("$_NODE_CMD" --version 2>/dev/null || echo "")
|
||||
echo -e " ${GREEN}[SKIP]${NC} Node.js already installed ($INSTALLED_VER)"
|
||||
# Repair wrappers in BIN_DIR (safe from npm overwrites)
|
||||
mkdir -p "$BIN_DIR"
|
||||
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then
|
||||
cat > "$BIN_DIR/npm" << NPMWRAP
|
||||
#!$PREFIX/bin/bash
|
||||
"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@"
|
||||
_npm_exit=\$?
|
||||
case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*)
|
||||
_oc_bin="$PREFIX/bin/openclaw"
|
||||
_oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs"
|
||||
if [ -f "\$_oc_mjs" ]; then
|
||||
[ -L "\$_oc_bin" ] && rm -f "\$_oc_bin"
|
||||
printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin"
|
||||
chmod +x "\$_oc_bin"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix)
|
||||
case "\$*" in *codex-cli-termux*)
|
||||
_codex_bin="$PREFIX/bin/codex"
|
||||
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
|
||||
if [ -f "\$_codex_pkg/codex.bin" ]; then
|
||||
[ -L "\$_codex_bin" ] && rm -f "\$_codex_bin"
|
||||
printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin"
|
||||
chmod +x "\$_codex_bin"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Fix shebangs in npm global CLI entry points after global install
|
||||
case "\$*" in *-g*|*--global*)
|
||||
for _js in $PREFIX/lib/node_modules/*/bin/*.js \
|
||||
$PREFIX/lib/node_modules/@*/*/bin/*.js; do
|
||||
[ -f "\$_js" ] || continue
|
||||
head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue
|
||||
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js"
|
||||
done
|
||||
;;
|
||||
esac
|
||||
exit \$_npm_exit
|
||||
NPMWRAP
|
||||
chmod +x "$BIN_DIR/npm"
|
||||
fi
|
||||
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then
|
||||
cat > "$BIN_DIR/npx" << NPXWRAP
|
||||
#!$PREFIX/bin/bash
|
||||
exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@"
|
||||
NPXWRAP
|
||||
chmod +x "$BIN_DIR/npx"
|
||||
fi
|
||||
if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then
|
||||
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack"
|
||||
fi
|
||||
else
|
||||
NODE_TAR="node-v${NODE_VERSION}-linux-arm64"
|
||||
echo " Downloading Node.js v${NODE_VERSION} (~25MB)..."
|
||||
curl -fSL --max-time 300 \
|
||||
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TAR}.tar.xz" \
|
||||
-o "$TMPDIR/${NODE_TAR}.tar.xz"
|
||||
|
||||
echo " Extracting..."
|
||||
tar -xJf "$TMPDIR/${NODE_TAR}.tar.xz" -C "$NODE_DIR" --strip-components=1
|
||||
|
||||
# Move original binary → node.real
|
||||
if [ -f "$NODE_DIR/bin/node" ] && [ ! -L "$NODE_DIR/bin/node" ]; then
|
||||
mv "$NODE_DIR/bin/node" "$NODE_DIR/bin/node.real"
|
||||
fi
|
||||
|
||||
rm -f "$TMPDIR/${NODE_TAR}.tar.xz"
|
||||
|
||||
# Create grun-style node wrapper in BIN_DIR (safe from npm overwrites)
|
||||
mkdir -p "$BIN_DIR"
|
||||
cat > "$BIN_DIR/node" << WRAPPER
|
||||
#!${PREFIX}/bin/bash
|
||||
[ -n "\$LD_PRELOAD" ] && export _OA_ORIG_LD_PRELOAD="\$LD_PRELOAD"
|
||||
unset LD_PRELOAD
|
||||
export _OA_WRAPPER_PATH="$BIN_DIR/node"
|
||||
_OA_COMPAT="\$HOME/.openclaw-android/patches/glibc-compat.js"
|
||||
if [ -f "\$_OA_COMPAT" ]; then
|
||||
case "\${NODE_OPTIONS:-}" in
|
||||
*"\$_OA_COMPAT"*) ;;
|
||||
*) export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }-r \$_OA_COMPAT" ;;
|
||||
esac
|
||||
fi
|
||||
_LEADING_OPTS=""
|
||||
_COUNT=0
|
||||
for _arg in "\$@"; do
|
||||
case "\$_arg" in --*) _COUNT=\$((_COUNT + 1)) ;; *) break ;; esac
|
||||
done
|
||||
if [ \$_COUNT -gt 0 ] && [ \$_COUNT -lt \$# ]; then
|
||||
while [ \$# -gt 0 ]; do
|
||||
case "\$1" in
|
||||
--*) _LEADING_OPTS="\${_LEADING_OPTS:+\$_LEADING_OPTS }\$1"; shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }\$_LEADING_OPTS"
|
||||
fi
|
||||
exec "$GLIBC_LDSO" --library-path "$PREFIX/glibc/lib" "$NODE_DIR/bin/node.real" "\$@"
|
||||
WRAPPER
|
||||
chmod +x "$BIN_DIR/node"
|
||||
|
||||
# Create npm/npx wrappers in BIN_DIR
|
||||
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then
|
||||
cat > "$BIN_DIR/npm" << NPMWRAP
|
||||
#!$PREFIX/bin/bash
|
||||
"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@"
|
||||
_npm_exit=\$?
|
||||
case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*)
|
||||
_oc_bin="$PREFIX/bin/openclaw"
|
||||
_oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs"
|
||||
if [ -f "\$_oc_mjs" ]; then
|
||||
[ -L "\$_oc_bin" ] && rm -f "\$_oc_bin"
|
||||
printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin"
|
||||
chmod +x "\$_oc_bin"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix)
|
||||
case "\$*" in *codex-cli-termux*)
|
||||
_codex_bin="$PREFIX/bin/codex"
|
||||
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
|
||||
if [ -f "\$_codex_pkg/codex.bin" ]; then
|
||||
[ -L "\$_codex_bin" ] && rm -f "\$_codex_bin"
|
||||
printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin"
|
||||
chmod +x "\$_codex_bin"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Fix shebangs in npm global CLI entry points after global install
|
||||
case "\$*" in *-g*|*--global*)
|
||||
for _js in $PREFIX/lib/node_modules/*/bin/*.js \
|
||||
$PREFIX/lib/node_modules/@*/*/bin/*.js; do
|
||||
[ -f "\$_js" ] || continue
|
||||
head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue
|
||||
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js"
|
||||
done
|
||||
;;
|
||||
esac
|
||||
exit \$_npm_exit
|
||||
NPMWRAP
|
||||
chmod +x "$BIN_DIR/npm"
|
||||
fi
|
||||
if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then
|
||||
cat > "$BIN_DIR/npx" << NPXWRAP
|
||||
#!$PREFIX/bin/bash
|
||||
exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@"
|
||||
NPXWRAP
|
||||
chmod +x "$BIN_DIR/npx"
|
||||
fi
|
||||
# corepack: shebang patch only
|
||||
if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then
|
||||
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack"
|
||||
fi
|
||||
|
||||
# Configure npm
|
||||
export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH"
|
||||
"$BIN_DIR/npm" config set script-shell "$PREFIX/bin/sh" 2>/dev/null || true
|
||||
|
||||
# Verify
|
||||
NODE_VER=$("$BIN_DIR/node" --version 2>/dev/null) || {
|
||||
echo -e " ${RED}✗${NC} Node.js verification failed"
|
||||
exit 1
|
||||
}
|
||||
echo -e " ${GREEN}✓${NC} Node.js $NODE_VER (glibc)"
|
||||
fi
|
||||
|
||||
# ─── [4/7] OpenClaw ─────────────────────────
|
||||
echo -e "▸ ${YELLOW}[4/7]${NC} Installing OpenClaw..."
|
||||
export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH"
|
||||
|
||||
# Auto-detect GitHub mirror for restricted networks
|
||||
resolve_repo_base
|
||||
|
||||
# Auto-detect npm registry (session-scoped via NPM_CONFIG_REGISTRY env var).
|
||||
# Does NOT write to ~/.npmrc — see CHANGELOG v1.0.24.
|
||||
resolve_npm_registry || true
|
||||
|
||||
# Force git to use HTTPS instead of SSH (no SSH client available).
|
||||
# Preserve any existing user .gitconfig (name, email, aliases); only set our keys.
|
||||
touch "$HOME/.gitconfig"
|
||||
git config --global http.sslCAInfo "$PREFIX/etc/tls/cert.pem"
|
||||
git config --global --unset-all url."https://github.com/".insteadOf 2>/dev/null || true
|
||||
git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
git config --global --add url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
# Git wrapper: replace $PREFIX/bin/git with a wrapper that:
|
||||
# 1. Strips --recurse-submodules (triggers open() on hardcoded com.termux path)
|
||||
# 2. Cleans existing target dirs before clone (npm's withTempDir creates dir first)
|
||||
# npm caches git path at module load via which.sync('git'), so we must replace the binary.
|
||||
# $PREFIX/bin/git is a symlink -> ../libexec/git-core/git (the real ELF binary).
|
||||
REAL_GIT="$PREFIX/libexec/git-core/git"
|
||||
if [ -f "$REAL_GIT" ] && [ ! -f "$PREFIX/bin/git.wrapper-installed" ]; then
|
||||
echo " Installing git wrapper (strips --recurse-submodules)..."
|
||||
rm -f "$PREFIX/bin/git"
|
||||
# Write shebang with absolute path (no LD_PRELOAD = no /bin/bash rewrite)
|
||||
echo "#!${PREFIX}/bin/bash" > "$PREFIX/bin/git"
|
||||
cat >> "$PREFIX/bin/git" << 'ENDWRAP'
|
||||
filtered=()
|
||||
is_clone=false
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--recurse-submodules) ;;
|
||||
clone) is_clone=true; filtered+=("$a") ;;
|
||||
*) filtered+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
if $is_clone; then
|
||||
for a in "${filtered[@]}"; do
|
||||
case "$a" in
|
||||
clone|--*|-*|http*|ssh*|git*|[0-9]) ;;
|
||||
*) [ -d "$a" ] && rm -rf "$a" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
ENDWRAP
|
||||
echo "exec \"$REAL_GIT\" \"\${filtered[@]}\"" >> "$PREFIX/bin/git"
|
||||
chmod +x "$PREFIX/bin/git"
|
||||
touch "$PREFIX/bin/git.wrapper-installed"
|
||||
echo -e " ${GREEN}\u2713${NC} git wrapper installed"
|
||||
else
|
||||
if [ -f "$PREFIX/bin/git.wrapper-installed" ]; then
|
||||
echo -e " ${GREEN}[SKIP]${NC} git wrapper already installed"
|
||||
else
|
||||
echo -e " ${RED}\u2717${NC} Real git not found at $REAL_GIT"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v openclaw &>/dev/null 2>&1; then
|
||||
OC_VER=$(openclaw --version 2>/dev/null || echo "unknown")
|
||||
echo -e " ${GREEN}[SKIP]${NC} OpenClaw already installed ($OC_VER)"
|
||||
else
|
||||
# Clean npm cache tmp dir (leftover from previous failed installs)
|
||||
rm -rf "$HOME/.npm/_cacache/tmp" 2>/dev/null || true
|
||||
npm install -g openclaw@latest --ignore-scripts 2>&1
|
||||
OC_VER=$(openclaw --version 2>/dev/null || echo "installed")
|
||||
echo -e " ${GREEN}✓${NC} OpenClaw $OC_VER"
|
||||
fi
|
||||
|
||||
# Restore optional/channel deps that --ignore-scripts skips.
|
||||
# Uses npm_config_ignore_scripts=true so sharp's native build doesn't block.
|
||||
OPENCLAW_DIR="$(npm root -g)/openclaw"
|
||||
if [ -d "$OPENCLAW_DIR" ]; then
|
||||
echo " Restoring optional dependencies..."
|
||||
(cd "$OPENCLAW_DIR" && npm_config_ignore_scripts=true node scripts/postinstall-bundled-plugins.mjs 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
# Install clawdhub (skill manager)
|
||||
echo " Installing clawdhub..."
|
||||
if npm install -g clawdhub --no-fund --no-audit; then
|
||||
echo -e " ${GREEN}✓${NC} clawdhub installed"
|
||||
CLAWHUB_DIR="$(npm root -g)/clawdhub"
|
||||
if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then
|
||||
echo " Installing undici dependency for clawdhub..."
|
||||
(cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit) || true
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)"
|
||||
fi
|
||||
|
||||
# PyYAML (for .skill packaging)
|
||||
command -v python &>/dev/null && { python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true; }
|
||||
|
||||
# Run openclaw update (builds native modules like sharp)
|
||||
echo " Running: openclaw update (this may take 5-10 minutes)..."
|
||||
openclaw update || true
|
||||
|
||||
# ─── [5/7] Patches ──────────────────────────
|
||||
echo -e "▸ ${YELLOW}[5/7]${NC} Applying patches..."
|
||||
|
||||
# Copy glibc-compat.js from project (bundled alongside this script)
|
||||
COMPAT_SRC="$(dirname "$0")/glibc-compat.js"
|
||||
if [ -f "$COMPAT_SRC" ]; then
|
||||
cp "$COMPAT_SRC" "$OCA_DIR/patches/glibc-compat.js"
|
||||
else
|
||||
# Fallback: download from repo
|
||||
curl -fsSL "$REPO_BASE/patches/glibc-compat.js" \
|
||||
-o "$OCA_DIR/patches/glibc-compat.js" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# systemctl stub
|
||||
printf '#!%s/bin/bash\nexit 0\n' "$PREFIX" > "$PREFIX/bin/systemctl"
|
||||
chmod +x "$PREFIX/bin/systemctl"
|
||||
|
||||
# sharp WASM fallback (prebuilt native binaries don't load on Android)
|
||||
if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then
|
||||
if ! node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
|
||||
echo " Installing sharp WebAssembly runtime..."
|
||||
(cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3) || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Patches applied"
|
||||
|
||||
# ─── [6/7] Environment ──────────────────────
|
||||
echo -e "▸ ${YELLOW}[6/7]${NC} Configuring environment..."
|
||||
|
||||
cat > "$HOME/.bashrc" << BASHRC
|
||||
# OpenClaw Android environment
|
||||
export PREFIX="$PREFIX"
|
||||
export HOME="$HOME"
|
||||
export TMPDIR="$TMPDIR"
|
||||
export PATH="$BIN_DIR:$NODE_DIR/bin:\$PREFIX/bin:\$PATH"
|
||||
export LD_LIBRARY_PATH="$PREFIX/lib"
|
||||
export LD_PRELOAD="$PREFIX/lib/libtermux-exec.so"
|
||||
export TERMUX__PREFIX="$PREFIX"
|
||||
export TERMUX_PREFIX="$PREFIX"
|
||||
export LANG=en_US.UTF-8
|
||||
export TERM=xterm-256color
|
||||
export OA_GLIBC=1
|
||||
export CONTAINER=1
|
||||
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
|
||||
export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem"
|
||||
export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem"
|
||||
export GIT_CONFIG_NOSYSTEM=1
|
||||
export GIT_EXEC_PATH="$PREFIX/libexec/git-core"
|
||||
export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates"
|
||||
export CLAWDHUB_WORKDIR="$HOME/.openclaw/workspace"
|
||||
export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
|
||||
# npm registry (auto-detected by OpenClaw Android, safe to override manually)
|
||||
[ -z "\${NPM_CONFIG_REGISTRY:-}" ] && [ -s "\$HOME/.openclaw-android/.npm-registry" ] && \\
|
||||
export NPM_CONFIG_REGISTRY="\$(cat "\$HOME/.openclaw-android/.npm-registry")"
|
||||
BASHRC
|
||||
|
||||
echo -e " ${GREEN}✓${NC} ~/.bashrc configured"
|
||||
|
||||
# oa CLI (enables oa --update, oa --backup, etc.)
|
||||
if curl -fsSL "$REPO_BASE/oa.sh" \
|
||||
-o "$PREFIX/bin/oa" 2>/dev/null; then
|
||||
chmod +x "$PREFIX/bin/oa"
|
||||
echo -e " ${GREEN}✓${NC} oa CLI installed"
|
||||
else
|
||||
echo -e " ${YELLOW}[WARN]${NC} oa CLI installation failed (non-critical)"
|
||||
fi
|
||||
|
||||
# ─── [7/7] Optional Tools ──────────────────
|
||||
TOOL_CONF="$OCA_DIR/tool-selections.conf"
|
||||
if [ -f "$TOOL_CONF" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$TOOL_CONF"
|
||||
|
||||
HAS_TOOLS=false
|
||||
for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_CODE_SERVER INSTALL_PLAYWRIGHT INSTALL_CLAUDE_CODE INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do
|
||||
eval "val=\${$var:-false}"
|
||||
# shellcheck disable=SC2154
|
||||
[ "$val" = "true" ] && HAS_TOOLS=true && break
|
||||
done
|
||||
|
||||
if $HAS_TOOLS; then
|
||||
echo -e "▸ ${YELLOW}[7/7]${NC} Installing optional tools..."
|
||||
|
||||
# Helper: install .deb with direct dependencies
|
||||
install_with_deps() {
|
||||
local pkg="$1"
|
||||
local deps
|
||||
deps=$(awk -v pkg="$pkg" '
|
||||
/^Package: / { found = ($2 == pkg) }
|
||||
found && /^Depends:/ {
|
||||
gsub(/^Depends: /, "")
|
||||
gsub(/ *\([^)]*\)/, "")
|
||||
gsub(/, /, "\n")
|
||||
print; exit
|
||||
}
|
||||
' "$PACKAGES_FILE")
|
||||
while IFS= read -r dep; do
|
||||
dep=$(echo "$dep" | tr -d ' ')
|
||||
[ -z "$dep" ] && continue
|
||||
local dep_file
|
||||
dep_file=$(get_deb_filename "$dep")
|
||||
if [ -n "$dep_file" ]; then install_deb "$dep_file" 2>/dev/null || true; fi
|
||||
done <<< "$deps"
|
||||
local filename
|
||||
filename=$(get_deb_filename "$pkg")
|
||||
[ -n "$filename" ] && install_deb "$filename"
|
||||
}
|
||||
|
||||
# Termux packages
|
||||
[ "${INSTALL_TMUX:-false}" = "true" ] && {
|
||||
echo " Installing tmux..."
|
||||
install_with_deps tmux
|
||||
echo -e " ${GREEN}✓${NC} tmux"
|
||||
}
|
||||
[ "${INSTALL_TTYD:-false}" = "true" ] && {
|
||||
echo " Installing ttyd..."
|
||||
install_with_deps ttyd
|
||||
echo -e " ${GREEN}✓${NC} ttyd"
|
||||
}
|
||||
[ "${INSTALL_DUFS:-false}" = "true" ] && {
|
||||
echo " Installing dufs..."
|
||||
install_with_deps dufs
|
||||
echo -e " ${GREEN}✓${NC} dufs"
|
||||
}
|
||||
|
||||
# npm packages
|
||||
[ "${INSTALL_CODE_SERVER:-false}" = "true" ] && {
|
||||
echo " Installing code-server (this may take a while)..."
|
||||
npm install -g code-server 2>&1 || true
|
||||
echo -e " ${GREEN}✓${NC} code-server"
|
||||
}
|
||||
[ "${INSTALL_PLAYWRIGHT:-false}" = "true" ] && {
|
||||
echo " Installing Playwright (playwright-core)..."
|
||||
npm install -g playwright-core 2>&1 || true
|
||||
# Set Playwright environment variables if Chromium is available
|
||||
CHROMIUM_BIN=""
|
||||
for bin in "$PREFIX/bin/chromium-browser" "$PREFIX/bin/chromium"; do
|
||||
[ -x "$bin" ] && CHROMIUM_BIN="$bin" && break
|
||||
done
|
||||
if [ -n "$CHROMIUM_BIN" ]; then
|
||||
PW_MARKER_START="# >>> Playwright >>>"
|
||||
PW_MARKER_END="# <<< Playwright <<<"
|
||||
if ! grep -qF "$PW_MARKER_START" "$HOME/.bashrc"; then
|
||||
cat >> "$HOME/.bashrc" << PWENV
|
||||
|
||||
${PW_MARKER_START}
|
||||
export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM_BIN"
|
||||
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
${PW_MARKER_END}
|
||||
PWENV
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} Playwright (env: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$CHROMIUM_BIN)"
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Playwright (install Chromium later via 'oa --install' for full setup)"
|
||||
fi
|
||||
}
|
||||
[ "${INSTALL_CLAUDE_CODE:-false}" = "true" ] && {
|
||||
echo " Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code 2>&1 || true
|
||||
echo -e " ${GREEN}✓${NC} Claude Code"
|
||||
}
|
||||
[ "${INSTALL_GEMINI_CLI:-false}" = "true" ] && {
|
||||
echo " Installing Gemini CLI..."
|
||||
npm install -g @google/gemini-cli 2>&1 || true
|
||||
echo -e " ${GREEN}✓${NC} Gemini CLI"
|
||||
}
|
||||
[ "${INSTALL_CODEX_CLI:-false}" = "true" ] && {
|
||||
echo " Installing Codex CLI (Termux)..."
|
||||
npm install -g @mmmbuto/codex-cli-termux 2>&1 || true
|
||||
# Create codex CLI wrapper (DioNanos fork launcher fix)
|
||||
_codex_bin="$PREFIX/bin/codex"
|
||||
_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin"
|
||||
if [ -f "$_codex_pkg/codex.bin" ]; then
|
||||
[ -L "$_codex_bin" ] && rm -f "$_codex_bin"
|
||||
printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \
|
||||
"$PREFIX" "$_codex_pkg" > "$_codex_bin"
|
||||
chmod +x "$_codex_bin"
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} Codex CLI (Termux)"
|
||||
}
|
||||
|
||||
# Fix shebangs in npm global CLIs (kept in sync with scripts/lib.sh fix_npm_global_shebangs())
|
||||
for _js in "$PREFIX/lib/node_modules"/*/bin/*.js \
|
||||
"$PREFIX/lib/node_modules"/@*/*/bin/*.js; do
|
||||
[ -f "$_js" ] || continue
|
||||
head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue
|
||||
sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$_js"
|
||||
done
|
||||
else
|
||||
echo -e "▸ ${YELLOW}[7/7]${NC} No optional tools selected"
|
||||
fi
|
||||
else
|
||||
echo -e "▸ ${YELLOW}[7/7]${NC} No optional tools selected"
|
||||
fi
|
||||
|
||||
# ─── Cleanup ────────────────────────────────
|
||||
rm -rf "$DEB_DIR" "$PKG_DIR" "$PACKAGES_FILE" "$TMPDIR/gpkg.db" 2>/dev/null || true
|
||||
|
||||
# ─── Done ────────────────────────────────────
|
||||
touch "$MARKER"
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo -e " ${GREEN}✓ Installation complete!${NC}"
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Loading environment..."
|
||||
source "$HOME/.bashrc"
|
||||
echo ""
|
||||
echo " Starting OpenClaw onboard..."
|
||||
echo ""
|
||||
openclaw onboard
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108">
|
||||
<!-- Background circle -->
|
||||
<circle cx="54" cy="54" r="54" fill="#111827"/>
|
||||
<!-- 4 claw scratch marks, rotated diagonally -->
|
||||
<g transform="rotate(-25 54 54)">
|
||||
<!-- Scratch 1 (outer left, shorter) -->
|
||||
<path fill="#FFFFFF" d="M31,22 C28,44,28,64,31,86 C34,64,34,44,31,22Z"/>
|
||||
<!-- Scratch 2 (inner left, longer) -->
|
||||
<path fill="#FFFFFF" d="M43,16 C40,40,40,64,43,92 C46,64,46,40,43,16Z"/>
|
||||
<!-- Scratch 3 (inner right, longer) -->
|
||||
<path fill="#FFFFFF" d="M55,16 C52,40,52,64,55,92 C58,64,58,40,55,16Z"/>
|
||||
<!-- Scratch 4 (outer right, shorter) -->
|
||||
<path fill="#FFFFFF" d="M67,22 C64,44,64,64,67,86 C70,64,70,44,67,22Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<title>OpenClaw</title>
|
||||
<script type="module" crossorigin src="./assets/index-D_agBQQF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BrlC3beh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- Native event bridge receiver (§2.8) — must load before React -->
|
||||
<script>
|
||||
window.__oc = {
|
||||
emit(type, data) {
|
||||
window.dispatchEvent(new CustomEvent('native:' + type, { detail: data }));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 120 120"><defs><linearGradient id="a" x1="0%" x2="100%" y1="0%" y2="100%"><stop offset="0%" stop-color="#ff4d4d"/><stop offset="100%" stop-color="#991b1b"/></linearGradient></defs><path fill="url(#a)" d="M60 10c-30 0-45 25-45 45s15 40 30 45v10h10v-10s5 2 10 0v10h10v-10c15-5 30-25 30-45S90 10 60 10"/><path fill="url(#a)" d="M20 45C5 40 0 50 5 60s15 5 20-5c3-7 0-10-5-10M100 45c15-5 20 5 15 15s-15 5-20-5c-3-7 0-10 5-10"/><path stroke="#ff4d4d" stroke-linecap="round" stroke-width="3" d="M45 15Q35 5 30 8M75 15Q85 5 90 8"/><circle cx="45" cy="35" r="6" fill="#050810"/><circle cx="75" cy="35" r="6" fill="#050810"/><circle cx="46" cy="34" r="2.5" fill="#00e5cc"/><circle cx="76" cy="34" r="2.5" fill="#00e5cc"/></svg>
|
||||
|
After Width: | Height: | Size: 782 B |
@@ -0,0 +1,48 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Centralized logging wrapper.
|
||||
* All app code should use AppLogger instead of android.util.Log directly.
|
||||
* detekt ForbiddenMethodCall enforces this rule for new code.
|
||||
*/
|
||||
@Suppress("ForbiddenMethodCall")
|
||||
object AppLogger {
|
||||
fun v(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = Log.v(tag, message)
|
||||
|
||||
fun d(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = Log.d(tag, message)
|
||||
|
||||
fun i(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = Log.i(tag, message)
|
||||
|
||||
fun w(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = Log.w(tag, message)
|
||||
|
||||
fun w(
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable,
|
||||
) = Log.w(tag, message, throwable)
|
||||
|
||||
fun e(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = Log.e(tag, message)
|
||||
|
||||
fun e(
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable,
|
||||
) = Log.e(tag, message, throwable)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Starts MainActivity on device boot so terminal sessions
|
||||
* and openclaw gateway can auto-launch.
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "BootReceiver"
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
AppLogger.i(TAG, "Boot completed — launching OpenClaw")
|
||||
val launchIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
putExtra("from_boot", true)
|
||||
}
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Manages Termux bootstrap download, extraction, and configuration.
|
||||
* Phase 0: extracts from assets. Phase 1+: downloads from network.
|
||||
* Based on AnyClaw BootstrapInstaller.kt pattern (§2.2.1).
|
||||
*/
|
||||
class BootstrapManager(
|
||||
private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BootstrapManager"
|
||||
private const val PROGRESS_PREPARING = 0.05f
|
||||
private const val PROGRESS_DOWNLOADING = 0.10f
|
||||
private const val PROGRESS_EXTRACTING = 0.30f
|
||||
private const val PROGRESS_CONFIGURING = 0.60f
|
||||
private const val ELF_MAGIC_SIZE = 4
|
||||
private val ELF_SIGNATURE = byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())
|
||||
private const val SYMLINK_SEPARATOR = "←"
|
||||
private const val SYMLINK_PARTS_COUNT = 2
|
||||
}
|
||||
|
||||
val prefixDir = File(context.filesDir, "usr")
|
||||
val homeDir = File(context.filesDir, "home")
|
||||
val tmpDir = File(context.filesDir, "tmp")
|
||||
val wwwDir = File(prefixDir, "share/openclaw-app/www")
|
||||
private val stagingDir = File(context.filesDir, "usr-staging")
|
||||
|
||||
fun isInstalled(): Boolean = prefixDir.resolve("bin/sh").exists()
|
||||
|
||||
fun needsPostSetup(): Boolean {
|
||||
val marker = File(homeDir, ".openclaw-android/.post-setup-done")
|
||||
return isInstalled() && !marker.exists()
|
||||
}
|
||||
|
||||
val postSetupScript: File
|
||||
get() = File(homeDir, ".openclaw-android/post-setup.sh")
|
||||
|
||||
data class SetupStatus(
|
||||
val bootstrapInstalled: Boolean,
|
||||
val runtimeInstalled: Boolean,
|
||||
val wwwInstalled: Boolean,
|
||||
val platformInstalled: Boolean,
|
||||
)
|
||||
|
||||
fun getStatus(): SetupStatus =
|
||||
SetupStatus(
|
||||
bootstrapInstalled = isInstalled(),
|
||||
runtimeInstalled = prefixDir.resolve("bin/node").exists(),
|
||||
wwwInstalled = wwwDir.resolve("index.html").exists(),
|
||||
platformInstalled = File(homeDir, ".openclaw-android/.post-setup-done").exists(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Full setup flow. Reports progress via callback (0.0–1.0).
|
||||
*/
|
||||
suspend fun startSetup(onProgress: (Float, String) -> Unit) =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Clean up any incomplete previous attempt before starting
|
||||
if (stagingDir.exists()) {
|
||||
AppLogger.i(TAG, "Removing incomplete staging dir from previous attempt")
|
||||
stagingDir.deleteRecursively()
|
||||
}
|
||||
if (isInstalled()) {
|
||||
// Bootstrap exists but setup is incomplete — wipe and reinstall
|
||||
AppLogger.i(TAG, "Incomplete bootstrap detected, reinstalling...")
|
||||
prefixDir.deleteRecursively()
|
||||
}
|
||||
|
||||
// Step 1: Download or extract bootstrap
|
||||
onProgress(PROGRESS_PREPARING, "Preparing bootstrap...")
|
||||
val zipStream = getBootstrapStream(onProgress)
|
||||
|
||||
// Step 2: Extract bootstrap
|
||||
onProgress(PROGRESS_EXTRACTING, "Extracting bootstrap...")
|
||||
extractBootstrap(zipStream)
|
||||
|
||||
// Step 3: Fix paths and configure
|
||||
onProgress(PROGRESS_CONFIGURING, "Configuring environment...")
|
||||
fixTermuxPaths(stagingDir)
|
||||
configureApt(stagingDir)
|
||||
|
||||
// Step 4: Atomic rename
|
||||
stagingDir.renameTo(prefixDir)
|
||||
setupDirectories()
|
||||
copyAssetScripts()
|
||||
syncWwwFromAssets()
|
||||
setupTermuxExec()
|
||||
|
||||
onProgress(1f, "Setup complete")
|
||||
}
|
||||
|
||||
// --- Bootstrap source ---
|
||||
|
||||
private suspend fun getBootstrapStream(onProgress: (Float, String) -> Unit): InputStream {
|
||||
// Phase 0: Try assets first
|
||||
try {
|
||||
return context.assets.open("bootstrap-aarch64.zip")
|
||||
} catch (_: Exception) {
|
||||
// Phase 1: Download from network
|
||||
}
|
||||
|
||||
onProgress(PROGRESS_DOWNLOADING, "Downloading bootstrap...")
|
||||
val url = UrlResolver(context).getBootstrapUrl()
|
||||
return URL(url).openStream()
|
||||
}
|
||||
|
||||
// --- Extraction ---
|
||||
|
||||
private fun extractBootstrap(inputStream: InputStream) {
|
||||
stagingDir.deleteRecursively()
|
||||
stagingDir.mkdirs()
|
||||
|
||||
ZipInputStream(inputStream).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
processZipEntry(zip, entry)
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processZipEntry(
|
||||
zip: ZipInputStream,
|
||||
entry: java.util.zip.ZipEntry,
|
||||
) {
|
||||
if (entry.name == "SYMLINKS.txt") {
|
||||
processSymlinks(zip, stagingDir)
|
||||
} else if (!entry.isDirectory) {
|
||||
val file = File(stagingDir, entry.name)
|
||||
file.parentFile?.mkdirs()
|
||||
file.outputStream().use { out -> zip.copyTo(out) }
|
||||
markExecutableIfNeeded(file, entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markExecutableIfNeeded(
|
||||
file: File,
|
||||
name: String,
|
||||
) {
|
||||
val knownExecutable =
|
||||
name.startsWith("bin/") ||
|
||||
name.startsWith("libexec/") ||
|
||||
name.startsWith("lib/apt/") ||
|
||||
name.startsWith("lib/bash/") ||
|
||||
name.endsWith(".so") ||
|
||||
name.contains(".so.")
|
||||
if (knownExecutable) {
|
||||
file.setExecutable(true)
|
||||
} else if (file.length() > ELF_MAGIC_SIZE && isElfBinary(file)) {
|
||||
file.setExecutable(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isElfBinary(file: File): Boolean =
|
||||
try {
|
||||
file.inputStream().use { fis ->
|
||||
val magic = ByteArray(ELF_MAGIC_SIZE)
|
||||
fis.read(magic) == ELF_MAGIC_SIZE &&
|
||||
magic.contentEquals(ELF_SIGNATURE)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Process SYMLINKS.txt: each line is "target←linkpath".
|
||||
* Replace com.termux paths with our package name.
|
||||
*/
|
||||
private fun processSymlinks(
|
||||
zip: ZipInputStream,
|
||||
targetDir: File,
|
||||
) {
|
||||
val content = zip.bufferedReader().readText()
|
||||
val ourPackage = context.packageName
|
||||
content
|
||||
.lines()
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(SYMLINK_SEPARATOR)
|
||||
if (parts.size == SYMLINK_PARTS_COUNT) parts else null
|
||||
}.forEach { parts ->
|
||||
val symlinkTarget = parts[0].trim().replace("com.termux", ourPackage)
|
||||
val symlinkPath = parts[1].trim()
|
||||
val linkFile = File(targetDir, symlinkPath)
|
||||
linkFile.parentFile?.mkdirs()
|
||||
try {
|
||||
Os.symlink(symlinkTarget, linkFile.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to create symlink: $symlinkPath -> $symlinkTarget", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Path fixing (§2.2.2) ---
|
||||
|
||||
private fun fixTermuxPaths(dir: File) {
|
||||
val ourPackage = context.packageName
|
||||
val oldPrefix = "/data/data/com.termux/files/usr"
|
||||
val newPrefix = prefixDir.absolutePath
|
||||
|
||||
// Fix dpkg status database
|
||||
fixTextFile(dir.resolve("var/lib/dpkg/status"), oldPrefix, newPrefix)
|
||||
|
||||
// Fix dpkg info files
|
||||
val dpkgInfoDir = dir.resolve("var/lib/dpkg/info")
|
||||
if (dpkgInfoDir.isDirectory) {
|
||||
dpkgInfoDir.listFiles()?.filter { it.name.endsWith(".list") }?.forEach { file ->
|
||||
fixTextFile(file, "com.termux", ourPackage)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix git scripts shebangs
|
||||
val gitCoreDir = dir.resolve("libexec/git-core")
|
||||
if (gitCoreDir.isDirectory) {
|
||||
gitCoreDir.listFiles()?.forEach { file ->
|
||||
if (file.isFile && !file.name.contains(".")) {
|
||||
fixTextFile(file, oldPrefix, newPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixTextFile(
|
||||
file: File,
|
||||
oldText: String,
|
||||
newText: String,
|
||||
) {
|
||||
if (!file.exists() || !file.isFile) return
|
||||
try {
|
||||
val content = file.readText()
|
||||
if (content.contains(oldText)) {
|
||||
file.writeText(content.replace(oldText, newText))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to fix paths in ${file.name}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- apt configuration (§2.2.3) ---
|
||||
|
||||
private fun configureApt(dir: File) {
|
||||
val prefix = prefixDir.absolutePath
|
||||
val ourPackage = context.packageName
|
||||
|
||||
// sources.list: HTTPS→HTTP downgrade + package name fix
|
||||
val sourcesList = dir.resolve("etc/apt/sources.list")
|
||||
if (sourcesList.exists()) {
|
||||
sourcesList.writeText(
|
||||
sourcesList
|
||||
.readText()
|
||||
.replace("https://", "http://")
|
||||
.replace("com.termux", ourPackage),
|
||||
)
|
||||
}
|
||||
|
||||
// apt.conf: full rewrite with correct paths
|
||||
val aptConf = dir.resolve("etc/apt/apt.conf")
|
||||
aptConf.parentFile?.mkdirs()
|
||||
// Create directories needed by apt and dpkg
|
||||
dir.resolve("etc/apt/apt.conf.d").mkdirs()
|
||||
dir.resolve("etc/apt/preferences.d").mkdirs()
|
||||
dir.resolve("etc/dpkg/dpkg.cfg.d").mkdirs()
|
||||
dir.resolve("var/cache/apt").mkdirs()
|
||||
dir.resolve("var/log/apt").mkdirs()
|
||||
aptConf.writeText(
|
||||
"""
|
||||
Dir "/";
|
||||
Dir::State "$prefix/var/lib/apt/";
|
||||
Dir::State::status "$prefix/var/lib/dpkg/status";
|
||||
Dir::Cache "$prefix/var/cache/apt/";
|
||||
Dir::Log "$prefix/var/log/apt/";
|
||||
Dir::Etc "$prefix/etc/apt/";
|
||||
Dir::Etc::SourceList "$prefix/etc/apt/sources.list";
|
||||
Dir::Etc::SourceParts "";
|
||||
Dir::Bin::dpkg "$prefix/bin/dpkg";
|
||||
Dir::Bin::Methods "$prefix/lib/apt/methods/";
|
||||
Dir::Bin::apt-key "$prefix/bin/apt-key";
|
||||
Dpkg::Options:: "--force-configure-any";
|
||||
Dpkg::Options:: "--force-bad-path";
|
||||
Dpkg::Options:: "--instdir=$prefix";
|
||||
Dpkg::Options:: "--admindir=$prefix/var/lib/dpkg";
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
APT::Get::AllowUnauthenticated "true";
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Setup helpers ---
|
||||
|
||||
private fun setupDirectories() {
|
||||
homeDir.mkdirs()
|
||||
tmpDir.mkdirs()
|
||||
wwwDir.mkdirs()
|
||||
File(homeDir, ".openclaw-android/patches").mkdirs()
|
||||
}
|
||||
|
||||
private fun setupTermuxExec() {
|
||||
// libtermux-exec.so is included in bootstrap.
|
||||
// It intercepts execve() to rewrite /data/data/com.termux paths (§2.2.4).
|
||||
// However, it does NOT intercept open()/opendir() calls, so binaries with
|
||||
// hardcoded config paths (dpkg, bash) need wrapper scripts.
|
||||
AppLogger.i(TAG, "Bootstrap installed at ${prefixDir.absolutePath}")
|
||||
|
||||
// Create dpkg wrapper that handles confdir permission errors.
|
||||
// The bootstrap dpkg has /data/data/com.termux/.../etc/dpkg/ hardcoded.
|
||||
// Since libtermux-exec only rewrites execve() paths, not open() paths,
|
||||
// dpkg fails on opendir() of the old com.termux config directory.
|
||||
// The wrapper captures stderr and returns success if confdir is the only error.
|
||||
val dpkgBin = File(prefixDir, "bin/dpkg")
|
||||
val dpkgReal = File(prefixDir, "bin/dpkg.real")
|
||||
if (dpkgBin.exists() && (!dpkgReal.exists() || !dpkgBin.readText().contains("export PATH"))) {
|
||||
if (!dpkgReal.exists()) dpkgBin.renameTo(dpkgReal)
|
||||
val d = "$" // dollar sign for shell script
|
||||
val realPath = dpkgReal.absolutePath
|
||||
val wrapperContent = """#!/bin/bash
|
||||
# dpkg wrapper: set PATH so dpkg can find sh, tar, rm, dpkg-deb etc.
|
||||
# Also suppresses confdir errors from hardcoded com.termux paths.
|
||||
export PATH="$realPath/../:$realPath/../applets:${d}PATH"
|
||||
"$realPath" "$d@"
|
||||
_rc=$d?
|
||||
if [ ${d}_rc -eq 2 ]; then exit 0; fi
|
||||
exit ${d}_rc
|
||||
"""
|
||||
dpkgBin.writeText(wrapperContent)
|
||||
dpkgBin.setExecutable(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy post-setup.sh and glibc-compat.js to home dir.
|
||||
* post-setup.sh: try GitHub first, fall back to bundled asset.
|
||||
* glibc-compat.js: always use bundled asset.
|
||||
*/
|
||||
private fun copyAssetScripts() {
|
||||
val ocaDir = File(homeDir, ".openclaw-android")
|
||||
ocaDir.mkdirs()
|
||||
File(ocaDir, "patches").mkdirs()
|
||||
|
||||
val postSetup = File(ocaDir, "post-setup.sh")
|
||||
copyPostSetupScript(postSetup)
|
||||
copyBundledAsset("glibc-compat.js", File(ocaDir, "patches/glibc-compat.js"))
|
||||
}
|
||||
|
||||
private fun copyPostSetupScript(target: File) {
|
||||
val url = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/post-setup.sh"
|
||||
try {
|
||||
java.net.URL(url).openStream().use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
target.setExecutable(true)
|
||||
AppLogger.i(TAG, "post-setup.sh downloaded from GitHub")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to download post-setup.sh, using bundled fallback", e)
|
||||
}
|
||||
try {
|
||||
context.assets.open("post-setup.sh").use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
target.setExecutable(true)
|
||||
AppLogger.i(TAG, "post-setup.sh copied from bundled assets")
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to copy bundled post-setup.sh", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyBundledAsset(
|
||||
assetName: String,
|
||||
target: File,
|
||||
) {
|
||||
try {
|
||||
context.assets.open(assetName).use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
AppLogger.i(TAG, "$assetName copied from bundled assets")
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to copy $assetName", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime packages are installed by post-setup.sh in the terminal
|
||||
|
||||
/**
|
||||
* Apply script update on APK version upgrade:
|
||||
* - Overwrites post-setup.sh and glibc-compat.js from latest assets
|
||||
* - Installs/updates oa CLI from GitHub so users can run `oa --update`
|
||||
*/
|
||||
fun applyScriptUpdate() {
|
||||
if (!isInstalled()) return
|
||||
copyAssetScripts()
|
||||
syncWwwFromAssets()
|
||||
installOaCli()
|
||||
AppLogger.i(TAG, "Script update applied")
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy bundled assets/www into wwwDir, overwriting any existing files.
|
||||
* Called on first install and on APK version upgrade to ensure the UI is always current.
|
||||
*/
|
||||
fun syncWwwFromAssets() {
|
||||
try {
|
||||
wwwDir.mkdirs()
|
||||
copyAssetDir("www", wwwDir)
|
||||
AppLogger.i(TAG, "www synced from assets to ${wwwDir.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to sync www from assets", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAssetDir(
|
||||
assetPath: String,
|
||||
targetDir: File,
|
||||
) {
|
||||
val entries = context.assets.list(assetPath) ?: return
|
||||
targetDir.mkdirs()
|
||||
for (entry in entries) {
|
||||
copyAssetEntry("$assetPath/$entry", File(targetDir, entry))
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAssetEntry(
|
||||
assetPath: String,
|
||||
targetFile: File,
|
||||
) {
|
||||
val children = context.assets.list(assetPath)
|
||||
if (!children.isNullOrEmpty()) {
|
||||
copyAssetDir(assetPath, targetFile)
|
||||
} else {
|
||||
context.assets.open(assetPath).use { input ->
|
||||
targetFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installOaCli() {
|
||||
val oaBin = File(prefixDir, "bin/oa")
|
||||
val oaUrl = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/oa.sh"
|
||||
try {
|
||||
java.net.URL(oaUrl).openStream().use { input ->
|
||||
oaBin.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
oaBin.setExecutable(true)
|
||||
AppLogger.i(TAG, "oa CLI installed at ${oaBin.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, "Failed to install oa CLI", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object Os {
|
||||
@JvmStatic
|
||||
fun symlink(
|
||||
target: String,
|
||||
path: String,
|
||||
) {
|
||||
android.system.Os.symlink(target, path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Shell command execution via ProcessBuilder (§2.2.5).
|
||||
* Uses Termux bootstrap environment for all commands.
|
||||
*/
|
||||
object CommandRunner {
|
||||
data class CommandResult(
|
||||
val exitCode: Int,
|
||||
val stdout: String,
|
||||
val stderr: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Run a command synchronously with timeout.
|
||||
* Returns stdout/stderr and exit code.
|
||||
*/
|
||||
fun runSync(
|
||||
command: String,
|
||||
env: Map<String, String>,
|
||||
workDir: File,
|
||||
timeoutMs: Long = 5_000,
|
||||
): CommandResult =
|
||||
try {
|
||||
val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh"
|
||||
val pb = ProcessBuilder(shell, "-c", command)
|
||||
pb.environment().clear()
|
||||
pb.environment().putAll(env)
|
||||
pb.directory(workDir)
|
||||
pb.redirectErrorStream(false)
|
||||
|
||||
val process = pb.start()
|
||||
val stdout = process.inputStream.bufferedReader().readText()
|
||||
val stderr = process.errorStream.bufferedReader().readText()
|
||||
val exited = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (!exited) {
|
||||
process.destroyForcibly()
|
||||
CommandResult(-1, stdout, "Command timed out after ${timeoutMs}ms")
|
||||
} else {
|
||||
CommandResult(process.exitValue(), stdout, stderr)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
CommandResult(-1, "", e.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command asynchronously, streaming output line-by-line via callback.
|
||||
*/
|
||||
suspend fun runStreaming(
|
||||
command: String,
|
||||
env: Map<String, String>,
|
||||
workDir: File,
|
||||
onOutput: (String) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh"
|
||||
val pb = ProcessBuilder(shell, "-c", command)
|
||||
pb.environment().clear()
|
||||
pb.environment().putAll(env)
|
||||
pb.directory(workDir)
|
||||
pb.redirectErrorStream(true)
|
||||
|
||||
val process = pb.start()
|
||||
process.inputStream.bufferedReader().forEachLine { line ->
|
||||
onOutput(line)
|
||||
}
|
||||
process.waitFor()
|
||||
} catch (e: Exception) {
|
||||
onOutput("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Builds the complete process environment for Termux bootstrap (§2.2.5).
|
||||
* Based on AnyClaw CodexServerManager.kt pattern.
|
||||
*/
|
||||
object EnvironmentBuilder {
|
||||
fun build(context: Context): Map<String, String> = build(context.filesDir)
|
||||
|
||||
fun build(filesDir: File): Map<String, String> {
|
||||
val prefix = File(filesDir, "usr")
|
||||
val home = File(filesDir, "home")
|
||||
val tmp = File(filesDir, "tmp")
|
||||
|
||||
return buildMap {
|
||||
// Core paths
|
||||
put("PREFIX", prefix.absolutePath)
|
||||
put("HOME", home.absolutePath)
|
||||
put("TMPDIR", tmp.absolutePath)
|
||||
val nodeBin = "${home.absolutePath}/.openclaw-android/node/bin"
|
||||
val localBin = "${home.absolutePath}/.local/bin"
|
||||
val prefixBin = "${prefix.absolutePath}/bin"
|
||||
put("PATH", "$nodeBin:$localBin:$prefixBin:$prefixBin/applets")
|
||||
put("LD_LIBRARY_PATH", "${prefix.absolutePath}/lib")
|
||||
|
||||
// libtermux-exec path conversion (§2.2.4)
|
||||
// The bootstrap binaries have hardcoded /data/data/com.termux/... paths.
|
||||
// libtermux-exec intercepts file operations and rewrites them.
|
||||
// Only set LD_PRELOAD if the library actually exists — otherwise
|
||||
// the dynamic linker refuses to start any process.
|
||||
val termuxExecLib = File(prefix, "lib/libtermux-exec.so")
|
||||
if (termuxExecLib.exists()) {
|
||||
put("LD_PRELOAD", termuxExecLib.absolutePath)
|
||||
}
|
||||
put("TERMUX__PREFIX", prefix.absolutePath)
|
||||
put("TERMUX_PREFIX", prefix.absolutePath)
|
||||
put("TERMUX__ROOTFS", filesDir.absolutePath)
|
||||
// Tell libtermux-exec where the current app data dir is
|
||||
val appDataDir = filesDir.parentFile?.absolutePath ?: filesDir.absolutePath
|
||||
put("TERMUX_APP__DATA_DIR", appDataDir)
|
||||
// Tell libtermux-exec the OLD path to match against and rewrite
|
||||
put("TERMUX_APP__LEGACY_DATA_DIR", "/data/data/com.termux")
|
||||
// put("TERMUX_EXEC__LOG_LEVEL", "2") // Uncomment to debug libtermux-exec
|
||||
|
||||
// apt/dpkg (§2.2.3)
|
||||
put("APT_CONFIG", "${prefix.absolutePath}/etc/apt/apt.conf")
|
||||
put("DPKG_ADMINDIR", "${prefix.absolutePath}/var/lib/dpkg")
|
||||
put("DPKG_ROOT", prefix.absolutePath)
|
||||
|
||||
// SSL (libgnutls.so hardcoded path workaround)
|
||||
put("SSL_CERT_FILE", "${prefix.absolutePath}/etc/tls/cert.pem")
|
||||
|
||||
put("CURL_CA_BUNDLE", "${prefix.absolutePath}/etc/tls/cert.pem")
|
||||
put("GIT_SSL_CAINFO", "${prefix.absolutePath}/etc/tls/cert.pem")
|
||||
|
||||
// Git (system gitconfig has hardcoded com.termux path)
|
||||
put("GIT_CONFIG_NOSYSTEM", "1")
|
||||
|
||||
// Git exec path (git looks for helpers like git-remote-https here)
|
||||
put("GIT_EXEC_PATH", "${prefix.absolutePath}/libexec/git-core")
|
||||
|
||||
// Git template dir (hardcoded /data/data/com.termux path workaround)
|
||||
put("GIT_TEMPLATE_DIR", "${prefix.absolutePath}/share/git-core/templates")
|
||||
|
||||
// Locale and terminal
|
||||
put("LANG", "en_US.UTF-8")
|
||||
put("TERM", "xterm-256color")
|
||||
|
||||
// Android-specific
|
||||
put("ANDROID_DATA", "/data")
|
||||
put("ANDROID_ROOT", "/system")
|
||||
|
||||
// OpenClaw platform
|
||||
put("OA_GLIBC", "1")
|
||||
put("CONTAINER", "1")
|
||||
put("CLAWDHUB_WORKDIR", "${home.absolutePath}/.openclaw/workspace")
|
||||
put("CPATH", "${prefix.absolutePath}/include/glib-2.0:${prefix.absolutePath}/lib/glib-2.0/include")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.webkit.WebView
|
||||
import com.google.gson.Gson
|
||||
|
||||
/**
|
||||
* Kotlin → WebView event dispatch (§2.8).
|
||||
* Uses evaluateJavascript + CustomEvent pattern.
|
||||
*
|
||||
* WebView side (index.html) must include:
|
||||
* window.__oc = {
|
||||
* emit(type, data) {
|
||||
* window.dispatchEvent(new CustomEvent(`native:${type}`, { detail: data }));
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
class EventBridge(
|
||||
private val webView: WebView,
|
||||
) {
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* Emit a named event to the WebView.
|
||||
* React side listens via: useNativeEvent('type', handler)
|
||||
*/
|
||||
fun emit(
|
||||
type: String,
|
||||
data: Any?,
|
||||
) {
|
||||
val json = gson.toJson(data ?: emptyMap<String, Any>())
|
||||
val script = "window.__oc&&window.__oc.emit('$type',$json)"
|
||||
webView.post { webView.evaluateJavascript(script, null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.webkit.JavascriptInterface
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* WebView → Kotlin bridge via @JavascriptInterface (§2.6).
|
||||
* All methods callable from JavaScript as window.OpenClaw.<method>().
|
||||
* All return values are JSON strings. Async operations use EventBridge (§2.8).
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LargeClass") // WebView bridge — single facade by design
|
||||
class JsBridge(
|
||||
private val activity: MainActivity,
|
||||
private val sessionManager: TerminalSessionManager,
|
||||
private val bootstrapManager: BootstrapManager,
|
||||
private val eventBridge: EventBridge,
|
||||
) {
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "JsBridge"
|
||||
private const val SHELL_INIT_DELAY_MS = 500L
|
||||
private const val COMMAND_TIMEOUT_MS = 5_000L
|
||||
private const val PLATFORM_LIST_TIMEOUT_MS = 10_000L
|
||||
private const val API_TIMEOUT_MS = 5000
|
||||
private const val PROGRESS_START = 0f
|
||||
private const val PROGRESS_HALF = 0.5f
|
||||
private const val PROGRESS_DONE = 1f
|
||||
private const val PROGRESS_DOWNLOAD = 0.2f
|
||||
private const val PROGRESS_EXTRACT = 0.6f
|
||||
private const val PROGRESS_APPLY = 0.9f
|
||||
private const val PROGRESS_BOOTSTRAP_START = 0.1f
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a coroutine on Dispatchers.IO with error handling.
|
||||
* Catches all exceptions to prevent app crashes from unhandled coroutine failures.
|
||||
* Errors are logged and emitted to the WebView via EventBridge.
|
||||
*/
|
||||
private fun launchWithErrorHandling(
|
||||
errorEventType: String = "error",
|
||||
errorContext: Map<String, Any?> = emptyMap(),
|
||||
block: suspend CoroutineScope.() -> Unit,
|
||||
) {
|
||||
val handler =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
AppLogger.e(TAG, "Coroutine error [$errorEventType]: ${throwable.message}", throwable)
|
||||
eventBridge.emit(
|
||||
errorEventType,
|
||||
errorContext +
|
||||
mapOf(
|
||||
"error" to (throwable.message ?: "Unknown error"),
|
||||
"progress" to PROGRESS_START,
|
||||
"message" to "Error: ${throwable.message}",
|
||||
),
|
||||
)
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO + handler).launch(block = block)
|
||||
}
|
||||
// ═══════════════════════════════════════════
|
||||
// Terminal domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun showTerminal() {
|
||||
// Create session if none exists (e.g., after first-time setup)
|
||||
if (sessionManager.activeSession == null) {
|
||||
val session = sessionManager.createSession()
|
||||
if (bootstrapManager.needsPostSetup()) {
|
||||
val script = bootstrapManager.postSetupScript.absolutePath
|
||||
// Delay write until after attachSession() initializes the shell process.
|
||||
// createSession() posts attachSession() via runOnUiThread; writing before
|
||||
// that runs silently drops the data (mShellPid is still 0).
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
session.write("bash $script\n")
|
||||
}, SHELL_INIT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
activity.showTerminal()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun showWebView() = activity.showWebView()
|
||||
|
||||
@JavascriptInterface
|
||||
fun createSession(): String {
|
||||
val session = sessionManager.createSession()
|
||||
return gson.toJson(mapOf("id" to session.mHandle, "name" to (session.title ?: "Terminal")))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun switchSession(id: String) =
|
||||
activity.runOnUiThread {
|
||||
sessionManager.switchSession(id)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun closeSession(id: String) {
|
||||
sessionManager.closeSession(id)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getTerminalSessions(): String = gson.toJson(sessionManager.getSessionsInfo())
|
||||
|
||||
@JavascriptInterface
|
||||
fun writeToTerminal(
|
||||
id: String,
|
||||
data: String,
|
||||
) {
|
||||
val session =
|
||||
if (id.isBlank()) {
|
||||
sessionManager.activeSession
|
||||
} else {
|
||||
sessionManager.getSessionById(id) ?: sessionManager.activeSession
|
||||
}
|
||||
session?.write(data)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun runInNewSession(command: String) {
|
||||
val session = sessionManager.createSession()
|
||||
activity.showTerminal()
|
||||
// Delay write until shell process initializes (same pattern as showTerminal post-setup)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
session.write(command)
|
||||
}, SHELL_INIT_DELAY_MS)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Setup domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun getSetupStatus(): String = gson.toJson(bootstrapManager.getStatus())
|
||||
|
||||
@JavascriptInterface
|
||||
fun getBootstrapStatus(): String =
|
||||
gson.toJson(
|
||||
mapOf(
|
||||
"installed" to bootstrapManager.isInstalled(),
|
||||
"prefixPath" to bootstrapManager.prefixDir.absolutePath,
|
||||
),
|
||||
)
|
||||
|
||||
@JavascriptInterface
|
||||
fun startSetup() {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "setup_progress",
|
||||
errorContext = mapOf("progress" to PROGRESS_START),
|
||||
) {
|
||||
bootstrapManager.startSetup { progress, message ->
|
||||
eventBridge.emit(
|
||||
"setup_progress",
|
||||
mapOf("progress" to progress, "message" to message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun saveToolSelections(json: String) {
|
||||
val configFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/tool-selections.conf")
|
||||
configFile.parentFile?.mkdirs()
|
||||
val selections = gson.fromJson(json, Map::class.java) as? Map<*, *> ?: return
|
||||
val lines =
|
||||
selections.entries.joinToString("\n") { (key, value) ->
|
||||
val envKey = "INSTALL_${(key as String).uppercase().replace("-", "_")}"
|
||||
"$envKey=$value"
|
||||
}
|
||||
configFile.writeText(lines + "\n")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Platform domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun getAvailablePlatforms(): String {
|
||||
// Read from cached config.json or return defaults
|
||||
return gson.toJson(
|
||||
listOf(
|
||||
mapOf(
|
||||
"id" to "openclaw",
|
||||
"name" to "OpenClaw",
|
||||
"icon" to "/openclaw.svg",
|
||||
"desc" to "AI agent platform",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getInstalledPlatforms(): String {
|
||||
// Check which platforms are installed via npm/filesystem
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val result =
|
||||
CommandRunner.runSync(
|
||||
"npm list -g --depth=0 --json 2>/dev/null",
|
||||
env,
|
||||
bootstrapManager.prefixDir,
|
||||
timeoutMs = PLATFORM_LIST_TIMEOUT_MS,
|
||||
)
|
||||
return result.stdout.ifBlank { "[]" }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun installPlatform(id: String) {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "install_progress",
|
||||
errorContext = mapOf("target" to id),
|
||||
) {
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."),
|
||||
)
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
CommandRunner.runStreaming(
|
||||
"npm install -g $id@latest --ignore-scripts",
|
||||
env,
|
||||
bootstrapManager.homeDir,
|
||||
) { output ->
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output),
|
||||
)
|
||||
}
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf("target" to id, "progress" to PROGRESS_DONE, "message" to "$id installed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun uninstallPlatform(id: String) {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "install_progress",
|
||||
errorContext = mapOf("target" to id),
|
||||
) {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
CommandRunner.runSync("npm uninstall -g $id", env, bootstrapManager.homeDir)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun switchPlatform(id: String) {
|
||||
// Write active platform marker
|
||||
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
|
||||
markerFile.parentFile?.mkdirs()
|
||||
markerFile.writeText(id)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getActivePlatform(): String {
|
||||
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
|
||||
val id = if (markerFile.exists()) markerFile.readText().trim() else "openclaw"
|
||||
return gson.toJson(mapOf("id" to id, "name" to id.replaceFirstChar { it.uppercase() }))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Tools domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun getInstalledTools(): String {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val prefix = bootstrapManager.prefixDir.absolutePath
|
||||
val tools = mutableListOf<Map<String, String>>()
|
||||
|
||||
// Termux packages - check binary path
|
||||
val pkgChecks =
|
||||
mapOf(
|
||||
"tmux" to "$prefix/bin/tmux",
|
||||
"ttyd" to "$prefix/bin/ttyd",
|
||||
"dufs" to "$prefix/bin/dufs",
|
||||
"openssh-server" to "$prefix/bin/sshd",
|
||||
"android-tools" to "$prefix/bin/adb",
|
||||
"code-server" to "$prefix/bin/code-server",
|
||||
)
|
||||
for ((id, path) in pkgChecks) {
|
||||
if (java.io.File(path).exists()) {
|
||||
tools.add(mapOf("id" to id, "name" to id, "version" to "installed"))
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium - check multiple possible paths
|
||||
if (java.io.File("$prefix/bin/chromium-browser").exists() || java.io.File("$prefix/bin/chromium").exists()) {
|
||||
tools.add(mapOf("id" to "chromium", "name" to "chromium", "version" to "installed"))
|
||||
}
|
||||
|
||||
// npm global packages - check binary file in node bin
|
||||
val nodeBin = "${bootstrapManager.homeDir.absolutePath}/.openclaw-android/node/bin"
|
||||
val npmBinChecks =
|
||||
mapOf(
|
||||
"claude-code" to "$nodeBin/claude",
|
||||
"gemini-cli" to "$nodeBin/gemini",
|
||||
"codex-cli" to "$nodeBin/codex",
|
||||
"opencode" to "$nodeBin/opencode",
|
||||
)
|
||||
for ((id, path) in npmBinChecks) {
|
||||
if (java.io.File(path).exists()) {
|
||||
tools.add(mapOf("id" to id, "name" to id, "version" to "installed"))
|
||||
}
|
||||
}
|
||||
|
||||
return gson.toJson(tools)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun installTool(id: String) {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "install_progress",
|
||||
errorContext = mapOf("target" to id),
|
||||
) {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val prefix = bootstrapManager.prefixDir.absolutePath
|
||||
val aptGet =
|
||||
"DEBIAN_FRONTEND=noninteractive $prefix/bin/apt-get" +
|
||||
" -y -o Acquire::AllowInsecureRepositories=true" +
|
||||
" -o APT::Get::AllowUnauthenticated=true"
|
||||
val cmd =
|
||||
when (id) {
|
||||
// Termux packages (apt-get)
|
||||
"tmux", "ttyd", "dufs", "openssh-server", "android-tools" ->
|
||||
"$aptGet install ${if (id == "openssh-server") "openssh" else id}"
|
||||
// Chromium (from x11-repo)
|
||||
"chromium" ->
|
||||
"$aptGet install chromium"
|
||||
// code-server (custom)
|
||||
"code-server" ->
|
||||
"npm install -g code-server"
|
||||
// npm-based AI CLI tools
|
||||
"claude-code" ->
|
||||
"npm install -g @anthropic-ai/claude-code"
|
||||
"gemini-cli" ->
|
||||
"npm install -g @google/gemini-cli"
|
||||
"codex-cli" ->
|
||||
"npm install -g @mmmbuto/codex-cli-termux"
|
||||
// OpenCode (Bun-based) — requires proot + ld.so concatenation
|
||||
"opencode" ->
|
||||
"curl -fsSL https://raw.githubusercontent.com/" +
|
||||
"AidanPark/openclaw-android/main/scripts/install-opencode.sh | bash"
|
||||
else -> "echo 'Unknown tool: $id'"
|
||||
}
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."),
|
||||
)
|
||||
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output),
|
||||
)
|
||||
}
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf("target" to id, "progress" to PROGRESS_DONE, "message" to "$id installed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun uninstallTool(id: String) {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "install_progress",
|
||||
errorContext = mapOf("target" to id),
|
||||
) {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val cmd =
|
||||
when (id) {
|
||||
"tmux", "ttyd", "dufs", "openssh-server", "android-tools", "chromium" -> {
|
||||
val pkg = if (id == "openssh-server") "openssh" else id
|
||||
"${bootstrapManager.prefixDir.absolutePath}/bin/apt-get remove -y $pkg"
|
||||
}
|
||||
"code-server" ->
|
||||
"npm uninstall -g code-server"
|
||||
"claude-code" ->
|
||||
"npm uninstall -g @anthropic-ai/claude-code"
|
||||
"gemini-cli" ->
|
||||
"npm uninstall -g @google/gemini-cli"
|
||||
"codex-cli" ->
|
||||
"npm uninstall -g @mmmbuto/codex-cli-termux"
|
||||
"opencode" ->
|
||||
"rm -f \$PREFIX/bin/opencode" +
|
||||
" \$HOME/.openclaw-android/bin/ld.so.opencode" +
|
||||
" \$PREFIX/tmp/ld.so.opencode" +
|
||||
" && rm -rf \$HOME/.config/opencode"
|
||||
else -> "echo 'Unknown tool: $id'"
|
||||
}
|
||||
CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun isToolInstalled(id: String): String {
|
||||
val prefix = bootstrapManager.prefixDir.absolutePath
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val exists =
|
||||
when (id) {
|
||||
"openssh-server" -> java.io.File("$prefix/bin/sshd").exists()
|
||||
"tmux", "ttyd", "dufs", "android-tools" -> {
|
||||
val bin = if (id == "android-tools") "adb" else id
|
||||
java.io.File("$prefix/bin/$bin").exists()
|
||||
}
|
||||
"chromium" -> {
|
||||
java.io.File("$prefix/bin/chromium-browser").exists() ||
|
||||
java.io.File("$prefix/bin/chromium").exists()
|
||||
}
|
||||
"code-server" -> java.io.File("$prefix/bin/code-server").exists()
|
||||
else -> {
|
||||
// npm global packages: check via command -v
|
||||
val result =
|
||||
CommandRunner.runSync(
|
||||
"command -v $id 2>/dev/null",
|
||||
env,
|
||||
bootstrapManager.prefixDir,
|
||||
timeoutMs = COMMAND_TIMEOUT_MS,
|
||||
)
|
||||
result.stdout.trim().isNotEmpty()
|
||||
}
|
||||
}
|
||||
return gson.toJson(mapOf("installed" to exists))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Commands domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun runCommand(cmd: String): String {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val result = CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
|
||||
return gson.toJson(result)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun runCommandAsync(
|
||||
callbackId: String,
|
||||
cmd: String,
|
||||
) {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "command_output",
|
||||
errorContext = mapOf("callbackId" to callbackId, "done" to true),
|
||||
) {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
|
||||
eventBridge.emit(
|
||||
"command_output",
|
||||
mapOf("callbackId" to callbackId, "data" to output, "done" to false),
|
||||
)
|
||||
}
|
||||
eventBridge.emit(
|
||||
"command_output",
|
||||
mapOf("callbackId" to callbackId, "data" to "", "done" to true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Updates domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun checkForUpdates(): String {
|
||||
// Compare local versions with config.json remote versions
|
||||
val updates = mutableListOf<Map<String, String>>()
|
||||
try {
|
||||
val configFile =
|
||||
java.io.File(
|
||||
activity.filesDir,
|
||||
"usr/share/openclaw-app/config.json",
|
||||
)
|
||||
if (configFile.exists()) {
|
||||
val config = gson.fromJson(configFile.readText(), Map::class.java) as? Map<*, *>
|
||||
val localWwwVersion =
|
||||
activity
|
||||
.getSharedPreferences("openclaw", 0)
|
||||
.getString("www_version", "0.0.0")
|
||||
val remoteWwwVersion = ((config?.get("www") as? Map<*, *>)?.get("version") as? String)
|
||||
if (remoteWwwVersion != null && remoteWwwVersion != localWwwVersion) {
|
||||
updates.add(
|
||||
mapOf(
|
||||
"component" to "www",
|
||||
"currentVersion" to (localWwwVersion ?: "0.0.0"),
|
||||
"newVersion" to remoteWwwVersion,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore parse errors
|
||||
}
|
||||
return gson.toJson(updates)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getApkUpdateInfo(): String {
|
||||
return try {
|
||||
val url = java.net.URL("https://api.github.com/repos/AidanPark/openclaw-android/releases/latest")
|
||||
val conn = url.openConnection() as java.net.HttpURLConnection
|
||||
conn.connectTimeout = API_TIMEOUT_MS
|
||||
conn.readTimeout = API_TIMEOUT_MS
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val release = gson.fromJson(body, Map::class.java) as? Map<*, *>
|
||||
val tagName =
|
||||
release?.get("tagName") as? String
|
||||
?: release?.get("tag_name") as? String
|
||||
?: return gson.toJson(mapOf("error" to "no tag"))
|
||||
val latestVersion = tagName.trimStart('v')
|
||||
val currentVersion =
|
||||
activity.packageManager
|
||||
.getPackageInfo(activity.packageName, 0)
|
||||
.versionName ?: "0.0.0"
|
||||
gson.toJson(
|
||||
mapOf(
|
||||
"currentVersion" to currentVersion,
|
||||
"latestVersion" to latestVersion,
|
||||
"updateAvailable" to (compareVersions(latestVersion, currentVersion) > 0),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
gson.toJson(mapOf("error" to e.message))
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun applyUpdate(component: String) {
|
||||
launchWithErrorHandling(
|
||||
errorEventType = "install_progress",
|
||||
errorContext = mapOf("target" to component),
|
||||
) {
|
||||
emitProgress(component, PROGRESS_START, "Updating $component...")
|
||||
when (component) {
|
||||
"www" -> updateWww()
|
||||
"bootstrap" -> updateBootstrap()
|
||||
"scripts" -> emitProgress("scripts", PROGRESS_HALF, "Scripts are updated with bootstrap")
|
||||
}
|
||||
emitProgress(component, PROGRESS_DONE, "$component updated")
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitProgress(
|
||||
target: String,
|
||||
progress: Float,
|
||||
message: String,
|
||||
) {
|
||||
eventBridge.emit(
|
||||
"install_progress",
|
||||
mapOf(
|
||||
"target" to target,
|
||||
"progress" to progress,
|
||||
"message" to message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateWww() {
|
||||
try {
|
||||
val url = UrlResolver(activity).getWwwUrl()
|
||||
val stagingWww = java.io.File(activity.cacheDir, "www-staging")
|
||||
stagingWww.deleteRecursively()
|
||||
stagingWww.mkdirs()
|
||||
|
||||
emitProgress("www", PROGRESS_DOWNLOAD, "Downloading...")
|
||||
val zipFile = java.io.File(activity.cacheDir, "www.zip")
|
||||
java.net.URL(url).openStream().use { input ->
|
||||
zipFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
|
||||
emitProgress("www", PROGRESS_EXTRACT, "Extracting...")
|
||||
extractZipToDir(zipFile, stagingWww)
|
||||
zipFile.delete()
|
||||
|
||||
emitProgress("www", PROGRESS_APPLY, "Applying...")
|
||||
val wwwDir = bootstrapManager.wwwDir
|
||||
wwwDir.deleteRecursively()
|
||||
wwwDir.parentFile?.mkdirs()
|
||||
stagingWww.renameTo(wwwDir)
|
||||
|
||||
activity.runOnUiThread { activity.reloadWebView() }
|
||||
} catch (e: Exception) {
|
||||
emitProgress("www", PROGRESS_START, "Update failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateBootstrap() {
|
||||
try {
|
||||
emitProgress("bootstrap", PROGRESS_BOOTSTRAP_START, "Downloading bootstrap...")
|
||||
bootstrapManager.startSetup { progress, message ->
|
||||
emitProgress("bootstrap", progress, message)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emitProgress("bootstrap", PROGRESS_START, "Update failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractZipToDir(
|
||||
zipFile: java.io.File,
|
||||
targetDir: java.io.File,
|
||||
) {
|
||||
java.util.zip.ZipInputStream(zipFile.inputStream()).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
extractZipEntry(zis, entry, targetDir)
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractZipEntry(
|
||||
zis: java.util.zip.ZipInputStream,
|
||||
entry: java.util.zip.ZipEntry,
|
||||
targetDir: java.io.File,
|
||||
) {
|
||||
val destFile = java.io.File(targetDir, entry.name)
|
||||
if (entry.isDirectory) {
|
||||
destFile.mkdirs()
|
||||
} else {
|
||||
destFile.parentFile?.mkdirs()
|
||||
destFile.outputStream().use { out -> zis.copyTo(out) }
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// System domain
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun getAppInfo(): String {
|
||||
val pInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
|
||||
return gson.toJson(
|
||||
mapOf(
|
||||
"versionName" to (pInfo.versionName ?: "unknown"),
|
||||
"versionCode" to pInfo.versionCode,
|
||||
"packageName" to activity.packageName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getBatteryOptimizationStatus(): String {
|
||||
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return gson.toJson(
|
||||
mapOf("isIgnoring" to pm.isIgnoringBatteryOptimizations(activity.packageName)),
|
||||
)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun requestBatteryOptimizationExclusion() {
|
||||
activity.runOnUiThread {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||
intent.data = Uri.parse("package:${activity.packageName}")
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openSystemSettings(page: String) {
|
||||
activity.runOnUiThread {
|
||||
val intent =
|
||||
when (page) {
|
||||
"battery" -> Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
|
||||
"app_info" ->
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${activity.packageName}")
|
||||
}
|
||||
else -> Intent(Settings.ACTION_SETTINGS)
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun copyToClipboard(text: String) {
|
||||
activity.runOnUiThread {
|
||||
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text))
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getStorageInfo(): String {
|
||||
val filesDir = activity.filesDir
|
||||
val totalSpace = filesDir.totalSpace
|
||||
val freeSpace = filesDir.freeSpace
|
||||
val bootstrapSize = bootstrapManager.prefixDir.walkTopDown().sumOf { it.length() }
|
||||
val wwwSize = bootstrapManager.wwwDir.walkTopDown().sumOf { it.length() }
|
||||
|
||||
return gson.toJson(
|
||||
mapOf(
|
||||
"totalBytes" to totalSpace,
|
||||
"freeBytes" to freeSpace,
|
||||
"bootstrapBytes" to bootstrapSize,
|
||||
"wwwBytes" to wwwSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun clearCache() {
|
||||
activity.cacheDir.deleteRecursively()
|
||||
activity.cacheDir.mkdirs()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openUrl(url: String) {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
|
||||
/** Returns positive if a > b, negative if a < b, 0 if equal (semver: major.minor.patch) */
|
||||
private fun compareVersions(
|
||||
a: String,
|
||||
b: String,
|
||||
): Int {
|
||||
val aParts = a.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val bParts = b.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val len = maxOf(aParts.size, bParts.size)
|
||||
for (i in 0 until len) {
|
||||
val diff = (aParts.getOrElse(i) { 0 }) - (bParts.getOrElse(i) { 0 })
|
||||
if (diff != 0) return diff
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.android.databinding.ActivityMainBinding
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.terminal.TerminalSessionClient
|
||||
import com.termux.view.TerminalViewClient
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val DEFAULT_TEXT_SIZE = 32
|
||||
private const val MIN_TEXT_SIZE = 8
|
||||
private const val MAX_TEXT_SIZE = 32
|
||||
private const val KEYBOARD_SHOW_DELAY_MS = 200L
|
||||
private const val TAB_NAME_TEXT_SIZE = 12f
|
||||
private const val TAB_CLOSE_TEXT_SIZE = 14f
|
||||
private const val TAB_ADD_TEXT_SIZE = 18f
|
||||
private const val TAB_MARGIN_DP = 2
|
||||
private const val TAB_HPAD_DP = 10
|
||||
private const val TAB_VPAD_DP = 4
|
||||
private const val TAB_CLOSE_PAD_DP = 6
|
||||
private const val TAB_ADD_PAD_DP = 12
|
||||
private const val INDICATOR_HEIGHT_DP = 2
|
||||
private const val INPUT_MODE_TYPE_NULL = 1
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
lateinit var sessionManager: TerminalSessionManager
|
||||
lateinit var bootstrapManager: BootstrapManager
|
||||
lateinit var eventBridge: EventBridge
|
||||
private lateinit var jsBridge: JsBridge
|
||||
|
||||
private var currentTextSize = DEFAULT_TEXT_SIZE
|
||||
private var ctrlDown = false
|
||||
private var altDown = false
|
||||
private val terminalSessionClient = OpenClawSessionClient()
|
||||
private val terminalViewClient = OpenClawViewClient()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
bootstrapManager = BootstrapManager(this)
|
||||
eventBridge = EventBridge(binding.webView)
|
||||
sessionManager = TerminalSessionManager(this, terminalSessionClient, eventBridge)
|
||||
jsBridge = JsBridge(this, sessionManager, bootstrapManager, eventBridge)
|
||||
|
||||
setupTerminalView()
|
||||
setupWebView()
|
||||
setupExtraKeys()
|
||||
sessionManager.onSessionsChanged = { updateSessionTabs() }
|
||||
startService(Intent(this, OpenClawService::class.java))
|
||||
|
||||
val isInstalled = bootstrapManager.isInstalled()
|
||||
AppLogger.i(TAG, "Bootstrap installed: $isInstalled, needsPostSetup: ${bootstrapManager.needsPostSetup()}")
|
||||
|
||||
// Sync www assets and check for APK version upgrade
|
||||
if (isInstalled) {
|
||||
val prefs = getSharedPreferences("openclaw", 0)
|
||||
val savedVersionCode = prefs.getInt("versionCode", 0)
|
||||
val currentVersionCode = packageManager.getPackageInfo(packageName, 0).versionCode
|
||||
// Always sync www from assets to pick up UI updates
|
||||
bootstrapManager.syncWwwFromAssets()
|
||||
// Ensure oa CLI is installed (network, run in background)
|
||||
Thread { bootstrapManager.installOaCli() }.start()
|
||||
if (currentVersionCode > savedVersionCode) {
|
||||
AppLogger.i(TAG, "APK version upgrade detected: $savedVersionCode -> $currentVersionCode")
|
||||
bootstrapManager.applyScriptUpdate()
|
||||
prefs.edit().putInt("versionCode", currentVersionCode).apply()
|
||||
}
|
||||
}
|
||||
if (isInstalled) {
|
||||
showTerminal()
|
||||
val session = sessionManager.createSession()
|
||||
if (bootstrapManager.needsPostSetup()) {
|
||||
AppLogger.i(TAG, "Running post-setup script in terminal")
|
||||
val script = bootstrapManager.postSetupScript.absolutePath
|
||||
binding.terminalView.post {
|
||||
session.write("bash $script\n")
|
||||
}
|
||||
} else if (intent?.getBooleanExtra("from_boot", false) == true) {
|
||||
val platformFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
|
||||
val platformId = if (platformFile.exists()) platformFile.readText().trim() else "openclaw"
|
||||
AppLogger.i(TAG, "Boot launch \u2014 auto-starting $platformId gateway")
|
||||
binding.terminalView.post {
|
||||
session.write("$platformId gateway\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
// else: WebView shows setup UI, user triggers startSetup via JsBridge
|
||||
}
|
||||
|
||||
// --- Terminal setup ---
|
||||
|
||||
private fun setupTerminalView() {
|
||||
binding.terminalView.setTerminalViewClient(terminalViewClient)
|
||||
binding.terminalView.setTextSize(currentTextSize)
|
||||
}
|
||||
|
||||
// --- WebView setup ---
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
binding.webView.apply {
|
||||
clearCache(true)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.allowFileAccess = true
|
||||
@Suppress("DEPRECATION")
|
||||
settings.allowFileAccessFromFileURLs = true
|
||||
@Suppress("DEPRECATION")
|
||||
settings.allowUniversalAccessFromFileURLs = true
|
||||
settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE
|
||||
addJavascriptInterface(jsBridge, "OpenClaw")
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onPageFinished(
|
||||
view: WebView?,
|
||||
url: String?,
|
||||
) {
|
||||
super.onPageFinished(view, url)
|
||||
AppLogger.i(TAG, "WebView page loaded: $url")
|
||||
// Page loaded successfully
|
||||
}
|
||||
}
|
||||
webChromeClient =
|
||||
object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
||||
consoleMessage?.let {
|
||||
AppLogger.d("WebViewJS", "${it.sourceId()}:${it.lineNumber()} ${it.message()}")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val wwwDir = bootstrapManager.wwwDir
|
||||
val url =
|
||||
if (wwwDir.resolve("index.html").exists()) {
|
||||
"file://${wwwDir.absolutePath}/index.html"
|
||||
} else {
|
||||
// Load bundled fallback setup page from assets
|
||||
"file:///android_asset/www/index.html"
|
||||
}
|
||||
AppLogger.i(TAG, "Loading WebView URL: $url")
|
||||
binding.webView.loadUrl(url)
|
||||
}
|
||||
|
||||
fun reloadWebView() {
|
||||
binding.webView.reload()
|
||||
}
|
||||
|
||||
// --- View switching ---
|
||||
|
||||
fun showTerminal() {
|
||||
runOnUiThread {
|
||||
binding.webView.visibility = View.GONE
|
||||
binding.terminalContainer.visibility = View.VISIBLE
|
||||
binding.terminalView.requestFocus()
|
||||
updateSessionTabs()
|
||||
// Delay keyboard show — view must be focused and laid out first
|
||||
binding.terminalView.postDelayed({
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(binding.terminalView, InputMethodManager.SHOW_IMPLICIT)
|
||||
}, KEYBOARD_SHOW_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun showWebView() {
|
||||
runOnUiThread {
|
||||
binding.terminalContainer.visibility = View.GONE
|
||||
binding.webView.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onBackPressed() {
|
||||
if (binding.terminalContainer.visibility == View.VISIBLE) {
|
||||
showWebView()
|
||||
} else if (binding.webView.canGoBack()) {
|
||||
binding.webView.goBack()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Extra Keys ---
|
||||
|
||||
private val pressedAlpha = 0.5f
|
||||
private val normalAlpha = 1.0f
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun setupExtraKeys() {
|
||||
// Key code buttons — send key event on touch, never steal focus
|
||||
val keyMap =
|
||||
mapOf(
|
||||
R.id.btnEsc to KeyEvent.KEYCODE_ESCAPE,
|
||||
R.id.btnTab to KeyEvent.KEYCODE_TAB,
|
||||
R.id.btnHome to KeyEvent.KEYCODE_MOVE_HOME,
|
||||
R.id.btnEnd to KeyEvent.KEYCODE_MOVE_END,
|
||||
R.id.btnUp to KeyEvent.KEYCODE_DPAD_UP,
|
||||
R.id.btnDown to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
R.id.btnLeft to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
R.id.btnRight to KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
)
|
||||
for ((btnId, keyCode) in keyMap) {
|
||||
setupExtraKeyTouch(findViewById(btnId)) { sendExtraKey(keyCode) }
|
||||
}
|
||||
|
||||
// Character keys
|
||||
setupExtraKeyTouch(findViewById(R.id.btnDash)) { sessionManager.activeSession?.write("-") }
|
||||
setupExtraKeyTouch(findViewById(R.id.btnPipe)) { sessionManager.activeSession?.write("|") }
|
||||
setupExtraKeyTouch(findViewById(R.id.btnPaste)) {
|
||||
val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val text =
|
||||
clipboard.primaryClip
|
||||
?.getItemAt(0)
|
||||
?.coerceToText(this)
|
||||
?.toString()
|
||||
if (!text.isNullOrEmpty()) sessionManager.activeSession?.write(text)
|
||||
}
|
||||
|
||||
// Modifier toggles — stay pressed until next key or toggled off
|
||||
setupModifierTouch(findViewById(R.id.btnCtrl)) {
|
||||
ctrlDown = !ctrlDown
|
||||
ctrlDown
|
||||
}
|
||||
setupModifierTouch(findViewById(R.id.btnAlt)) {
|
||||
altDown = !altDown
|
||||
altDown
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun setupExtraKeyTouch(
|
||||
btn: Button,
|
||||
action: () -> Unit,
|
||||
) {
|
||||
btn.setOnTouchListener { v, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
v.alpha = normalAlpha
|
||||
if (event.action == MotionEvent.ACTION_UP) action()
|
||||
}
|
||||
}
|
||||
true // consume — never let focus leave TerminalView
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun setupModifierTouch(
|
||||
btn: Button,
|
||||
toggle: () -> Boolean,
|
||||
) {
|
||||
btn.setOnTouchListener { v, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val active = toggle()
|
||||
updateModifierButton(v as Button, active)
|
||||
v.alpha = normalAlpha
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> v.alpha = normalAlpha
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendExtraKey(keyCode: Int) {
|
||||
var metaState = 0
|
||||
if (ctrlDown) metaState = metaState or (KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
|
||||
if (altDown) metaState = metaState or (KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON)
|
||||
|
||||
val ev = KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState)
|
||||
binding.terminalView.onKeyDown(keyCode, ev)
|
||||
|
||||
// Auto-deactivate modifiers after use
|
||||
if (ctrlDown) {
|
||||
ctrlDown = false
|
||||
updateModifierButton(findViewById(R.id.btnCtrl), false)
|
||||
}
|
||||
if (altDown) {
|
||||
altDown = false
|
||||
updateModifierButton(findViewById(R.id.btnAlt), false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateModifierButton(
|
||||
button: Button,
|
||||
active: Boolean,
|
||||
) {
|
||||
val bgColor = if (active) R.color.extraKeyActive else R.color.extraKeyDefault
|
||||
val txtColor = if (active) R.color.extraKeyActiveText else R.color.extraKeyText
|
||||
button.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, bgColor))
|
||||
button.setTextColor(ContextCompat.getColor(this, txtColor))
|
||||
}
|
||||
|
||||
// --- Session tab bar ---
|
||||
|
||||
private fun updateSessionTabs() {
|
||||
val tabsLayout = binding.tabsLayout
|
||||
tabsLayout.removeAllViews()
|
||||
|
||||
val sessions = sessionManager.getSessionsInfo()
|
||||
val density = resources.displayMetrics.density
|
||||
|
||||
for (info in sessions) {
|
||||
val tabWrapper = createSessionTab(info, density)
|
||||
tabsLayout.addView(tabWrapper)
|
||||
if (info["active"] as Boolean) {
|
||||
binding.sessionTabBar.post {
|
||||
binding.sessionTabBar.smoothScrollTo(tabWrapper.left, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tabsLayout.addView(createAddButton(density))
|
||||
}
|
||||
|
||||
private fun createSessionTab(
|
||||
info: Map<String, Any>,
|
||||
density: Float,
|
||||
): LinearLayout {
|
||||
val id = info["id"] as String
|
||||
val name = info["name"] as String
|
||||
val active = info["active"] as Boolean
|
||||
val finished = info["finished"] as Boolean
|
||||
|
||||
val tabWrapper =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams =
|
||||
LinearLayout
|
||||
.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
).apply {
|
||||
marginEnd = (TAB_MARGIN_DP * density).toInt()
|
||||
}
|
||||
val bgColor =
|
||||
if (active) {
|
||||
R.color.tabActiveBackground
|
||||
} else {
|
||||
R.color.tabInactiveBackground
|
||||
}
|
||||
setBackgroundColor(ContextCompat.getColor(this@MainActivity, bgColor))
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
}
|
||||
|
||||
val tabContent = createTabContent(name, active, finished, id, density)
|
||||
val indicator = createTabIndicator(active, density)
|
||||
|
||||
tabWrapper.addView(tabContent)
|
||||
tabWrapper.addView(indicator)
|
||||
tabWrapper.setOnClickListener {
|
||||
sessionManager.switchSession(id)
|
||||
binding.terminalView.requestFocus()
|
||||
}
|
||||
|
||||
return tabWrapper
|
||||
}
|
||||
|
||||
private fun createTabContent(
|
||||
name: String,
|
||||
active: Boolean,
|
||||
finished: Boolean,
|
||||
id: String,
|
||||
density: Float,
|
||||
): LinearLayout {
|
||||
val tabContent =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val hPad = (TAB_HPAD_DP * density).toInt()
|
||||
val vPad = (TAB_VPAD_DP * density).toInt()
|
||||
setPadding(hPad, vPad, (TAB_CLOSE_PAD_DP * density).toInt(), vPad)
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
}
|
||||
|
||||
val nameView =
|
||||
TextView(this).apply {
|
||||
text = name
|
||||
textSize = TAB_NAME_TEXT_SIZE
|
||||
val textColor =
|
||||
when {
|
||||
finished -> R.color.tabTextFinished
|
||||
active -> R.color.tabTextPrimary
|
||||
else -> R.color.tabTextSecondary
|
||||
}
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, textColor))
|
||||
if (finished) setTypeface(typeface, Typeface.ITALIC)
|
||||
isSingleLine = true
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}
|
||||
|
||||
val closeView =
|
||||
TextView(this).apply {
|
||||
text = "\u00D7"
|
||||
textSize = TAB_CLOSE_TEXT_SIZE
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary))
|
||||
setPadding((TAB_CLOSE_PAD_DP * density).toInt(), 0, 0, 0)
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
setOnClickListener { closeSessionFromTab(id) }
|
||||
}
|
||||
|
||||
tabContent.addView(nameView)
|
||||
tabContent.addView(closeView)
|
||||
return tabContent
|
||||
}
|
||||
|
||||
private fun createTabIndicator(
|
||||
active: Boolean,
|
||||
density: Float,
|
||||
): View =
|
||||
View(this).apply {
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
(INDICATOR_HEIGHT_DP * density).toInt(),
|
||||
)
|
||||
val color = if (active) R.color.tabAccent else android.R.color.transparent
|
||||
setBackgroundColor(ContextCompat.getColor(this@MainActivity, color))
|
||||
}
|
||||
|
||||
private fun createAddButton(density: Float): TextView =
|
||||
TextView(this).apply {
|
||||
text = "+"
|
||||
textSize = TAB_ADD_TEXT_SIZE
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary))
|
||||
val pad = (TAB_ADD_PAD_DP * density).toInt()
|
||||
setPadding(pad, 0, pad, 0)
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
setOnClickListener {
|
||||
sessionManager.createSession()
|
||||
binding.terminalView.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeSessionFromTab(handleId: String) {
|
||||
if (sessionManager.sessionCount <= 1) {
|
||||
// Create new session first, then close the old one
|
||||
sessionManager.createSession()
|
||||
}
|
||||
sessionManager.closeSession(handleId)
|
||||
binding.terminalView.requestFocus()
|
||||
}
|
||||
|
||||
// --- Terminal session callbacks ---
|
||||
|
||||
private inner class OpenClawSessionClient : TerminalSessionClient {
|
||||
override fun onTextChanged(changedSession: TerminalSession) {
|
||||
binding.terminalView.onScreenUpdated()
|
||||
}
|
||||
|
||||
override fun onTitleChanged(changedSession: TerminalSession) {
|
||||
// Update tab bar when title changes
|
||||
runOnUiThread { updateSessionTabs() }
|
||||
// title changes propagated via EventBridge
|
||||
}
|
||||
|
||||
override fun onSessionFinished(finishedSession: TerminalSession) {
|
||||
sessionManager.onSessionFinished(finishedSession)
|
||||
}
|
||||
|
||||
override fun onCopyTextToClipboard(
|
||||
session: TerminalSession,
|
||||
text: String,
|
||||
) {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text))
|
||||
}
|
||||
|
||||
override fun onPasteTextFromClipboard(session: TerminalSession?) {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = clipboard.primaryClip?.getItemAt(0)?.text ?: return
|
||||
session?.write(text.toString())
|
||||
}
|
||||
|
||||
override fun onBell(session: TerminalSession) = Unit
|
||||
|
||||
override fun onColorsChanged(session: TerminalSession) = Unit
|
||||
|
||||
override fun onTerminalCursorStateChange(state: Boolean) = Unit
|
||||
|
||||
override fun setTerminalShellPid(
|
||||
session: TerminalSession,
|
||||
pid: Int,
|
||||
) = Unit
|
||||
|
||||
override fun getTerminalCursorStyle(): Int = 0
|
||||
|
||||
override fun logError(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.e(tag, message)
|
||||
}
|
||||
|
||||
override fun logWarn(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.w(tag, message)
|
||||
}
|
||||
|
||||
override fun logInfo(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.i(tag, message)
|
||||
}
|
||||
|
||||
override fun logDebug(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.d(tag, message)
|
||||
}
|
||||
|
||||
override fun logVerbose(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.v(tag, message)
|
||||
}
|
||||
|
||||
override fun logStackTraceWithMessage(
|
||||
tag: String,
|
||||
message: String,
|
||||
e: Exception,
|
||||
) {
|
||||
AppLogger.e(tag, message, e)
|
||||
}
|
||||
|
||||
override fun logStackTrace(
|
||||
tag: String,
|
||||
e: Exception,
|
||||
) {
|
||||
AppLogger.e(tag, "Exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Terminal view callbacks ---
|
||||
|
||||
@Suppress("TooManyFunctions") // Interface implementation requires all methods
|
||||
private inner class OpenClawViewClient : TerminalViewClient {
|
||||
override fun onScale(scale: Float): Float {
|
||||
val currentSize = currentTextSize
|
||||
val newSize = if (scale > 1f) currentSize + 1 else currentSize - 1
|
||||
val clamped = newSize.coerceIn(MIN_TEXT_SIZE, MAX_TEXT_SIZE)
|
||||
currentTextSize = clamped
|
||||
binding.terminalView.setTextSize(clamped)
|
||||
return scale
|
||||
}
|
||||
|
||||
override fun onSingleTapUp(e: MotionEvent) {
|
||||
// Toggle soft keyboard on tap (same as Termux)
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0)
|
||||
}
|
||||
|
||||
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
|
||||
|
||||
override fun shouldEnforceCharBasedInput(): Boolean = true
|
||||
|
||||
override fun getInputMode(): Int = INPUT_MODE_TYPE_NULL
|
||||
|
||||
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
|
||||
|
||||
override fun isTerminalViewSelected(): Boolean = binding.terminalContainer.visibility == View.VISIBLE
|
||||
|
||||
override fun copyModeChanged(copyMode: Boolean) = Unit
|
||||
|
||||
override fun onKeyDown(
|
||||
keyCode: Int,
|
||||
e: KeyEvent,
|
||||
session: TerminalSession,
|
||||
): Boolean = false
|
||||
|
||||
override fun onKeyUp(
|
||||
keyCode: Int,
|
||||
e: KeyEvent,
|
||||
): Boolean = false
|
||||
|
||||
override fun onLongPress(event: MotionEvent): Boolean = false
|
||||
|
||||
override fun readControlKey(): Boolean {
|
||||
val v = ctrlDown
|
||||
if (v) {
|
||||
ctrlDown = false
|
||||
runOnUiThread { updateModifierButton(findViewById(R.id.btnCtrl), false) }
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun readAltKey(): Boolean {
|
||||
val v = altDown
|
||||
if (v) {
|
||||
altDown = false
|
||||
runOnUiThread { updateModifierButton(findViewById(R.id.btnAlt), false) }
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun readShiftKey(): Boolean = false
|
||||
|
||||
override fun readFnKey(): Boolean = false
|
||||
|
||||
override fun onCodePoint(
|
||||
codePoint: Int,
|
||||
ctrlDown: Boolean,
|
||||
session: TerminalSession,
|
||||
): Boolean = false
|
||||
|
||||
override fun onEmulatorSet() = Unit
|
||||
|
||||
override fun logError(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.e(tag, message)
|
||||
}
|
||||
|
||||
override fun logWarn(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.w(tag, message)
|
||||
}
|
||||
|
||||
override fun logInfo(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.i(tag, message)
|
||||
}
|
||||
|
||||
override fun logDebug(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.d(tag, message)
|
||||
}
|
||||
|
||||
override fun logVerbose(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
AppLogger.v(tag, message)
|
||||
}
|
||||
|
||||
override fun logStackTraceWithMessage(
|
||||
tag: String,
|
||||
message: String,
|
||||
e: Exception,
|
||||
) {
|
||||
AppLogger.e(tag, message, e)
|
||||
}
|
||||
|
||||
override fun logStackTrace(
|
||||
tag: String,
|
||||
e: Exception,
|
||||
) {
|
||||
AppLogger.e(tag, "Exception", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
|
||||
/**
|
||||
* Foreground Service that keeps terminal sessions alive when the app is in background.
|
||||
* Uses START_STICKY to restart if killed. targetSdk 28 — no specialUse needed.
|
||||
*/
|
||||
class OpenClawService : Service() {
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val CHANNEL_ID = "openclaw_service"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Keeps OpenClaw terminal sessions running"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val builder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
}
|
||||
|
||||
return builder
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.terminal.TerminalSessionClient
|
||||
|
||||
/**
|
||||
* Multi-terminal session management (§2.6, Phase 1 checklist).
|
||||
* Uses TerminalView.attachSession() for session switching — one TerminalView, many sessions.
|
||||
*/
|
||||
class TerminalSessionManager(
|
||||
private val activity: MainActivity,
|
||||
private val sessionClient: TerminalSessionClient,
|
||||
private val eventBridge: EventBridge,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "SessionManager"
|
||||
private const val TRANSCRIPT_ROWS = 2000
|
||||
}
|
||||
|
||||
private val sessions = mutableListOf<TerminalSession>()
|
||||
private var activeSessionIndex = -1
|
||||
private val finishedSessionIds = mutableSetOf<String>()
|
||||
var onSessionsChanged: (() -> Unit)? = null
|
||||
|
||||
val activeSession: TerminalSession?
|
||||
get() = sessions.getOrNull(activeSessionIndex)
|
||||
|
||||
/**
|
||||
* Create a new terminal session. Returns the session handle.
|
||||
*/
|
||||
fun createSession(): TerminalSession {
|
||||
val env = EnvironmentBuilder.build(activity)
|
||||
val prefix = env["PREFIX"] ?: ""
|
||||
val homeDir = env["HOME"] ?: activity.filesDir.absolutePath
|
||||
val tmpDir = env["TMPDIR"]
|
||||
|
||||
// Ensure HOME and TMP directories exist before starting the shell.
|
||||
// Without this, chdir() fails if bootstrap hasn't been run yet.
|
||||
java.io.File(homeDir).mkdirs()
|
||||
tmpDir?.let { java.io.File(it).mkdirs() }
|
||||
|
||||
val shell =
|
||||
if (java.io.File("$prefix/bin/bash").exists()) {
|
||||
"$prefix/bin/bash"
|
||||
} else if (java.io.File("$prefix/bin/sh").exists()) {
|
||||
"$prefix/bin/sh"
|
||||
} else {
|
||||
"/system/bin/sh"
|
||||
}
|
||||
|
||||
val session =
|
||||
TerminalSession(
|
||||
shell,
|
||||
homeDir,
|
||||
arrayOf<String>(),
|
||||
env.entries.map { "${it.key}=${it.value}" }.toTypedArray(),
|
||||
TRANSCRIPT_ROWS,
|
||||
sessionClient,
|
||||
)
|
||||
|
||||
sessions.add(session)
|
||||
switchSession(sessions.size - 1)
|
||||
|
||||
eventBridge.emit(
|
||||
"session_changed",
|
||||
mapOf("id" to session.mHandle, "action" to "created"),
|
||||
)
|
||||
activity.runOnUiThread { onSessionsChanged?.invoke() }
|
||||
|
||||
AppLogger.i(TAG, "Created session ${session.mHandle} (total: ${sessions.size})")
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to session by index.
|
||||
*/
|
||||
fun switchSession(index: Int) {
|
||||
if (index < 0 || index >= sessions.size) return
|
||||
activeSessionIndex = index
|
||||
val session = sessions[index]
|
||||
activity.runOnUiThread {
|
||||
val terminalView = activity.findViewById<com.termux.view.TerminalView>(R.id.terminalView)
|
||||
terminalView.attachSession(session)
|
||||
terminalView.invalidate()
|
||||
}
|
||||
eventBridge.emit(
|
||||
"session_changed",
|
||||
mapOf("id" to session.mHandle, "action" to "switched"),
|
||||
)
|
||||
activity.runOnUiThread { onSessionsChanged?.invoke() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to session by handle ID.
|
||||
*/
|
||||
fun switchSession(handleId: String) {
|
||||
val index = sessions.indexOfFirst { it.mHandle == handleId }
|
||||
if (index >= 0) switchSession(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a session by handle ID.
|
||||
*/
|
||||
fun getSessionById(handleId: String): TerminalSession? = sessions.find { it.mHandle == handleId }
|
||||
|
||||
/**
|
||||
* Close a session by handle ID.
|
||||
*/
|
||||
fun closeSession(handleId: String) {
|
||||
val index = sessions.indexOfFirst { it.mHandle == handleId }
|
||||
if (index < 0) return
|
||||
|
||||
finishedSessionIds.remove(handleId)
|
||||
val session = sessions.removeAt(index)
|
||||
session.finishIfRunning()
|
||||
|
||||
eventBridge.emit(
|
||||
"session_changed",
|
||||
mapOf("id" to handleId, "action" to "closed"),
|
||||
)
|
||||
|
||||
// Switch to another session if available
|
||||
if (sessions.isNotEmpty()) {
|
||||
val newIndex = (index).coerceAtMost(sessions.size - 1)
|
||||
switchSession(newIndex)
|
||||
} else {
|
||||
activeSessionIndex = -1
|
||||
}
|
||||
|
||||
activity.runOnUiThread { onSessionsChanged?.invoke() }
|
||||
AppLogger.i(TAG, "Closed session $handleId (remaining: ${sessions.size})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a session's process exits.
|
||||
*/
|
||||
fun onSessionFinished(session: TerminalSession) {
|
||||
finishedSessionIds.add(session.mHandle)
|
||||
eventBridge.emit(
|
||||
"session_changed",
|
||||
mapOf("id" to session.mHandle, "action" to "finished"),
|
||||
)
|
||||
activity.runOnUiThread { onSessionsChanged?.invoke() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions info for JsBridge.
|
||||
*/
|
||||
fun getSessionsInfo(): List<Map<String, Any>> =
|
||||
sessions.mapIndexed { index, session ->
|
||||
mapOf(
|
||||
"id" to session.mHandle,
|
||||
"name" to (session.title ?: "Session ${index + 1}"),
|
||||
"active" to (index == activeSessionIndex),
|
||||
"finished" to (session.mHandle in finishedSessionIds),
|
||||
)
|
||||
}
|
||||
|
||||
fun isSessionFinished(handleId: String): Boolean = handleId in finishedSessionIds
|
||||
|
||||
val sessionCount: Int get() = sessions.size
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Resolves download URLs with BuildConfig hardcoded fallback + config.json override (§2.9).
|
||||
*
|
||||
* Priority: cached config.json → remote config.json (5s timeout) → BuildConfig constants
|
||||
*/
|
||||
class UrlResolver(
|
||||
private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val CONFIG_FETCH_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
|
||||
private val configFile =
|
||||
File(
|
||||
context.filesDir,
|
||||
"usr/share/openclaw-app/config.json",
|
||||
)
|
||||
private val gson = Gson()
|
||||
|
||||
suspend fun getBootstrapUrl(): String {
|
||||
val config = loadConfig()
|
||||
return config?.bootstrap?.url ?: BuildConfig.BOOTSTRAP_URL
|
||||
}
|
||||
|
||||
suspend fun getWwwUrl(): String {
|
||||
val config = loadConfig()
|
||||
return config?.www?.url ?: BuildConfig.WWW_URL
|
||||
}
|
||||
|
||||
private suspend fun loadConfig(): RemoteConfig? {
|
||||
// 1. Local cache
|
||||
if (configFile.exists()) {
|
||||
return try {
|
||||
gson.fromJson(configFile.readText(), RemoteConfig::class.java)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remote fetch (5s timeout)
|
||||
return try {
|
||||
withTimeout(CONFIG_FETCH_TIMEOUT_MS) {
|
||||
val json = URL(BuildConfig.CONFIG_URL).readText()
|
||||
configFile.parentFile?.mkdirs()
|
||||
configFile.writeText(json)
|
||||
gson.fromJson(json, RemoteConfig::class.java)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null // BuildConfig fallback
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config data classes ---
|
||||
|
||||
data class RemoteConfig(
|
||||
val version: Int?,
|
||||
val bootstrap: ComponentConfig?,
|
||||
val www: ComponentConfig?,
|
||||
val platforms: List<PlatformConfig>?,
|
||||
val features: Map<String, Boolean>?,
|
||||
)
|
||||
|
||||
data class ComponentConfig(
|
||||
val url: String,
|
||||
val version: String?,
|
||||
@SerializedName("sha256") val sha256: String?,
|
||||
)
|
||||
|
||||
data class PlatformConfig(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val icon: String?,
|
||||
val description: String?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#55ffffff">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/extraKeyDefault" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#55ffffff">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/extraKeyActive" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#111827"
|
||||
android:pathData="M54,54m-54,0a54,54,0,1,1,108,0a54,54,0,1,1,-108,0" />
|
||||
<!-- 4 claw scratch marks, rotated diagonally -->
|
||||
<group
|
||||
android:pivotX="54"
|
||||
android:pivotY="54"
|
||||
android:rotation="-25">
|
||||
<!-- Scratch 1 (outer left, shorter) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M31,22 C28,44,28,64,31,86 C34,64,34,44,31,22Z" />
|
||||
<!-- Scratch 2 (inner left, longer) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M43,16 C40,40,40,64,43,92 C46,64,46,40,43,16Z" />
|
||||
<!-- Scratch 3 (inner right, longer) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M55,16 C52,40,52,64,55,92 C58,64,58,40,55,16Z" />
|
||||
<!-- Scratch 4 (outer right, shorter) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M67,22 C64,44,64,64,67,86 C70,64,70,44,67,22Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M20,19V7H4V19H20ZM20,3C21.1,3 22,3.9 22,5V19C22,20.1 21.1,21 20,21H4C2.9,21 2,20.1 2,19V5C2,3.9 2.9,3 4,3H20ZM13,17V15H18V17H13ZM9.58,13L5.57,9L7,7.59L12.42,13L7,18.41L5.58,17L9.58,13Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/rootContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/terminalBackground">
|
||||
|
||||
<!-- WebView — setup, dashboard, settings, platform selector -->
|
||||
<WebView
|
||||
android:id="@+id/webView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="visible" />
|
||||
|
||||
<!-- Terminal container (TerminalView + Extra Keys bar) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/terminalContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
|
||||
<!-- Session tab bar -->
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/sessionTabBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:scrollbars="none"
|
||||
android:background="@color/tabBarBackground"
|
||||
android:focusable="false"
|
||||
android:focusableInTouchMode="false">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/tabsLayout"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp" />
|
||||
</HorizontalScrollView>
|
||||
|
||||
<!-- Tab bar / terminal separator -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/tabBorder" />
|
||||
|
||||
<!-- TerminalView — native PTY terminal -->
|
||||
<com.termux.view.TerminalView
|
||||
android:id="@+id/terminalView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
|
||||
<!-- Extra Keys — two rows, no scrolling -->
|
||||
<LinearLayout
|
||||
android:id="@+id/extraKeysBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/extraKeysBackground">
|
||||
|
||||
<!-- Row 1: modifier & function keys -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="2dp"
|
||||
android:paddingEnd="2dp">
|
||||
|
||||
<Button android:id="@+id/btnEsc" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="ESC" />
|
||||
<Button android:id="@+id/btnCtrl" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="CTRL" />
|
||||
<Button android:id="@+id/btnAlt" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="ALT" />
|
||||
<Button android:id="@+id/btnTab" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="TAB" />
|
||||
<Button android:id="@+id/btnHome" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="HOME" />
|
||||
<Button android:id="@+id/btnEnd" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="END" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 2: arrow keys & common chars -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="2dp"
|
||||
android:paddingEnd="2dp">
|
||||
|
||||
<Button android:id="@+id/btnLeft" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="◀" />
|
||||
<Button android:id="@+id/btnUp" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▲" />
|
||||
<Button android:id="@+id/btnDown" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▼" />
|
||||
<Button android:id="@+id/btnRight" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="▶" />
|
||||
<Button android:id="@+id/btnDash" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="-" />
|
||||
<Button android:id="@+id/btnPipe" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="|" />
|
||||
<Button android:id="@+id/btnPaste" style="@style/ExtraKeyButton" android:layout_weight="1" android:text="PST" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#1A1A2E</color>
|
||||
<color name="colorPrimaryDark">#16213E</color>
|
||||
<color name="colorAccent">#0F3460</color>
|
||||
<color name="terminalBackground">#000000</color>
|
||||
<color name="terminalForeground">#FFFFFF</color>
|
||||
<color name="extraKeysBackground">#1a1a1a</color>
|
||||
<color name="extraKeyDefault">#333333</color>
|
||||
<color name="extraKeyActive">#58a6ff</color>
|
||||
<color name="extraKeyText">#e0e0e0</color>
|
||||
<color name="extraKeyActiveText">#ffffff</color>
|
||||
|
||||
<!-- Session tab bar -->
|
||||
<color name="tabBarBackground">#161b22</color>
|
||||
<color name="tabInactiveBackground">#21262d</color>
|
||||
<color name="tabActiveBackground">#0d1117</color>
|
||||
<color name="tabTextPrimary">#f0f6fc</color>
|
||||
<color name="tabTextSecondary">#8b949e</color>
|
||||
<color name="tabTextFinished">#484f58</color>
|
||||
<color name="tabAccent">#58a6ff</color>
|
||||
<color name="tabBorder">#30363d</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Claw</string>
|
||||
<string name="notification_channel_id">openclaw_service</string>
|
||||
<string name="notification_channel_name">OpenClaw Service</string>
|
||||
<string name="notification_title">OpenClaw is running</string>
|
||||
<string name="notification_text">Terminal session active</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:windowBackground">@color/terminalBackground</item>
|
||||
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
||||
</style>
|
||||
|
||||
<style name="ExtraKeyButton" parent="Widget.MaterialComponents.Button.TextButton">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">36dp</item>
|
||||
<item name="android:minWidth">42dp</item>
|
||||
<item name="android:paddingStart">8dp</item>
|
||||
<item name="android:paddingEnd">8dp</item>
|
||||
<item name="android:layout_marginStart">2dp</item>
|
||||
<item name="android:layout_marginEnd">2dp</item>
|
||||
<item name="android:textSize">12sp</item>
|
||||
<item name="android:textColor">@color/extraKeyText</item>
|
||||
<item name="backgroundTint">@color/extraKeyDefault</item>
|
||||
<item name="android:textAllCaps">false</item>
|
||||
<item name="android:insetTop">0dp</item>
|
||||
<item name="android:insetBottom">0dp</item>
|
||||
<item name="android:focusable">false</item>
|
||||
<item name="android:focusableInTouchMode">false</item>
|
||||
</style>
|
||||
|
||||
<style name="SessionTabClose">
|
||||
<item name="android:layout_width">20dp</item>
|
||||
<item name="android:layout_height">20dp</item>
|
||||
<item name="android:textSize">14sp</item>
|
||||
<item name="android:textColor">@color/tabTextSecondary</item>
|
||||
<item name="android:background">?attr/selectableItemBackgroundBorderless</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:padding">0dp</item>
|
||||
<item name="android:focusable">false</item>
|
||||
<item name="android:focusableInTouchMode">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import android.util.Log
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AppLoggerTest {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.v(any(), any()) } returns 0
|
||||
every { Log.d(any(), any()) } returns 0
|
||||
every { Log.i(any(), any()) } returns 0
|
||||
every { Log.w(any(), any<String>()) } returns 0
|
||||
every { Log.w(any(), any<String>(), any()) } returns 0
|
||||
every { Log.e(any(), any()) } returns 0
|
||||
every { Log.e(any(), any(), any()) } returns 0
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
unmockkStatic(Log::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v delegates to Log v`() {
|
||||
AppLogger.v("TAG", "msg")
|
||||
verify { Log.v("TAG", "msg") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `d delegates to Log d`() {
|
||||
AppLogger.d("TAG", "msg")
|
||||
verify { Log.d("TAG", "msg") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `i delegates to Log i`() {
|
||||
AppLogger.i("TAG", "msg")
|
||||
verify { Log.i("TAG", "msg") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `w delegates to Log w`() {
|
||||
AppLogger.w("TAG", "msg")
|
||||
verify { Log.w("TAG", "msg") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `w with throwable delegates to Log w`() {
|
||||
val ex = RuntimeException("test")
|
||||
AppLogger.w("TAG", "msg", ex)
|
||||
verify { Log.w("TAG", "msg", ex) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `e delegates to Log e`() {
|
||||
AppLogger.e("TAG", "msg")
|
||||
verify { Log.e("TAG", "msg") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `e with throwable delegates to Log e`() {
|
||||
val ex = RuntimeException("test")
|
||||
AppLogger.e("TAG", "msg", ex)
|
||||
verify { Log.e("TAG", "msg", ex) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.File
|
||||
|
||||
class CommandRunnerTest {
|
||||
@TempDir
|
||||
lateinit var tempDir: File
|
||||
|
||||
// PREFIX="" makes shell path "/bin/sh" — works on both macOS and Linux
|
||||
private val env = mapOf("PREFIX" to "", "PATH" to "/usr/bin:/bin")
|
||||
|
||||
@Test
|
||||
fun `runSync returns stdout for echo command`() {
|
||||
val result = CommandRunner.runSync("echo hello", env, tempDir)
|
||||
assertEquals(0, result.exitCode)
|
||||
assertEquals("hello", result.stdout.trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runSync returns non-zero exit code for failing command`() {
|
||||
val result = CommandRunner.runSync("exit 42", env, tempDir)
|
||||
assertEquals(42, result.exitCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runSync captures stderr`() {
|
||||
val result = CommandRunner.runSync("echo error >&2", env, tempDir)
|
||||
assertEquals(0, result.exitCode)
|
||||
assertEquals("error", result.stderr.trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runSync handles invalid command`() {
|
||||
val result = CommandRunner.runSync("nonexistent_command_xyz", env, tempDir)
|
||||
assertTrue(result.exitCode != 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CommandResult data class holds values correctly`() {
|
||||
val result = CommandRunner.CommandResult(0, "out", "err")
|
||||
assertEquals(0, result.exitCode)
|
||||
assertEquals("out", result.stdout)
|
||||
assertEquals("err", result.stderr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.openclaw.android
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
|
||||
class EnvironmentBuilderTest {
|
||||
private lateinit var env: Map<String, String>
|
||||
private val filesDir = File("/data/data/com.openclaw.android/files")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
env = EnvironmentBuilder.build(filesDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PREFIX points to usr directory`() {
|
||||
assertEquals("${filesDir.absolutePath}/usr", env["PREFIX"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HOME points to home directory`() {
|
||||
assertEquals("${filesDir.absolutePath}/home", env["HOME"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TMPDIR points to tmp directory`() {
|
||||
assertEquals("${filesDir.absolutePath}/tmp", env["TMPDIR"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PATH contains node bin and prefix bin`() {
|
||||
val path = env["PATH"]!!
|
||||
assertTrue(path.contains(".openclaw-android/node/bin"))
|
||||
assertTrue(path.contains("/usr/bin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LD_LIBRARY_PATH is set`() {
|
||||
assertNotNull(env["LD_LIBRARY_PATH"])
|
||||
assertTrue(env["LD_LIBRARY_PATH"]!!.contains("/usr/lib"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `APT_CONFIG points to apt conf`() {
|
||||
assertTrue(env["APT_CONFIG"]!!.endsWith("/etc/apt/apt.conf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIT_CONFIG_NOSYSTEM is set to 1`() {
|
||||
assertEquals("1", env["GIT_CONFIG_NOSYSTEM"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LANG is en_US UTF-8`() {
|
||||
assertEquals("en_US.UTF-8", env["LANG"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TERM is xterm-256color`() {
|
||||
assertEquals("xterm-256color", env["TERM"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OA_GLIBC is set`() {
|
||||
assertEquals("1", env["OA_GLIBC"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user