v2.0.0: Built-in Termux — full Linux environment, no external app needed
This commit is contained in:
10
README.md
10
README.md
@@ -631,6 +631,16 @@ data: [DONE]
|
|||||||
|
|
||||||
## Changelog
|
## 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)
|
### v1.4.0 (2026-05-19)
|
||||||
- **Agentic Feedback Loop** — build errors auto-sent back to AI for fixing (up to 3 retries)
|
- **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)
|
- **Termux Integration** — auto-detects Termux, uses its `PATH` (aapt2, d8, ecj, tsu/su)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "ai.z.chat"
|
applicationId "ai.z.chat"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 9
|
versionCode 10
|
||||||
versionName "1.4.0"
|
versionName "2.0.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
|||||||
406
android/app/src/main/java/ai/z/chat/BootstrapPlugin.java
Normal file
406
android/app/src/main/java/ai/z/chat/BootstrapPlugin.java
Normal file
@@ -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<String[]> 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<String[]> extractBootstrap(File zipFile, String destDir, ExtractCallback callback) throws Exception {
|
||||||
|
List<String[]> symlinks = new ArrayList<>();
|
||||||
|
ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile));
|
||||||
|
ZipEntry entry;
|
||||||
|
int extracted = 0;
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
java.util.Enumeration<java.util.zip.ZipEntry> 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<String[]> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
registerPlugin(ShellPlugin.class);
|
registerPlugin(ShellPlugin.class);
|
||||||
registerPlugin(InstallerPlugin.class);
|
registerPlugin(InstallerPlugin.class);
|
||||||
registerPlugin(WakePlugin.class);
|
registerPlugin(WakePlugin.class);
|
||||||
|
registerPlugin(BootstrapPlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,30 @@ public class ShellPlugin extends Plugin {
|
|||||||
private String homeDir = null;
|
private String homeDir = null;
|
||||||
private String toolsDir = null;
|
private String toolsDir = null;
|
||||||
private String projectsDir = null;
|
private String projectsDir = null;
|
||||||
|
private String prefixDir = null;
|
||||||
|
private String shellPath = null;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void load() {
|
public void load() {
|
||||||
homeDir = getContext().getFilesDir().getAbsolutePath();
|
homeDir = getContext().getFilesDir().getAbsolutePath();
|
||||||
toolsDir = homeDir + "/tools";
|
toolsDir = homeDir + "/tools";
|
||||||
projectsDir = homeDir + "/projects";
|
projectsDir = homeDir + "/projects";
|
||||||
|
prefixDir = homeDir + "/usr";
|
||||||
currentCwd = homeDir;
|
currentCwd = homeDir;
|
||||||
new File(toolsDir).mkdirs();
|
new File(toolsDir).mkdirs();
|
||||||
new File(projectsDir).mkdirs();
|
new File(projectsDir).mkdirs();
|
||||||
new File(homeDir + "/bin").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
|
@PluginMethod
|
||||||
@@ -57,7 +71,8 @@ public class ShellPlugin extends Plugin {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String[] env = buildEnv();
|
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.directory(new File(cwd));
|
||||||
pb.environment().putAll(toEnvMap(env));
|
pb.environment().putAll(toEnvMap(env));
|
||||||
pb.redirectErrorStream(true);
|
pb.redirectErrorStream(true);
|
||||||
@@ -214,9 +229,11 @@ public class ShellPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String[] buildEnv() {
|
private String[] buildEnv() {
|
||||||
|
String ourBin = prefixDir + "/bin";
|
||||||
String termuxBin = "/data/data/com.termux/files/usr/bin";
|
String termuxBin = "/data/data/com.termux/files/usr/bin";
|
||||||
String termuxPrefix = "/data/data/com.termux/files/usr";
|
String termuxPrefix = "/data/data/com.termux/files/usr";
|
||||||
boolean hasTermux = new File(termuxBin).isDirectory();
|
boolean hasTermux = new File(termuxBin).isDirectory();
|
||||||
|
boolean hasOurPrefix = new File(ourBin + "/bash").exists() || new File(ourBin + "/sh").exists();
|
||||||
|
|
||||||
String toolsBin = toolsDir + "/bin";
|
String toolsBin = toolsDir + "/bin";
|
||||||
String toolsUsrBin = toolsDir + "/usr/bin";
|
String toolsUsrBin = toolsDir + "/usr/bin";
|
||||||
@@ -224,23 +241,31 @@ public class ShellPlugin extends Plugin {
|
|||||||
String systemPath = System.getenv("PATH");
|
String systemPath = System.getenv("PATH");
|
||||||
|
|
||||||
StringBuilder pathBuilder = new StringBuilder();
|
StringBuilder pathBuilder = new StringBuilder();
|
||||||
|
if (hasOurPrefix) pathBuilder.append(ourBin).append(":");
|
||||||
pathBuilder.append(appBin).append(":");
|
pathBuilder.append(appBin).append(":");
|
||||||
pathBuilder.append(toolsBin).append(":");
|
pathBuilder.append(toolsBin).append(":");
|
||||||
pathBuilder.append(toolsUsrBin).append(":");
|
pathBuilder.append(toolsUsrBin).append(":");
|
||||||
if (hasTermux) pathBuilder.append(termuxBin).append(":");
|
if (hasTermux) pathBuilder.append(termuxBin).append(":");
|
||||||
pathBuilder.append(systemPath);
|
pathBuilder.append(systemPath);
|
||||||
|
|
||||||
|
String prefix = hasOurPrefix ? prefixDir : (hasTermux ? termuxPrefix : toolsDir + "/usr");
|
||||||
|
String home = hasOurPrefix ? homeDir + "/home" : homeDir;
|
||||||
|
|
||||||
java.util.List<String> envList = new java.util.ArrayList<>();
|
java.util.List<String> envList = new java.util.ArrayList<>();
|
||||||
envList.add("HOME=" + homeDir);
|
envList.add("HOME=" + home);
|
||||||
envList.add("PATH=" + pathBuilder.toString());
|
envList.add("PATH=" + pathBuilder.toString());
|
||||||
envList.add("PREFIX=" + (hasTermux ? termuxPrefix : toolsDir + "/usr"));
|
envList.add("PREFIX=" + prefix);
|
||||||
envList.add("TMPDIR=" + homeDir + "/tmp");
|
envList.add("TMPDIR=" + (hasOurPrefix ? prefixDir + "/tmp" : homeDir + "/tmp"));
|
||||||
envList.add("TERM=xterm-256color");
|
envList.add("TERM=xterm-256color");
|
||||||
envList.add("LANG=en_US.UTF-8");
|
envList.add("LANG=en_US.UTF-8");
|
||||||
envList.add("ANDROID_HOME=" + toolsDir);
|
envList.add("ANDROID_HOME=" + toolsDir);
|
||||||
envList.add("ANDROID_SDK_ROOT=" + toolsDir);
|
envList.add("ANDROID_SDK_ROOT=" + toolsDir);
|
||||||
envList.add("JAVA_HOME=" + toolsDir + "/java");
|
envList.add("JAVA_HOME=" + toolsDir + "/java");
|
||||||
envList.add("PROJECTS=" + projectsDir);
|
envList.add("PROJECTS=" + projectsDir);
|
||||||
|
if (hasOurPrefix) {
|
||||||
|
envList.add("LD_LIBRARY_PATH=" + prefixDir + "/lib");
|
||||||
|
envList.add("BOOTSTRAP=zaichat");
|
||||||
|
}
|
||||||
if (hasTermux) {
|
if (hasTermux) {
|
||||||
envList.add("TERMUX_VERSION=" + getTermuxVersion());
|
envList.add("TERMUX_VERSION=" + getTermuxVersion());
|
||||||
envList.add("LD_LIBRARY_PATH=" + termuxPrefix + "/lib");
|
envList.add("LD_LIBRARY_PATH=" + termuxPrefix + "/lib");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zai-chat",
|
"name": "zai-chat",
|
||||||
"version": "1.4.0",
|
"version": "2.0.0",
|
||||||
"description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan",
|
"description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -166,8 +166,10 @@
|
|||||||
<p class="subtitle">Set up on-device build tools</p>
|
<p class="subtitle">Set up on-device build tools</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="devsetup-status" class="devsetup-status">
|
<div id="devsetup-status" class="devsetup-status">
|
||||||
<p>Downloads build tools to compile & install APKs directly on your device.</p>
|
<p>Downloads and sets up a complete <strong>Termux Linux environment</strong> inside the app.</p>
|
||||||
<p>Required: ~50MB download (aapt2, d8, ecj, android.jar, apksigner)</p>
|
<p>No external apps needed — bash, coreutils, package manager all included.</p>
|
||||||
|
<p>Download size: ~30MB (one-time). Architecture auto-detected.</p>
|
||||||
|
<p>After install, use <code>pkg install aapt2 ecj dx apksigner</code> to add build tools.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="devsetup-progress" style="display:none">
|
<div id="devsetup-progress" style="display:none">
|
||||||
<div class="devsetup-progress-bar">
|
<div class="devsetup-progress-bar">
|
||||||
@@ -272,13 +274,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>About</h3>
|
<h3>About</h3>
|
||||||
<p class="about-text">Z.AI Chat v1.4.0</p>
|
<p class="about-text">Z.AI Chat v2.0.0</p>
|
||||||
<p class="about-text">Built with Z.AI SDK & GLM-5.1</p>
|
<p class="about-text">Built with Z.AI SDK & GLM-5.1</p>
|
||||||
<p class="about-text">Compatible with Android 15/16</p>
|
<p class="about-text">Compatible with Android 15/16</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Changelog</h3>
|
<h3>Changelog</h3>
|
||||||
<ul class="changelog-list">
|
<ul class="changelog-list">
|
||||||
|
<li>
|
||||||
|
<span class="changelog-version">v2.0.0</span>
|
||||||
|
<span class="changelog-date">2026-05-19</span>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Built-in Termux</strong> — full Linux environment inside the app, no external Termux needed</li>
|
||||||
|
<li>One-time download (~30MB) of Termux bootstrap (bash, coreutils, apt package manager)</li>
|
||||||
|
<li>Auto-detects CPU architecture (ARM64, ARM, x86, x86_64)</li>
|
||||||
|
<li>Path patching — fixes all Termux paths to work from our app prefix</li>
|
||||||
|
<li>BootstrapPlugin — native download, extract, symlink, patch pipeline</li>
|
||||||
|
<li>ShellPlugin upgraded — uses bundled bash instead of limited /system/bin/sh</li>
|
||||||
|
<li>Install build tools via: <code>pkg install aapt2 ecj dx apksigner</code></li>
|
||||||
|
<li>APK stays tiny (~1MB) — bootstrap downloaded on first use, not embedded</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="changelog-version">v1.4.0</span>
|
<span class="changelog-version">v1.4.0</span>
|
||||||
<span class="changelog-date">2026-05-19</span>
|
<span class="changelog-date">2026-05-19</span>
|
||||||
|
|||||||
126
www/js/app.js
126
www/js/app.js
@@ -1043,6 +1043,7 @@
|
|||||||
var Shell = null;
|
var Shell = null;
|
||||||
var Installer = null;
|
var Installer = null;
|
||||||
var Wake = null;
|
var Wake = null;
|
||||||
|
var Bootstrap = null;
|
||||||
var termState = {
|
var termState = {
|
||||||
history: [],
|
history: [],
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
@@ -1062,10 +1063,12 @@
|
|||||||
Shell = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Shell;
|
Shell = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Shell;
|
||||||
Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer;
|
Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer;
|
||||||
Wake = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Wake;
|
Wake = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Wake;
|
||||||
|
Bootstrap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Bootstrap;
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
if (!Shell) console.warn('Shell plugin not available');
|
if (!Shell) console.warn('Shell plugin not available');
|
||||||
if (!Installer) console.warn('Installer plugin not available');
|
if (!Installer) console.warn('Installer plugin not available');
|
||||||
if (!Wake) console.warn('Wake plugin not available');
|
if (!Wake) console.warn('Wake plugin not available');
|
||||||
|
if (!Bootstrap) console.warn('Bootstrap plugin not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setWakeLock(on) {
|
async function setWakeLock(on) {
|
||||||
@@ -1710,17 +1713,16 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function checkDevTools() {
|
async function checkDevTools() {
|
||||||
if (!Shell) return false;
|
if (!Bootstrap) return false;
|
||||||
try {
|
try {
|
||||||
var result = await shellExec('which aapt2 2>/dev/null && echo "OK" || echo "MISSING"', termState.homeDir, false);
|
var status = await Bootstrap.getStatus();
|
||||||
termState.devToolsInstalled = result.output && result.output.indexOf('OK') >= 0;
|
return status.installed === true;
|
||||||
return termState.devToolsInstalled;
|
|
||||||
} catch(e) { return false; }
|
} catch(e) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupDevTools() {
|
async function setupDevTools() {
|
||||||
if (!Shell) {
|
if (!Bootstrap) {
|
||||||
alert('Shell plugin not available');
|
alert('Bootstrap plugin not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1734,94 +1736,38 @@
|
|||||||
btn.querySelector('.btn-text').textContent = 'Installing...';
|
btn.querySelector('.btn-text').textContent = 'Installing...';
|
||||||
btn.querySelector('.btn-loader').style.display = 'inline-block';
|
btn.querySelector('.btn-loader').style.display = 'inline-block';
|
||||||
progress.style.display = 'block';
|
progress.style.display = 'block';
|
||||||
|
progressText.textContent = 'Starting...';
|
||||||
|
|
||||||
var toolsDir = termState.toolsDir || (termState.homeDir + '/tools');
|
Bootstrap.addListener('bootstrap-progress', function(event) {
|
||||||
var binDir = toolsDir + '/bin';
|
if (progressFill) progressFill.style.width = event.percent + '%';
|
||||||
var steps = [
|
if (progressText) progressText.textContent = event.message;
|
||||||
{ label: 'Creating directories...', cmd: 'mkdir -p ' + binDir + ' ' + toolsDir + '/lib ' + toolsDir + '/java' },
|
});
|
||||||
{ label: 'Setting up shell...', cmd: 'cp /system/bin/sh ' + binDir + '/sh 2>/dev/null; chmod +x ' + binDir + '/* 2>/dev/null; echo OK' },
|
|
||||||
{ label: 'Checking environment...', cmd: 'ls -la ' + binDir + '/ && echo "Environment ready"' }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (var i = 0; i < steps.length; i++) {
|
|
||||||
progressText.textContent = steps[i].label;
|
|
||||||
progressFill.style.width = ((i + 1) / (steps.length + 1) * 100) + '%';
|
|
||||||
var result = await shellExec(steps[i].cmd, termState.homeDir, false);
|
|
||||||
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
|
||||||
progressText.textContent = 'Warning: ' + steps[i].label + ' had issues';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progressText.textContent = 'Writing setup scripts...';
|
|
||||||
progressFill.style.width = '80%';
|
|
||||||
|
|
||||||
var setupScript = '#!/system/bin/sh\n' +
|
|
||||||
'TOOLS_DIR="' + toolsDir + '"\n' +
|
|
||||||
'BIN_DIR="' + binDir + '"\n' +
|
|
||||||
'echo "[*] Z.AI Dev Tools Setup"\n' +
|
|
||||||
'echo "[*] Tools directory: $TOOLS_DIR"\n' +
|
|
||||||
'echo "[*] For full build support, install these via Termux:"\n' +
|
|
||||||
'echo " pkg install aapt2 openjdk-17 dx ecj"\n' +
|
|
||||||
'echo ""\n' +
|
|
||||||
'echo "[*] Checking available tools..."\n' +
|
|
||||||
'for tool in aapt2 d8 ecj java apksigner zipalign; do\n' +
|
|
||||||
' if which $tool 2>/dev/null; then\n' +
|
|
||||||
' echo " [+] $tool: $(which $tool)"\n' +
|
|
||||||
' else\n' +
|
|
||||||
' echo " [-] $tool: not found"\n' +
|
|
||||||
' fi\n' +
|
|
||||||
'done\n' +
|
|
||||||
'echo ""\n' +
|
|
||||||
'echo "[*] For on-device APK building, you need:"\n' +
|
|
||||||
'echo " 1. Install Termux from F-Droid or GitHub"\n' +
|
|
||||||
'echo " 2. In Termux: pkg install aapt2 openjdk-17 dx ecj apksigner"\n' +
|
|
||||||
'echo " 3. Set TOOLS_PATH in terminal to point to Termux binaries"\n' +
|
|
||||||
'echo ""\n' +
|
|
||||||
'echo "[*] Device info:"\n' +
|
|
||||||
'uname -a\n' +
|
|
||||||
'echo "Arch: $(uname -m)"\n' +
|
|
||||||
'echo "[*] Done"\n';
|
|
||||||
|
|
||||||
await shellWriteFile(toolsDir + '/setup.sh', setupScript);
|
|
||||||
await shellExec('chmod +x ' + toolsDir + '/setup.sh', termState.homeDir, false);
|
|
||||||
|
|
||||||
var projectTemplate = '#!/system/bin/sh\n' +
|
|
||||||
'# Z.AI Quick Project Creator\n' +
|
|
||||||
'PROJECT_NAME="${1:-myapp}"\n' +
|
|
||||||
'PROJECT_DIR="' + (termState.projectsDir || termState.homeDir + '/projects') + '/$PROJECT_NAME"\n' +
|
|
||||||
'mkdir -p "$PROJECT_DIR"/app/src/main/java/ai/z/app\n' +
|
|
||||||
'mkdir -p "$PROJECT_DIR"/app/src/main/res/values\n' +
|
|
||||||
'mkdir -p "$PROJECT_DIR"/app/src/main/res/layout\n' +
|
|
||||||
'mkdir -p "$PROJECT_DIR"/app/src/main/res/mipmap-hdpi\n' +
|
|
||||||
'mkdir -p "$PROJECT_DIR"/build\n' +
|
|
||||||
'# AndroidManifest.xml\n' +
|
|
||||||
'cat > "$PROJECT_DIR"/app/src/main/AndroidManifest.xml << \'MANIFEST\'\n' +
|
|
||||||
'<?xml version="1.0" encoding="utf-8"?>\n' +
|
|
||||||
'<manifest xmlns:android="http://schemas.android.com/apk/res/android"\n' +
|
|
||||||
' package="ai.z.app">\n' +
|
|
||||||
' <application android:label="$PROJECT_NAME" android:theme="@android:style/Theme.Material.Light">\n' +
|
|
||||||
' <activity android:name=".MainActivity" android:exported="true">\n' +
|
|
||||||
' <intent-filter>\n' +
|
|
||||||
' <action android:name="android.intent.action.MAIN"/>\n' +
|
|
||||||
' <category android:name="android.intent.category.LAUNCHER"/>\n' +
|
|
||||||
' </intent-filter>\n' +
|
|
||||||
' </activity>\n' +
|
|
||||||
' </application>\n' +
|
|
||||||
'</manifest>\n' +
|
|
||||||
'MANIFEST\n' +
|
|
||||||
'echo "[OK] Project created: $PROJECT_DIR"\n' +
|
|
||||||
'echo "[*] Next: Ask AI to generate the Java code, then build with Deploy"\n';
|
|
||||||
|
|
||||||
await shellWriteFile(toolsDir + '/create-project.sh', projectTemplate);
|
|
||||||
await shellExec('chmod +x ' + toolsDir + '/create-project.sh', termState.homeDir, false);
|
|
||||||
|
|
||||||
progressFill.style.width = '100%';
|
|
||||||
progressText.textContent = 'Setup complete!';
|
|
||||||
statusEl.innerHTML = '<p style="color:var(--success)">Dev environment ready!</p>' +
|
|
||||||
'<p>Use the terminal to build apps. Install Termux for full tool support (aapt2, d8, ecj).</p>';
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = await Bootstrap.install();
|
||||||
|
statusEl.innerHTML = '<p style="color:var(--success);font-size:16px;font-weight:700">✔ Termux environment installed!</p>' +
|
||||||
|
'<p>Full Linux shell with bash, coreutils, and package manager ready.</p>' +
|
||||||
|
'<p>Install build tools: open Terminal → type <code>pkg install aapt2 ecj dx apksigner</code></p>';
|
||||||
btn.querySelector('.btn-text').textContent = 'Installed';
|
btn.querySelector('.btn-text').textContent = 'Installed';
|
||||||
btn.querySelector('.btn-loader').style.display = 'none';
|
btn.querySelector('.btn-loader').style.display = 'none';
|
||||||
|
|
||||||
|
termState.homeDir = result.prefixDir ? result.prefixDir.replace('/usr', '') : termState.homeDir;
|
||||||
|
termState.cwd = termState.homeDir + '/home';
|
||||||
|
if (Shell) {
|
||||||
|
var env = await Shell.getEnv();
|
||||||
|
termState.homeDir = env.HOME;
|
||||||
|
termState.toolsDir = env.TOOLS;
|
||||||
|
termState.projectsDir = env.PROJECTS;
|
||||||
|
termState.cwd = env.CWD || env.HOME;
|
||||||
|
}
|
||||||
|
updateCwdDisplay();
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.innerHTML = '<p style="color:var(--danger)">Install failed: ' + e.message + '</p>' +
|
||||||
|
'<p>Check your internet connection and try again.</p>';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.querySelector('.btn-text').textContent = 'Retry Install';
|
||||||
|
btn.querySelector('.btn-loader').style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Init Terminal ----
|
// ---- Init Terminal ----
|
||||||
|
|||||||
Reference in New Issue
Block a user