v2.0.0: Built-in Termux — full Linux environment, no external app needed

This commit is contained in:
admin
2026-05-19 17:37:40 +04:00
Unverified
parent a9f53e45dd
commit e7015b129a
8 changed files with 504 additions and 100 deletions

View 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);
}
}

View File

@@ -10,6 +10,7 @@ public class MainActivity extends BridgeActivity {
registerPlugin(ShellPlugin.class);
registerPlugin(InstallerPlugin.class);
registerPlugin(WakePlugin.class);
registerPlugin(BootstrapPlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -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");