v2.2.3: Os.chmod() syscall + PRoot fallback + explicit sh invocation for SELinux bypass
This commit is contained in:
@@ -631,6 +631,15 @@ data: [DONE]
|
|||||||
|
|
||||||
## Changelog
|
## 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)
|
### v2.2.2 (2026-05-19)
|
||||||
- **Permission Fix** — `chmod -R 755` on all bootstrap binaries after extraction (fixes "Permission denied" on bash)
|
- **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`
|
- **Shell Auto-Fallback** — if bash fails with permission error, auto-falls back to `/system/bin/sh`
|
||||||
|
|||||||
@@ -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 15
|
versionCode 16
|
||||||
versionName "2.2.2"
|
versionName "2.2.3"
|
||||||
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:!*~'
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import com.getcapacitor.PluginCall;
|
|||||||
import com.getcapacitor.PluginMethod;
|
import com.getcapacitor.PluginMethod;
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
import android.system.Os;
|
||||||
|
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
import java.io.BufferedOutputStream;
|
import java.io.BufferedOutputStream;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
@@ -129,10 +131,9 @@ public class BootstrapPlugin extends Plugin {
|
|||||||
patchPaths(stagingDir);
|
patchPaths(stagingDir);
|
||||||
|
|
||||||
sendProgress(call, "Setting permissions...", 85);
|
sendProgress(call, "Setting permissions...", 85);
|
||||||
setPermissions(new File(stagingDir, "bin"));
|
chmodRecursive(new File(stagingDir, "bin"));
|
||||||
setPermissions(new File(stagingDir, "libexec"));
|
chmodRecursive(new File(stagingDir, "libexec"));
|
||||||
setPermissionsRecursive(new File(stagingDir, "bin"));
|
chmodRecursive(new File(stagingDir, "lib"));
|
||||||
setPermissionsRecursive(new File(stagingDir, "libexec"));
|
|
||||||
|
|
||||||
new File(homeDir).mkdirs();
|
new File(homeDir).mkdirs();
|
||||||
new File(prefixDir + "/tmp").mkdirs();
|
new File(prefixDir + "/tmp").mkdirs();
|
||||||
@@ -149,14 +150,9 @@ public class BootstrapPlugin extends Plugin {
|
|||||||
throw new RuntimeException("Failed to rename staging to prefix");
|
throw new RuntimeException("Failed to rename staging to prefix");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
chmodRecursive(new File(prefixDir + "/bin"));
|
||||||
Runtime rt = Runtime.getRuntime();
|
chmodRecursive(new File(prefixDir + "/libexec"));
|
||||||
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/bin"}).waitFor();
|
chmodRecursive(new File(prefixDir + "/lib"));
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
writeEnvFile();
|
writeEnvFile();
|
||||||
writeProfileFile();
|
writeProfileFile();
|
||||||
@@ -315,29 +311,20 @@ public class BootstrapPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setPermissions(File dir) {
|
private void chmodRecursive(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) {
|
|
||||||
if (!dir.exists() || !dir.isDirectory()) return;
|
if (!dir.exists() || !dir.isDirectory()) return;
|
||||||
File[] children = dir.listFiles();
|
File[] children = dir.listFiles();
|
||||||
if (children == null) return;
|
if (children == null) return;
|
||||||
for (File f : children) {
|
for (File f : children) {
|
||||||
if (f.isDirectory()) {
|
try {
|
||||||
f.setExecutable(true, false);
|
if (f.isDirectory()) {
|
||||||
setPermissionsRecursive(f);
|
Os.chmod(f.getAbsolutePath(), 0755);
|
||||||
} else if (f.isFile()) {
|
chmodRecursive(f);
|
||||||
f.setExecutable(true, false);
|
} else {
|
||||||
f.setReadable(true, false);
|
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
|
@PluginMethod
|
||||||
public void fixPermissions(PluginCall call) {
|
public void fixPermissions(PluginCall call) {
|
||||||
try {
|
try {
|
||||||
File binDir = new File(prefixDir + "/bin");
|
chmodRecursive(new File(prefixDir + "/bin"));
|
||||||
if (binDir.exists()) {
|
chmodRecursive(new File(prefixDir + "/libexec"));
|
||||||
Runtime rt = Runtime.getRuntime();
|
chmodRecursive(new File(prefixDir + "/lib"));
|
||||||
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/bin"}).waitFor();
|
File etcDir = new File(prefixDir + "/etc");
|
||||||
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/libexec"}).waitFor();
|
if (etcDir.exists()) chmodRecursive(etcDir);
|
||||||
setPermissionsRecursive(binDir);
|
|
||||||
}
|
|
||||||
call.resolve(new JSObject().put("fixed", true));
|
call.resolve(new JSObject().put("fixed", true));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
call.reject("Fix permissions failed: " + e.getMessage());
|
call.reject("Fix permissions failed: " + e.getMessage());
|
||||||
@@ -438,6 +423,200 @@ public class BootstrapPlugin extends Plugin {
|
|||||||
file.delete();
|
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("!<arch>\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 {
|
interface ProgressCallback {
|
||||||
void onProgress(long downloaded, long total);
|
void onProgress(long downloaded, long total);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package ai.z.chat;
|
package ai.z.chat;
|
||||||
|
|
||||||
|
import android.system.Os;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.getcapacitor.JSObject;
|
import com.getcapacitor.JSObject;
|
||||||
@@ -47,28 +48,39 @@ public class ShellPlugin extends Plugin {
|
|||||||
refreshShell();
|
refreshShell();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String prootPath = null;
|
||||||
|
|
||||||
private void refreshShell() {
|
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 bash = new File(prefixDir + "/bin/bash");
|
||||||
File sh = new File(prefixDir + "/bin/sh");
|
File sh = new File(prefixDir + "/bin/sh");
|
||||||
if (bash.exists()) {
|
if (bash.exists()) {
|
||||||
if (!bash.canExecute()) {
|
try { Os.chmod(bash.getAbsolutePath(), 0755); } catch (Exception e) {}
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
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
|
@PluginMethod
|
||||||
@@ -77,6 +89,7 @@ public class ShellPlugin extends Plugin {
|
|||||||
String cwd = call.getString("cwd", currentCwd);
|
String cwd = call.getString("cwd", currentCwd);
|
||||||
boolean stream = call.getBoolean("stream", false);
|
boolean stream = call.getBoolean("stream", false);
|
||||||
int timeout = call.getInt("timeout", 300000);
|
int timeout = call.getInt("timeout", 300000);
|
||||||
|
boolean useProot = call.getBoolean("useProot", false);
|
||||||
|
|
||||||
if (command.isEmpty()) {
|
if (command.isEmpty()) {
|
||||||
call.reject("No command provided");
|
call.reject("No command provided");
|
||||||
@@ -89,7 +102,13 @@ public class ShellPlugin extends Plugin {
|
|||||||
refreshShell();
|
refreshShell();
|
||||||
String[] env = buildEnv();
|
String[] env = buildEnv();
|
||||||
String shell = shellPath != null ? shellPath : "sh";
|
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.directory(new File(cwd));
|
||||||
pb.environment().putAll(toEnvMap(env));
|
pb.environment().putAll(toEnvMap(env));
|
||||||
pb.redirectErrorStream(true);
|
pb.redirectErrorStream(true);
|
||||||
@@ -98,16 +117,12 @@ public class ShellPlugin extends Plugin {
|
|||||||
try {
|
try {
|
||||||
process = pb.start();
|
process = pb.start();
|
||||||
} catch (java.io.IOException ioe) {
|
} catch (java.io.IOException ioe) {
|
||||||
if (shell != null && !shell.equals("/system/bin/sh")) {
|
Log.w(TAG, "Shell " + shell + " failed: " + ioe.getMessage());
|
||||||
Log.w(TAG, "Shell " + shell + " failed, falling back to /system/bin/sh: " + ioe.getMessage());
|
pb = new ProcessBuilder("/system/bin/sh", "-c", actualCommand);
|
||||||
pb = new ProcessBuilder("/system/bin/sh", "-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);
|
process = pb.start();
|
||||||
process = pb.start();
|
|
||||||
} else {
|
|
||||||
throw ioe;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
String processId = String.valueOf(System.currentTimeMillis());
|
String processId = String.valueOf(System.currentTimeMillis());
|
||||||
activeProcesses.put(processId, process);
|
activeProcesses.put(processId, process);
|
||||||
@@ -122,6 +137,10 @@ public class ShellPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String bashEscape(String s) {
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
public void kill(PluginCall call) {
|
public void kill(PluginCall call) {
|
||||||
String processId = call.getString("pid", "");
|
String processId = call.getString("pid", "");
|
||||||
@@ -158,9 +177,28 @@ public class ShellPlugin extends Plugin {
|
|||||||
env.put("PROJECTS", projectsDir);
|
env.put("PROJECTS", projectsDir);
|
||||||
env.put("CWD", currentCwd);
|
env.put("CWD", currentCwd);
|
||||||
env.put("PREFIX", prefixDir);
|
env.put("PREFIX", prefixDir);
|
||||||
|
env.put("hasProot", prootPath != null);
|
||||||
|
env.put("prootPath", prootPath != null ? prootPath : "");
|
||||||
|
env.put("nativeLibDir", getNativeLibDir());
|
||||||
call.resolve(env);
|
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
|
@PluginMethod
|
||||||
public void writeFile(PluginCall call) {
|
public void writeFile(PluginCall call) {
|
||||||
String path = call.getString("path", "");
|
String path = call.getString("path", "");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zai-chat",
|
"name": "zai-chat",
|
||||||
"version": "2.2.2",
|
"version": "2.2.3",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -327,13 +327,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>About</h3>
|
<h3>About</h3>
|
||||||
<p class="about-text">Z.AI Chat v2.2.2</p>
|
<p class="about-text">Z.AI Chat v2.2.3</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.2.3</span>
|
||||||
|
<span class="changelog-date">2026-05-19</span>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Os.chmod() Fix</strong> — uses Android system call (chmod(2)) instead of Runtime.exec for reliable permissions</li>
|
||||||
|
<li><strong>PRoot Fallback</strong> — auto-downloads PRoot from Termux repo when SELinux blocks binary execution</li>
|
||||||
|
<li><strong>Shell Always /system/bin/sh</strong> — avoids Termux bash permission issues entirely</li>
|
||||||
|
<li><strong>Explicit sh Invocation</strong> — runs pkg/apt via "sh script" instead of direct execution</li>
|
||||||
|
<li><strong>installProot Plugin</strong> — downloads .deb, extracts proot binary with AR+tar parser</li>
|
||||||
|
<li><strong>Termux Fallback Guide</strong> — if all else fails, clear instructions to install Termux from F-Droid</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="changelog-version">v2.2.2</span>
|
<span class="changelog-version">v2.2.2</span>
|
||||||
<span class="changelog-date">2026-05-19</span>
|
<span class="changelog-date">2026-05-19</span>
|
||||||
|
|||||||
@@ -1182,6 +1182,8 @@
|
|||||||
activePid: null,
|
activePid: null,
|
||||||
activeStreamId: null,
|
activeStreamId: null,
|
||||||
devToolsInstalled: false,
|
devToolsInstalled: false,
|
||||||
|
hasProot: false,
|
||||||
|
prootPath: '',
|
||||||
commandQueue: []
|
commandQueue: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1919,6 +1921,8 @@
|
|||||||
termState.toolsDir = env.TOOLS;
|
termState.toolsDir = env.TOOLS;
|
||||||
termState.projectsDir = env.PROJECTS;
|
termState.projectsDir = env.PROJECTS;
|
||||||
termState.cwd = env.CWD || env.HOME;
|
termState.cwd = env.CWD || env.HOME;
|
||||||
|
termState.hasProot = env.hasProot === true;
|
||||||
|
termState.prootPath = env.prootPath || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
var prefix = termState.homeDir ? termState.homeDir.replace('/home', '') : termState.homeDir || '';
|
var prefix = termState.homeDir ? termState.homeDir.replace('/home', '') : termState.homeDir || '';
|
||||||
@@ -1947,15 +1951,15 @@
|
|||||||
|
|
||||||
var methods = [];
|
var methods = [];
|
||||||
if (pkgTest.exitCode === 0) {
|
if (pkgTest.exitCode === 0) {
|
||||||
methods.push('chmod 755 "' + pkgBin + '" 2>/dev/null; "' + pkgBin + '" update -y 2>&1 && "' + pkgBin + '" install -y aapt2 ecj dx apksigner 2>&1');
|
methods.push({cmd: 'export PREFIX="' + prefixUsr + '" PATH="' + prefixUsr + '/bin:$PATH" LD_LIBRARY_PATH="' + prefixUsr + '/lib" && sh "' + pkgBin + '" update -y 2>&1 && sh "' + pkgBin + '" install -y aapt2 ecj dx apksigner 2>&1', label: 'pkg'});
|
||||||
}
|
}
|
||||||
if (aptTest.exitCode === 0) {
|
if (aptTest.exitCode === 0) {
|
||||||
methods.push('chmod 755 "' + aptBin + '" 2>/dev/null; "' + aptBin + '" update -y 2>&1 && "' + aptBin + '" install -y aapt2 ecj dx apksigner 2>&1');
|
methods.push({cmd: 'export PREFIX="' + prefixUsr + '" PATH="' + prefixUsr + '/bin:$PATH" LD_LIBRARY_PATH="' + prefixUsr + '/lib" && sh "' + aptBin + '" update -y 2>&1 && sh "' + aptBin + '" install -y aapt2 ecj dx apksigner 2>&1', label: 'apt'});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var m = 0; m < methods.length; m++) {
|
for (var m = 0; m < methods.length; m++) {
|
||||||
termPrint('[*] Attempt ' + (m + 1) + '...', 'info');
|
termPrint('[*] Attempt ' + (m + 1) + ' (' + methods[m].label + ')...', 'info');
|
||||||
var installResult = await shellExec(methods[m], termState.homeDir, false);
|
var installResult = await shellExec(methods[m].cmd, termState.homeDir, false);
|
||||||
if (installResult.output) {
|
if (installResult.output) {
|
||||||
var out = installResult.output;
|
var out = installResult.output;
|
||||||
if (out.length > 2000) out = out.substring(0, 1000) + '\n... truncated ...\n' + out.substring(out.length - 800);
|
if (out.length > 2000) out = out.substring(0, 1000) + '\n... truncated ...\n' + out.substring(out.length - 800);
|
||||||
@@ -1971,12 +1975,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
termPrint('[!] Auto-install failed. Open Terminal and run:', 'err');
|
termPrint('[*] Direct execution failed. Trying PRoot workaround...', 'info');
|
||||||
termPrint(' pkg update && pkg install aapt2 ecj dx apksigner', 'warning');
|
var prootOk = await tryProotInstall(prefixUsr, pkgBin, aptBin);
|
||||||
|
if (prootOk) return true;
|
||||||
|
|
||||||
|
termPrint('', '');
|
||||||
|
termPrint('[!] Auto-install failed. Android SELinux may be blocking binary execution.', 'err');
|
||||||
|
termPrint('[*] Fallback options:', 'warning');
|
||||||
|
termPrint(' 1. Install Termux from F-Droid, then run:', 'warning');
|
||||||
|
termPrint(' pkg install aapt2 ecj dx apksigner', 'warning');
|
||||||
|
termPrint(' 2. Z.AI Chat will auto-detect Termux tools.', 'warning');
|
||||||
termState.devToolsInstalled = false;
|
termState.devToolsInstalled = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryProotInstall(prefixUsr, pkgBin, aptBin) {
|
||||||
|
try {
|
||||||
|
termPrint('[*] Downloading PRoot from Termux repo...', 'info');
|
||||||
|
var prootResult = await Bootstrap.installProot();
|
||||||
|
if (!prootResult || !prootResult.path) {
|
||||||
|
termPrint('[!] PRoot download failed', 'err');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
termPrint('[OK] PRoot downloaded: ' + prootResult.path, 'success');
|
||||||
|
termState.hasProot = true;
|
||||||
|
termState.prootPath = prootResult.path;
|
||||||
|
} catch(e) {
|
||||||
|
termPrint('[!] PRoot download failed: ' + e.message, 'err');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prootCmd = termState.prootPath;
|
||||||
|
var pkgCmd = 'sh "' + pkgBin + '" update -y 2>&1 && sh "' + pkgBin + '" install -y aapt2 ecj dx apksigner 2>&1';
|
||||||
|
var wrappedCmd = prootCmd + ' -0 -b /dev -b /proc -b /sys -r ' + prefixUsr + ' /bin/sh -c \'' + pkgCmd.replace(/'/g, "'\\''") + '\'';
|
||||||
|
|
||||||
|
termPrint('[*] Installing via PRoot...', 'info');
|
||||||
|
var result = await shellExec(wrappedCmd, termState.homeDir, false);
|
||||||
|
if (result.output) {
|
||||||
|
var out = result.output;
|
||||||
|
if (out.length > 2000) out = out.substring(0, 1000) + '\n... truncated ...\n' + out.substring(out.length - 800);
|
||||||
|
termPrint(out.replace(/\n$/, ''), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
var recheck = await shellExec('command -v aapt2 >/dev/null 2>&1 && command -v ecj >/dev/null 2>&1', termState.homeDir, false);
|
||||||
|
if (recheck.exitCode === 0) {
|
||||||
|
termPrint('[OK] Build tools installed via PRoot!', 'success');
|
||||||
|
showStatusToast('Build tools installed!', 'success');
|
||||||
|
termState.devToolsInstalled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function checkDevEnvironment() {
|
async function checkDevEnvironment() {
|
||||||
if (state.currentMode !== 'coding' && state.currentMode !== 'agentic') return;
|
if (state.currentMode !== 'coding' && state.currentMode !== 'agentic') return;
|
||||||
|
|
||||||
@@ -2387,7 +2437,7 @@
|
|||||||
if (toolsStatus) {
|
if (toolsStatus) {
|
||||||
toolsStatus.innerHTML = toolsOk
|
toolsStatus.innerHTML = toolsOk
|
||||||
? '<span style="color:var(--success)">✔ All tools installed — ready to build!</span>'
|
? '<span style="color:var(--success)">✔ All tools installed — ready to build!</span>'
|
||||||
: '<span style="color:var(--warning)">Build tools not installed. Open Terminal and run: pkg install aapt2 ecj dx apksigner</span>';
|
: '<span style="color:var(--warning)">Build tools not installed.<br>1. Install Termux from F-Droid<br>2. Run: pkg install aapt2 ecj dx apksigner<br>3. Z.AI Chat will auto-detect Termux tools.</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.querySelector('.btn-text').textContent = 'Installed';
|
btn.querySelector('.btn-text').textContent = 'Installed';
|
||||||
|
|||||||
Reference in New Issue
Block a user