v2.0.0: Built-in Termux — full Linux environment, no external app needed
This commit is contained in:
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(InstallerPlugin.class);
|
||||
registerPlugin(WakePlugin.class);
|
||||
registerPlugin(BootstrapPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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");
|
||||
|
||||
Reference in New Issue
Block a user