From e7015b129a25ffb056a25243ff7527e5f7922b92 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 19 May 2026 17:37:40 +0400 Subject: [PATCH] =?UTF-8?q?v2.0.0:=20Built-in=20Termux=20=E2=80=94=20full?= =?UTF-8?q?=20Linux=20environment,=20no=20external=20app=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 + android/app/build.gradle | 4 +- .../main/java/ai/z/chat/BootstrapPlugin.java | 406 ++++++++++++++++++ .../src/main/java/ai/z/chat/MainActivity.java | 1 + .../src/main/java/ai/z/chat/ShellPlugin.java | 33 +- package.json | 2 +- www/index.html | 22 +- www/js/app.js | 126 ++---- 8 files changed, 504 insertions(+), 100 deletions(-) create mode 100644 android/app/src/main/java/ai/z/chat/BootstrapPlugin.java diff --git a/README.md b/README.md index 18c21cc..4db4bef 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,16 @@ data: [DONE] ## Changelog +### v2.0.0 (2026-05-19) +- **Built-in Termux** — full Linux environment inside the app, no external Termux install needed +- One-time ~30MB download of Termux bootstrap (bash, coreutils, apt, 25+ packages) +- Auto-detects CPU architecture (arm64-v8a, armeabi-v7a, x86, x86_64) +- Path patching: fixes all `/data/data/com.termux` references to work from app prefix +- `BootstrapPlugin` — native download, ZIP extraction, symlink creation, path patching +- `ShellPlugin` upgraded — uses bundled `bash` instead of limited `/system/bin/sh` +- Install build tools: `pkg install aapt2 ecj dx apksigner` +- APK stays ~1MB — bootstrap downloaded on first use, never embedded + ### v1.4.0 (2026-05-19) - **Agentic Feedback Loop** — build errors auto-sent back to AI for fixing (up to 3 retries) - **Termux Integration** — auto-detects Termux, uses its `PATH` (aapt2, d8, ecj, tsu/su) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2251d42..3339d3b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "ai.z.chat" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 9 - versionName "1.4.0" + versionCode 10 + versionName "2.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' diff --git a/android/app/src/main/java/ai/z/chat/BootstrapPlugin.java b/android/app/src/main/java/ai/z/chat/BootstrapPlugin.java new file mode 100644 index 0000000..165358a --- /dev/null +++ b/android/app/src/main/java/ai/z/chat/BootstrapPlugin.java @@ -0,0 +1,406 @@ +package ai.z.chat; + +import android.content.Context; +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@CapacitorPlugin(name = "Bootstrap") +public class BootstrapPlugin extends Plugin { + private static final String TAG = "BootstrapPlugin"; + + private static final String BOOTSTRAP_URL_AARCH64 = + "https://github.com/termux/termux-packages/releases/download/bootstrap-2026.02.12-r1%2Bapt.android-7/bootstrap-aarch64.zip"; + private static final String BOOTSTRAP_URL_ARM = + "https://github.com/termux/termux-packages/releases/download/bootstrap-2026.02.12-r1%2Bapt.android-7/bootstrap-arm.zip"; + private static final String BOOTSTRAP_URL_X86_64 = + "https://github.com/termux/termux-packages/releases/download/bootstrap-2026.02.12-r1%2Bapt.android-7/bootstrap-x86_64.zip"; + private static final String BOOTSTRAP_URL_X86 = + "https://github.com/termux/termux-packages/releases/download/bootstrap-2026.02.12-r1%2Bapt.android-7/bootstrap-i686.zip"; + + private static final String TERMUX_PREFIX = "/data/data/com.termux/files/usr"; + private static final int BUFFER_SIZE = 8192; + + private String filesDir; + private String prefixDir; + private String stagingDir; + private String homeDir; + private String binDir; + private boolean isInstalling = false; + + @Override + public void load() { + filesDir = getContext().getFilesDir().getAbsolutePath(); + prefixDir = filesDir + "/usr"; + stagingDir = filesDir + "/usr-staging"; + homeDir = filesDir + "/home"; + binDir = prefixDir + "/bin"; + } + + @PluginMethod + public void getStatus(PluginCall call) { + JSObject status = new JSObject(); + boolean installed = new File(binDir + "/bash").exists() || new File(binDir + "/sh").exists(); + status.put("installed", installed); + status.put("prefixDir", prefixDir); + status.put("binDir", binDir); + status.put("homeDir", homeDir); + status.put("arch", getArch()); + status.put("isInstalling", isInstalling); + if (installed) { + status.put("shellPath", new File(binDir + "/bash").exists() ? binDir + "/bash" : binDir + "/sh"); + } + call.resolve(status); + } + + @PluginMethod + public void install(PluginCall call) { + if (isInstalling) { + call.reject("Installation already in progress"); + return; + } + + boolean installed = new File(binDir + "/bash").exists(); + if (installed) { + call.resolve(new JSObject().put("installed", true).put("message", "Already installed")); + return; + } + + call.setKeepAlive(true); + isInstalling = true; + + new Thread(() -> { + try { + doInstall(call); + } catch (Exception e) { + Log.e(TAG, "Install failed", e); + isInstalling = false; + try { + call.reject("Install failed: " + e.getMessage()); + } catch (Exception ignored) {} + } + }).start(); + } + + private void doInstall(PluginCall call) throws Exception { + String arch = getArch(); + String bootstrapUrl = getBootstrapUrl(arch); + + sendProgress(call, "Downloading bootstrap for " + arch + "...", 0); + + File zipFile = new File(getContext().getCacheDir(), "bootstrap.zip"); + downloadFile(bootstrapUrl, zipFile, (downloaded, total) -> { + int percent = total > 0 ? (int)(downloaded * 100 / total) : 0; + String sizeMB = String.format("%.1f", downloaded / (1024.0 * 1024.0)); + sendProgress(call, "Downloading... " + sizeMB + " MB (" + percent + "%)", percent / 3); + }); + + sendProgress(call, "Extracting bootstrap...", 35); + + new File(stagingDir).mkdirs(); + List symlinks = extractBootstrap(zipFile, stagingDir, (extracted, total) -> { + int percent = 35 + (int)(extracted * 30 / Math.max(total, 1)); + sendProgress(call, "Extracting... " + extracted + "/" + total + " files", percent); + }); + + sendProgress(call, "Creating symlinks (" + symlinks.size() + ")...", 68); + createSymlinks(symlinks, stagingDir); + + sendProgress(call, "Patching paths...", 75); + patchPaths(stagingDir); + + sendProgress(call, "Setting permissions...", 85); + setPermissions(new File(stagingDir, "bin")); + setPermissions(new File(stagingDir, "libexec")); + + new File(homeDir).mkdirs(); + new File(prefixDir + "/tmp").mkdirs(); + + sendProgress(call, "Finalizing...", 92); + + File staging = new File(stagingDir); + File prefix = new File(prefixDir); + if (prefix.exists()) { + deleteRecursive(prefix); + } + boolean renamed = staging.renameTo(prefix); + if (!renamed) { + throw new RuntimeException("Failed to rename staging to prefix"); + } + + writeEnvFile(); + writeProfileFile(); + + zipFile.delete(); + + sendProgress(call, "Termux environment ready!", 100); + isInstalling = false; + + JSObject result = new JSObject(); + result.put("installed", true); + result.put("prefixDir", prefixDir); + result.put("shellPath", binDir + "/bash"); + result.put("message", "Bootstrap installed successfully"); + call.resolve(result); + } + + private void downloadFile(String urlStr, File outputFile, ProgressCallback callback) throws Exception { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + conn.setRequestProperty("Accept-Language", "en-US,en"); + conn.connect(); + + int total = conn.getContentLength(); + BufferedInputStream in = new BufferedInputStream(conn.getInputStream()); + FileOutputStream out = new FileOutputStream(outputFile); + + byte[] buffer = new byte[BUFFER_SIZE]; + long downloaded = 0; + int read; + long lastNotify = 0; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + downloaded += read; + long now = System.currentTimeMillis(); + if (callback != null && now - lastNotify > 500) { + callback.onProgress(downloaded, total); + lastNotify = now; + } + } + + out.flush(); + out.close(); + in.close(); + conn.disconnect(); + } + + private List extractBootstrap(File zipFile, String destDir, ExtractCallback callback) throws Exception { + List symlinks = new ArrayList<>(); + ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile)); + ZipEntry entry; + int extracted = 0; + int total = 0; + + java.util.Enumeration entries = java.util.Collections.emptyEnumeration(); + + java.util.zip.ZipFile zf = new java.util.zip.ZipFile(zipFile); + total = zf.size(); + zf.close(); + + zis = new ZipInputStream(new FileInputStream(zipFile)); + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + if (name.equals("SYMLINKS.txt")) { + BufferedReader reader = new BufferedReader(new InputStreamReader(zis)); + String line; + while ((line = reader.readLine()) != null) { + String[] parts = line.split("\u2190"); + if (parts.length == 2) { + String target = parts[0].replace(TERMUX_PREFIX, destDir); + String linkPath = destDir + "/" + parts[1]; + symlinks.add(new String[]{target, linkPath}); + } + } + } else { + File outFile = new File(destDir, name); + if (entry.isDirectory()) { + outFile.mkdirs(); + } else { + File parent = outFile.getParentFile(); + if (parent != null) parent.mkdirs(); + FileOutputStream fos = new FileOutputStream(outFile); + byte[] buf = new byte[BUFFER_SIZE]; + int len; + while ((len = zis.read(buf)) > 0) { + fos.write(buf, 0, len); + } + fos.close(); + } + } + extracted++; + if (callback != null && extracted % 50 == 0) { + callback.onProgress(extracted, total); + } + zis.closeEntry(); + } + zis.close(); + return symlinks; + } + + private void createSymlinks(List symlinks, String stagingDir) { + for (String[] link : symlinks) { + try { + String target = link[0]; + String linkPath = link[1]; + File linkFile = new File(linkPath); + File parent = linkFile.getParentFile(); + if (parent != null && !parent.exists()) parent.mkdirs(); + if (linkFile.exists()) linkFile.delete(); + android.system.Os.symlink(target, linkPath); + } catch (Exception e) { + Log.w(TAG, "Symlink failed: " + link[1] + " -> " + link[0] + ": " + e.getMessage()); + } + } + } + + private void patchPaths(String dir) { + try { + ProcessBuilder pb = new ProcessBuilder("find", dir, "-type", "f", + "(", "-name", "*.sh", "-o", "-name", "*.conf", "-o", "-name", "*.cfg", + "-o", "-name", "*.txt", "-o", "-name", "*.env", "-o", "-name", "properties.sh", + "-o", "-name", "profile", "-o", "-name", "bashrc", "-o", "-name", "*.profile", ")"); + pb.redirectErrorStream(true); + Process p = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + List files = new ArrayList<>(); + String line; + while ((line = reader.readLine()) != null) { + files.add(line); + } + p.waitFor(); + + String ourPrefix = filesDir + "/usr"; + for (String filePath : files) { + try { + ProcessBuilder sedPb = new ProcessBuilder("sed", "-i", + "s|/data/data/com.termux/files/usr|" + ourPrefix + "|g;" + + "s|/data/data/com.termux/files/home|" + homeDir + "|g;" + + "s|/data/data/com.termux|" + filesDir + "|g", + filePath); + sedPb.start().waitFor(); + } catch (Exception e) { + Log.w(TAG, "Patch failed for " + filePath + ": " + e.getMessage()); + } + } + + ProcessBuilder ldSo = new ProcessBuilder("sed", "-i", + "s|/data/data/com.termux/files/usr|" + ourPrefix + "|g", + dir + "/etc/ld.so.conf"); + ldSo.start().waitFor(); + } catch (Exception e) { + Log.w(TAG, "Path patching error: " + e.getMessage()); + } + } + + private void setPermissions(File dir) { + if (!dir.exists()) return; + File[] children = dir.listFiles(); + if (children == null) return; + for (File f : children) { + if (f.isFile()) { + f.setExecutable(true, false); + f.setReadable(true, false); + } + } + } + + private void writeEnvFile() { + try { + File envFile = new File(prefixDir + "/etc/termux.env"); + envFile.getParentFile().mkdirs(); + java.io.FileWriter writer = new java.io.FileWriter(envFile); + writer.write("HOME=" + homeDir + "\n"); + writer.write("PREFIX=" + prefixDir + "\n"); + writer.write("PATH=" + binDir + "\n"); + writer.write("TMPDIR=" + prefixDir + "/tmp\n"); + writer.write("TERM=xterm-256color\n"); + writer.write("LANG=en_US.UTF-8\n"); + writer.write("BOOTSTRAP=zaichat\n"); + writer.close(); + } catch (Exception e) { + Log.w(TAG, "Failed to write env file: " + e.getMessage()); + } + } + + private void writeProfileFile() { + try { + File profileDir = new File(prefixDir + "/etc/profile.d"); + profileDir.mkdirs(); + File profile = new File(profileDir, "zai-chat.sh"); + java.io.FileWriter writer = new java.io.FileWriter(profile); + writer.write("export HOME=" + homeDir + "\n"); + writer.write("export PREFIX=" + prefixDir + "\n"); + writer.write("export PATH=" + binDir + "\n"); + writer.write("export TMPDIR=" + prefixDir + "/tmp\n"); + writer.write("export TERM=xterm-256color\n"); + writer.write("export LANG=en_US.UTF-8\n"); + writer.write("export ANDROID_HOME=" + filesDir + "/tools\n"); + writer.write("export PROJECTS=" + filesDir + "/projects\n"); + writer.write("export PS1='\\$ '\n"); + writer.close(); + profile.setExecutable(true, false); + } catch (Exception e) { + Log.w(TAG, "Failed to write profile: " + e.getMessage()); + } + } + + private void sendProgress(PluginCall call, String message, int percent) { + JSObject event = new JSObject(); + event.put("message", message); + event.put("percent", percent); + notifyListeners("bootstrap-progress", event); + } + + private String getArch() { + String abi = android.os.Build.SUPPORTED_ABIS[0]; + switch (abi) { + case "arm64-v8a": return "aarch64"; + case "armeabi-v7a": return "arm"; + case "x86_64": return "x86_64"; + case "x86": return "i686"; + default: return "aarch64"; + } + } + + private String getBootstrapUrl(String arch) { + switch (arch) { + case "aarch64": return BOOTSTRAP_URL_AARCH64; + case "arm": return BOOTSTRAP_URL_ARM; + case "x86_64": return BOOTSTRAP_URL_X86_64; + case "i686": return BOOTSTRAP_URL_X86; + default: return BOOTSTRAP_URL_AARCH64; + } + } + + private void deleteRecursive(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursive(child); + } + } + } + file.delete(); + } + + interface ProgressCallback { + void onProgress(long downloaded, long total); + } + + interface ExtractCallback { + void onProgress(int extracted, int total); + } +} diff --git a/android/app/src/main/java/ai/z/chat/MainActivity.java b/android/app/src/main/java/ai/z/chat/MainActivity.java index 6a62c7b..2a8f7c0 100644 --- a/android/app/src/main/java/ai/z/chat/MainActivity.java +++ b/android/app/src/main/java/ai/z/chat/MainActivity.java @@ -10,6 +10,7 @@ public class MainActivity extends BridgeActivity { registerPlugin(ShellPlugin.class); registerPlugin(InstallerPlugin.class); registerPlugin(WakePlugin.class); + registerPlugin(BootstrapPlugin.class); super.onCreate(savedInstanceState); } } diff --git a/android/app/src/main/java/ai/z/chat/ShellPlugin.java b/android/app/src/main/java/ai/z/chat/ShellPlugin.java index 48d4bbb..5d7dc4e 100644 --- a/android/app/src/main/java/ai/z/chat/ShellPlugin.java +++ b/android/app/src/main/java/ai/z/chat/ShellPlugin.java @@ -29,16 +29,30 @@ public class ShellPlugin extends Plugin { private String homeDir = null; private String toolsDir = null; private String projectsDir = null; + private String prefixDir = null; + private String shellPath = null; @Override public void load() { homeDir = getContext().getFilesDir().getAbsolutePath(); toolsDir = homeDir + "/tools"; projectsDir = homeDir + "/projects"; + prefixDir = homeDir + "/usr"; currentCwd = homeDir; new File(toolsDir).mkdirs(); new File(projectsDir).mkdirs(); new File(homeDir + "/bin").mkdirs(); + new File(homeDir + "/tmp").mkdirs(); + + File bash = new File(prefixDir + "/bin/bash"); + File sh = new File(prefixDir + "/bin/sh"); + if (bash.exists()) { + shellPath = bash.getAbsolutePath(); + } else if (sh.exists()) { + shellPath = sh.getAbsolutePath(); + } else { + shellPath = "/system/bin/sh"; + } } @PluginMethod @@ -57,7 +71,8 @@ public class ShellPlugin extends Plugin { try { String[] env = buildEnv(); - ProcessBuilder pb = new ProcessBuilder("sh", "-c", command); + String shell = shellPath != null ? shellPath : "sh"; + ProcessBuilder pb = new ProcessBuilder(shell, "-c", command); pb.directory(new File(cwd)); pb.environment().putAll(toEnvMap(env)); pb.redirectErrorStream(true); @@ -214,9 +229,11 @@ public class ShellPlugin extends Plugin { } private String[] buildEnv() { + String ourBin = prefixDir + "/bin"; String termuxBin = "/data/data/com.termux/files/usr/bin"; String termuxPrefix = "/data/data/com.termux/files/usr"; boolean hasTermux = new File(termuxBin).isDirectory(); + boolean hasOurPrefix = new File(ourBin + "/bash").exists() || new File(ourBin + "/sh").exists(); String toolsBin = toolsDir + "/bin"; String toolsUsrBin = toolsDir + "/usr/bin"; @@ -224,23 +241,31 @@ public class ShellPlugin extends Plugin { String systemPath = System.getenv("PATH"); StringBuilder pathBuilder = new StringBuilder(); + if (hasOurPrefix) pathBuilder.append(ourBin).append(":"); pathBuilder.append(appBin).append(":"); pathBuilder.append(toolsBin).append(":"); pathBuilder.append(toolsUsrBin).append(":"); if (hasTermux) pathBuilder.append(termuxBin).append(":"); pathBuilder.append(systemPath); + String prefix = hasOurPrefix ? prefixDir : (hasTermux ? termuxPrefix : toolsDir + "/usr"); + String home = hasOurPrefix ? homeDir + "/home" : homeDir; + java.util.List envList = new java.util.ArrayList<>(); - envList.add("HOME=" + homeDir); + envList.add("HOME=" + home); envList.add("PATH=" + pathBuilder.toString()); - envList.add("PREFIX=" + (hasTermux ? termuxPrefix : toolsDir + "/usr")); - envList.add("TMPDIR=" + homeDir + "/tmp"); + envList.add("PREFIX=" + prefix); + envList.add("TMPDIR=" + (hasOurPrefix ? prefixDir + "/tmp" : homeDir + "/tmp")); envList.add("TERM=xterm-256color"); envList.add("LANG=en_US.UTF-8"); envList.add("ANDROID_HOME=" + toolsDir); envList.add("ANDROID_SDK_ROOT=" + toolsDir); envList.add("JAVA_HOME=" + toolsDir + "/java"); envList.add("PROJECTS=" + projectsDir); + if (hasOurPrefix) { + envList.add("LD_LIBRARY_PATH=" + prefixDir + "/lib"); + envList.add("BOOTSTRAP=zaichat"); + } if (hasTermux) { envList.add("TERMUX_VERSION=" + getTermuxVersion()); envList.add("LD_LIBRARY_PATH=" + termuxPrefix + "/lib"); diff --git a/package.json b/package.json index d45145f..5140706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zai-chat", - "version": "1.4.0", + "version": "2.0.0", "description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan", "main": "index.js", "scripts": { diff --git a/www/index.html b/www/index.html index e9ab620..e0f5e30 100644 --- a/www/index.html +++ b/www/index.html @@ -166,8 +166,10 @@

Set up on-device build tools

-

Downloads build tools to compile & install APKs directly on your device.

-

Required: ~50MB download (aapt2, d8, ecj, android.jar, apksigner)

+

Downloads and sets up a complete Termux Linux environment inside the app.

+

No external apps needed — bash, coreutils, package manager all included.

+

Download size: ~30MB (one-time). Architecture auto-detected.

+

After install, use pkg install aapt2 ecj dx apksigner to add build tools.