diff --git a/README.md b/README.md index 34a5fa9..3704065 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,15 @@ data: [DONE] ## Changelog +### v2.2.3 (2026-05-19) +- **Os.chmod() Fix** — uses `android.system.Os.chmod()` (direct syscall) instead of `Runtime.exec("chmod")` for reliable execute permissions +- **PRoot Fallback** — auto-downloads PRoot from Termux package repo when SELinux blocks binary execution +- **Shell Always /system/bin/sh** — avoids Termux bash permission issues entirely, uses system shell with correct PATH +- **Explicit sh Invocation** — runs `pkg`/`apt` via `sh "$script"` instead of direct execution +- **installProot Plugin** — `BootstrapPlugin.installProot()` downloads .deb, extracts proot binary with AR+tar parser +- **Termux Fallback Guide** — if all auto-install methods fail, clear instructions to install Termux from F-Droid +- `chmodRecursive()` replaces `setPermissionsRecursive()` — uses `Os.chmod(path, 0755)` for every file + ### v2.2.2 (2026-05-19) - **Permission Fix** — `chmod -R 755` on all bootstrap binaries after extraction (fixes "Permission denied" on bash) - **Shell Auto-Fallback** — if bash fails with permission error, auto-falls back to `/system/bin/sh` diff --git a/android/app/build.gradle b/android/app/build.gradle index 69977ea..b86e157 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 15 - versionName "2.2.2" + versionCode 16 + versionName "2.2.3" 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 index e513c05..43782e6 100644 --- a/android/app/src/main/java/ai/z/chat/BootstrapPlugin.java +++ b/android/app/src/main/java/ai/z/chat/BootstrapPlugin.java @@ -9,6 +9,8 @@ import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; +import android.system.Os; + import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; @@ -129,10 +131,9 @@ public class BootstrapPlugin extends Plugin { patchPaths(stagingDir); sendProgress(call, "Setting permissions...", 85); - setPermissions(new File(stagingDir, "bin")); - setPermissions(new File(stagingDir, "libexec")); - setPermissionsRecursive(new File(stagingDir, "bin")); - setPermissionsRecursive(new File(stagingDir, "libexec")); + chmodRecursive(new File(stagingDir, "bin")); + chmodRecursive(new File(stagingDir, "libexec")); + chmodRecursive(new File(stagingDir, "lib")); new File(homeDir).mkdirs(); new File(prefixDir + "/tmp").mkdirs(); @@ -149,14 +150,9 @@ public class BootstrapPlugin extends Plugin { throw new RuntimeException("Failed to rename staging to prefix"); } - try { - Runtime rt = Runtime.getRuntime(); - rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/bin"}).waitFor(); - rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/libexec"}).waitFor(); - rt.exec(new String[]{"chmod", "755", prefixDir + "/lib"}).waitFor(); - } catch (Exception ce) { - Log.w(TAG, "chmod after rename failed: " + ce.getMessage()); - } + chmodRecursive(new File(prefixDir + "/bin")); + chmodRecursive(new File(prefixDir + "/libexec")); + chmodRecursive(new File(prefixDir + "/lib")); writeEnvFile(); writeProfileFile(); @@ -315,29 +311,20 @@ public class BootstrapPlugin extends Plugin { } } - 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 setPermissionsRecursive(File dir) { + private void chmodRecursive(File dir) { if (!dir.exists() || !dir.isDirectory()) return; File[] children = dir.listFiles(); if (children == null) return; for (File f : children) { - if (f.isDirectory()) { - f.setExecutable(true, false); - setPermissionsRecursive(f); - } else if (f.isFile()) { - f.setExecutable(true, false); - f.setReadable(true, false); + try { + if (f.isDirectory()) { + Os.chmod(f.getAbsolutePath(), 0755); + chmodRecursive(f); + } else { + Os.chmod(f.getAbsolutePath(), 0755); + } + } catch (Exception e) { + Log.w(TAG, "chmod failed: " + f.getAbsolutePath() + ": " + e.getMessage()); } } } @@ -345,13 +332,11 @@ public class BootstrapPlugin extends Plugin { @PluginMethod public void fixPermissions(PluginCall call) { try { - File binDir = new File(prefixDir + "/bin"); - if (binDir.exists()) { - Runtime rt = Runtime.getRuntime(); - rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/bin"}).waitFor(); - rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/libexec"}).waitFor(); - setPermissionsRecursive(binDir); - } + chmodRecursive(new File(prefixDir + "/bin")); + chmodRecursive(new File(prefixDir + "/libexec")); + chmodRecursive(new File(prefixDir + "/lib")); + File etcDir = new File(prefixDir + "/etc"); + if (etcDir.exists()) chmodRecursive(etcDir); call.resolve(new JSObject().put("fixed", true)); } catch (Exception e) { call.reject("Fix permissions failed: " + e.getMessage()); @@ -438,6 +423,200 @@ public class BootstrapPlugin extends Plugin { file.delete(); } + @PluginMethod + public void installProot(PluginCall call) { + call.setKeepAlive(true); + new Thread(() -> { + try { + String arch = getArch(); + String prootBinPath = binDir + "/proot"; + File prootBin = new File(prootBinPath); + + if (prootBin.exists()) { + try { Os.chmod(prootBinPath, 0755); } catch (Exception e) {} + call.resolve(new JSObject().put("installed", true).put("path", prootBinPath)); + return; + } + + String nativeLibDir = null; + try { + nativeLibDir = getContext().getApplicationInfo().nativeLibraryDir; + File nativeProot = new File(nativeLibDir, "libproot.so"); + if (nativeProot.exists()) { + call.resolve(new JSObject().put("installed", true).put("path", nativeProot.getAbsolutePath()).put("bundled", true)); + return; + } + } catch (Exception e) {} + + String packagesUrl = "https://packages.termux.dev/apt/termux-main/dists/stable/main/binary-" + arch + "/Packages"; + Log.i(TAG, "Fetching package index: " + packagesUrl); + + String packagesContent = downloadToString(packagesUrl); + String prootDebUrl = null; + String prootDebName = null; + + String[] blocks = packagesContent.split("\n\n"); + for (String block : blocks) { + if (block.contains("Package: proot")) { + for (String line : block.split("\n")) { + if (line.startsWith("Filename:")) { + prootDebName = line.substring("Filename:".length()).trim(); + } + } + if (prootDebName != null) break; + } + } + + if (prootDebName == null) { + call.reject("proot package not found in repo"); + return; + } + + prootDebUrl = "https://packages.termux.dev/apt/termux-main/" + prootDebName; + Log.i(TAG, "Downloading proot: " + prootDebUrl); + + File debFile = new File(getContext().getCacheDir(), "proot.deb"); + downloadFile(prootDebUrl, debFile, null); + + Log.i(TAG, "Extracting proot binary from .deb..."); + byte[] prootData = extractBinaryFromDeb(debFile, "proot"); + if (prootData == null) { + call.reject("Failed to extract proot binary from .deb"); + debFile.delete(); + return; + } + + new File(binDir).mkdirs(); + FileOutputStream fos = new FileOutputStream(prootBin); + fos.write(prootData); + fos.close(); + + Os.chmod(prootBinPath, 0755); + debFile.delete(); + + Log.i(TAG, "proot installed: " + prootBinPath + " (" + prootData.length + " bytes)"); + call.resolve(new JSObject().put("installed", true).put("path", prootBinPath).put("size", prootData.length)); + } catch (Exception e) { + Log.e(TAG, "installProot failed", e); + try { call.reject("installProot failed: " + e.getMessage()); } catch (Exception ignored) {} + } + }).start(); + } + + private String downloadToString(String urlStr) throws Exception { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + reader.close(); + conn.disconnect(); + return sb.toString(); + } + + private byte[] extractBinaryFromDeb(File debFile, String binaryName) throws Exception { + FileInputStream fis = new FileInputStream(debFile); + byte[] magic = new byte[8]; + if (fis.read(magic) != 8 || !new String(magic).equals("!\n")) { + fis.close(); + throw new RuntimeException("Not a valid .deb (AR) file"); + } + + while (true) { + byte[] header = new byte[60]; + int read = fis.read(header); + if (read < 60) break; + + String name = new String(header, 0, 16).trim(); + String sizeStr = new String(header, 48, 10).trim(); + long entrySize = Long.parseLong(sizeStr); + + if (name.startsWith("data.tar")) { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + long remaining = entrySize; + while (remaining > 0) { + int toRead = (int) Math.min(buf.length, remaining); + int r = fis.read(buf, 0, toRead); + if (r <= 0) break; + baos.write(buf, 0, r); + remaining -= r; + } + fis.close(); + + byte[] tarData = baos.toByteArray(); + if (name.contains(".xz")) { + tarData = decompressXz(tarData); + } else if (name.contains(".gz")) { + tarData = decompressGz(tarData); + } else if (name.contains(".zst")) { + throw new RuntimeException("ZSTD compression not supported"); + } + return findBinaryInTar(tarData, binaryName); + } else { + long skip = (entrySize + 1) & ~1L; + fis.skip(skip); + } + } + fis.close(); + return null; + } + + private byte[] decompressGz(byte[] data) throws Exception { + java.util.zip.GZIPInputStream gzis = new java.util.zip.GZIPInputStream( + new java.io.ByteArrayInputStream(data)); + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int r; + while ((r = gzis.read(buf)) > 0) baos.write(buf, 0, r); + gzis.close(); + baos.close(); + return baos.toByteArray(); + } + + private byte[] decompressXz(byte[] data) throws Exception { + ProcessBuilder pb = new ProcessBuilder("xz", "-d", "--stdout", "-"); + pb.redirectErrorStream(true); + Process p = pb.start(); + OutputStream pos = p.getOutputStream(); + pos.write(data); + pos.close(); + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + java.io.InputStream pis = p.getInputStream(); + int r; + while ((r = pis.read(buf)) > 0) baos.write(buf, 0, r); + pis.close(); + p.waitFor(); + return baos.toByteArray(); + } + + private byte[] findBinaryInTar(byte[] tarData, String binaryName) throws Exception { + int offset = 0; + while (offset + 512 <= tarData.length) { + String headerName = new String(tarData, offset, 100).trim().replace("\0", ""); + String sizeStr = new String(tarData, offset + 124, 12).trim().replace("\0", ""); + long fileSize = 0; + try { fileSize = Long.parseLong(sizeStr, 8); } catch (Exception e) { break; } + + if (headerName.endsWith("/bin/" + binaryName) || headerName.equals(binaryName) || + headerName.endsWith("/" + binaryName)) { + byte[] result = new byte[(int) fileSize]; + System.arraycopy(tarData, offset + 512, result, 0, (int) Math.min(fileSize, tarData.length - offset - 512)); + return result; + } + + long blocks = (fileSize + 511) / 512; + offset += 512 + (int)(blocks * 512); + } + return null; + } + interface ProgressCallback { void onProgress(long downloaded, long total); } 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 050ce58..12004b4 100644 --- a/android/app/src/main/java/ai/z/chat/ShellPlugin.java +++ b/android/app/src/main/java/ai/z/chat/ShellPlugin.java @@ -1,5 +1,6 @@ package ai.z.chat; +import android.system.Os; import android.util.Log; import com.getcapacitor.JSObject; @@ -47,28 +48,39 @@ public class ShellPlugin extends Plugin { refreshShell(); } + private String prootPath = null; + private void refreshShell() { + File proot = new File(prefixDir + "/bin/proot"); + if (proot.exists()) { + try { Os.chmod(proot.getAbsolutePath(), 0755); } catch (Exception e) {} + prootPath = proot.getAbsolutePath(); + } else { + String nativeLibDir = getNativeLibDir(); + if (nativeLibDir != null) { + File nativeProot = new File(nativeLibDir, "libproot.so"); + if (nativeProot.exists()) { + prootPath = nativeProot.getAbsolutePath(); + } + } + } + File bash = new File(prefixDir + "/bin/bash"); File sh = new File(prefixDir + "/bin/sh"); if (bash.exists()) { - if (!bash.canExecute()) { - bash.setExecutable(true, false); - try { - Runtime.getRuntime().exec(new String[]{"chmod", "755", bash.getAbsolutePath()}).waitFor(); - } catch (Exception e) {} - } - shellPath = bash.getAbsolutePath(); - } else if (sh.exists()) { - if (!sh.canExecute()) { - sh.setExecutable(true, false); - try { - Runtime.getRuntime().exec(new String[]{"chmod", "755", sh.getAbsolutePath()}).waitFor(); - } catch (Exception e) {} - } - shellPath = sh.getAbsolutePath(); - } else { - shellPath = "/system/bin/sh"; + try { Os.chmod(bash.getAbsolutePath(), 0755); } catch (Exception e) {} } + if (sh.exists()) { + try { Os.chmod(sh.getAbsolutePath(), 0755); } catch (Exception e) {} + } + + shellPath = "/system/bin/sh"; + } + + private String getNativeLibDir() { + try { + return getContext().getApplicationInfo().nativeLibraryDir; + } catch (Exception e) { return null; } } @PluginMethod @@ -77,6 +89,7 @@ public class ShellPlugin extends Plugin { String cwd = call.getString("cwd", currentCwd); boolean stream = call.getBoolean("stream", false); int timeout = call.getInt("timeout", 300000); + boolean useProot = call.getBoolean("useProot", false); if (command.isEmpty()) { call.reject("No command provided"); @@ -89,7 +102,13 @@ public class ShellPlugin extends Plugin { refreshShell(); String[] env = buildEnv(); String shell = shellPath != null ? shellPath : "sh"; - ProcessBuilder pb = new ProcessBuilder(shell, "-c", command); + String actualCommand = command; + + if (useProot && prootPath != null) { + actualCommand = prootPath + " -0 -b /dev -b /proc -b /sys -r " + prefixDir + " /bin/sh -c " + bashEscape(command); + } + + ProcessBuilder pb = new ProcessBuilder(shell, "-c", actualCommand); pb.directory(new File(cwd)); pb.environment().putAll(toEnvMap(env)); pb.redirectErrorStream(true); @@ -98,16 +117,12 @@ public class ShellPlugin extends Plugin { try { process = pb.start(); } catch (java.io.IOException ioe) { - if (shell != null && !shell.equals("/system/bin/sh")) { - Log.w(TAG, "Shell " + shell + " failed, falling back to /system/bin/sh: " + ioe.getMessage()); - pb = new ProcessBuilder("/system/bin/sh", "-c", command); - pb.directory(new File(cwd)); - pb.environment().putAll(toEnvMap(env)); - pb.redirectErrorStream(true); - process = pb.start(); - } else { - throw ioe; - } + Log.w(TAG, "Shell " + shell + " failed: " + ioe.getMessage()); + pb = new ProcessBuilder("/system/bin/sh", "-c", actualCommand); + pb.directory(new File(cwd)); + pb.environment().putAll(toEnvMap(env)); + pb.redirectErrorStream(true); + process = pb.start(); } String processId = String.valueOf(System.currentTimeMillis()); activeProcesses.put(processId, process); @@ -122,6 +137,10 @@ public class ShellPlugin extends Plugin { } } + private String bashEscape(String s) { + return "'" + s.replace("'", "'\\''") + "'"; + } + @PluginMethod public void kill(PluginCall call) { String processId = call.getString("pid", ""); @@ -158,9 +177,28 @@ public class ShellPlugin extends Plugin { env.put("PROJECTS", projectsDir); env.put("CWD", currentCwd); env.put("PREFIX", prefixDir); + env.put("hasProot", prootPath != null); + env.put("prootPath", prootPath != null ? prootPath : ""); + env.put("nativeLibDir", getNativeLibDir()); call.resolve(env); } + @PluginMethod + public void testExec(PluginCall call) { + String testPath = call.getString("path", prefixDir + "/bin/sh"); + try { + File f = new File(testPath); + if (!f.exists()) { + call.resolve(new JSObject().put("exists", false).put("executable", false)); + return; + } + boolean canExec = f.canExecute(); + call.resolve(new JSObject().put("exists", true).put("executable", canExec).put("path", testPath)); + } catch (Exception e) { + call.resolve(new JSObject().put("exists", false).put("executable", false).put("error", e.getMessage())); + } + } + @PluginMethod public void writeFile(PluginCall call) { String path = call.getString("path", ""); diff --git a/package.json b/package.json index d6b5188..8432363 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zai-chat", - "version": "2.2.2", + "version": "2.2.3", "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 f56f329..12ff0d9 100644 --- a/www/index.html +++ b/www/index.html @@ -327,13 +327,25 @@

About

-

Z.AI Chat v2.2.2

+

Z.AI Chat v2.2.3

Built with Z.AI SDK & GLM-5.1

Compatible with Android 15/16

Changelog