v1.3.0: Full terminal with shell execution, APK build/install, AI deploy pipeline
This commit is contained in:
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "ai.z.chat"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 6
|
||||
versionName "1.2.4"
|
||||
versionCode 7
|
||||
versionName "1.3.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
109
android/app/src/main/java/ai/z/chat/InstallerPlugin.java
Normal file
109
android/app/src/main/java/ai/z/chat/InstallerPlugin.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package ai.z.chat;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
@CapacitorPlugin(name = "Installer")
|
||||
public class InstallerPlugin extends Plugin {
|
||||
private static final String TAG = "InstallerPlugin";
|
||||
|
||||
@PluginMethod
|
||||
public void installApk(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
|
||||
File apkFile = new File(path);
|
||||
if (!apkFile.exists()) {
|
||||
call.reject("APK file not found: " + path);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Context context = getContext();
|
||||
Uri apkUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
context.getPackageName() + ".fileprovider",
|
||||
apkFile
|
||||
);
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(intent);
|
||||
|
||||
call.resolve(new JSObject()
|
||||
.put("installed", true)
|
||||
.put("path", apkFile.getAbsolutePath())
|
||||
.put("size", apkFile.length()));
|
||||
} catch (Exception e) {
|
||||
call.reject("Install failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getDeviceInfo(PluginCall call) {
|
||||
JSObject info = new JSObject();
|
||||
info.put("sdk", Build.VERSION.SDK_INT);
|
||||
info.put("release", Build.VERSION.RELEASE);
|
||||
info.put("device", Build.DEVICE);
|
||||
info.put("model", Build.MODEL);
|
||||
info.put("manufacturer", Build.MANUFACTURER);
|
||||
info.put("abi", Build.SUPPORTED_ABIS.length > 0 ? Build.SUPPORTED_ABIS[0] : "unknown");
|
||||
info.put("package", getContext().getPackageName());
|
||||
info.put("filesDir", getContext().getFilesDir().getAbsolutePath());
|
||||
call.resolve(info);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
String mimeType = call.getString("mimeType", "*/*");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
|
||||
File file = new File(path);
|
||||
if (!file.exists()) {
|
||||
call.reject("File not found: " + path);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Context context = getContext();
|
||||
Uri uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
context.getPackageName() + ".fileprovider",
|
||||
file
|
||||
);
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(uri, mimeType);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(intent);
|
||||
call.resolve(new JSObject().put("opened", true));
|
||||
} catch (Exception e) {
|
||||
call.reject("Open failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,13 @@ package ai.z.chat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
import android.os.Bundle;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(ShellPlugin.class);
|
||||
registerPlugin(InstallerPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
353
android/app/src/main/java/ai/z/chat/ShellPlugin.java
Normal file
353
android/app/src/main/java/ai/z/chat/ShellPlugin.java
Normal file
@@ -0,0 +1,353 @@
|
||||
package ai.z.chat;
|
||||
|
||||
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 com.getcapacitor.annotation.Permission;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@CapacitorPlugin(name = "Shell",
|
||||
permissions = {
|
||||
@Permission(strings = {}, alias = "shell")
|
||||
}
|
||||
)
|
||||
public class ShellPlugin extends Plugin {
|
||||
private static final String TAG = "ShellPlugin";
|
||||
private final Map<String, Process> activeProcesses = new HashMap<>();
|
||||
private String currentCwd = null;
|
||||
private String homeDir = null;
|
||||
private String toolsDir = null;
|
||||
private String projectsDir = null;
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
homeDir = getContext().getFilesDir().getAbsolutePath();
|
||||
toolsDir = homeDir + "/tools";
|
||||
projectsDir = homeDir + "/projects";
|
||||
currentCwd = homeDir;
|
||||
new File(toolsDir).mkdirs();
|
||||
new File(projectsDir).mkdirs();
|
||||
new File(homeDir + "/bin").mkdirs();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void execute(PluginCall call) {
|
||||
String command = call.getString("command", "");
|
||||
String cwd = call.getString("cwd", currentCwd);
|
||||
boolean stream = call.getBoolean("stream", false);
|
||||
int timeout = call.getInt("timeout", 30000);
|
||||
|
||||
if (command.isEmpty()) {
|
||||
call.reject("No command provided");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cwd == null || cwd.isEmpty()) cwd = homeDir;
|
||||
|
||||
try {
|
||||
String[] env = buildEnv();
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.directory(new File(cwd));
|
||||
pb.environment().putAll(toEnvMap(env));
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
String processId = String.valueOf(System.currentTimeMillis());
|
||||
activeProcesses.put(processId, process);
|
||||
|
||||
if (stream) {
|
||||
streamOutput(call, process, processId, cwd);
|
||||
} else {
|
||||
executeBlocking(call, process, processId, timeout);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
call.reject("Execution failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void kill(PluginCall call) {
|
||||
String processId = call.getString("pid", "");
|
||||
Process p = activeProcesses.remove(processId);
|
||||
if (p != null) {
|
||||
p.destroyForcibly();
|
||||
call.resolve(new JSObject().put("killed", true));
|
||||
} else {
|
||||
call.resolve(new JSObject().put("killed", false));
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getCwd(PluginCall call) {
|
||||
call.resolve(new JSObject().put("cwd", currentCwd != null ? currentCwd : homeDir));
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void setCwd(PluginCall call) {
|
||||
String cwd = call.getString("cwd", homeDir);
|
||||
if (new File(cwd).isDirectory()) {
|
||||
currentCwd = cwd;
|
||||
call.resolve(new JSObject().put("cwd", currentCwd));
|
||||
} else {
|
||||
call.reject("Not a directory: " + cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getEnv(PluginCall call) {
|
||||
JSObject env = new JSObject();
|
||||
env.put("HOME", homeDir);
|
||||
env.put("TOOLS", toolsDir);
|
||||
env.put("PROJECTS", projectsDir);
|
||||
env.put("CWD", currentCwd);
|
||||
env.put("PREFIX", homeDir + "/tools/usr");
|
||||
call.resolve(env);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void writeFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
String content = call.getString("content", "");
|
||||
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
File file = new File(path);
|
||||
File parent = file.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.FileWriter writer = new java.io.FileWriter(file);
|
||||
writer.write(content);
|
||||
writer.close();
|
||||
call.resolve(new JSObject().put("path", file.getAbsolutePath()).put("size", file.length()));
|
||||
} catch (Exception e) {
|
||||
call.reject("Write failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void readFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File file = new File(path);
|
||||
if (!file.exists()) {
|
||||
call.reject("File not found: " + path);
|
||||
return;
|
||||
}
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line).append("\n");
|
||||
}
|
||||
reader.close();
|
||||
call.resolve(new JSObject().put("content", sb.toString()).put("path", file.getAbsolutePath()));
|
||||
} catch (Exception e) {
|
||||
call.reject("Read failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void mkdirs(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
boolean created = new File(path).mkdirs();
|
||||
call.resolve(new JSObject().put("created", created).put("path", new File(path).getAbsolutePath()));
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void listDir(PluginCall call) {
|
||||
String path = call.getString("path", currentCwd != null ? currentCwd : homeDir);
|
||||
File dir = new File(path);
|
||||
if (!dir.isDirectory()) {
|
||||
call.reject("Not a directory: " + path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
com.getcapacitor.JSArray files = new com.getcapacitor.JSArray();
|
||||
File[] children = dir.listFiles();
|
||||
if (children != null) {
|
||||
for (File f : children) {
|
||||
JSObject entry = new JSObject();
|
||||
entry.put("name", f.getName());
|
||||
entry.put("path", f.getAbsolutePath());
|
||||
entry.put("isDirectory", f.isDirectory());
|
||||
entry.put("size", f.length());
|
||||
files.put(entry);
|
||||
}
|
||||
}
|
||||
call.resolve(new JSObject().put("files", files).put("path", dir.getAbsolutePath()));
|
||||
} catch (Exception e) {
|
||||
call.reject("List failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void deleteFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
boolean deleted = deleteRecursive(new File(path));
|
||||
call.resolve(new JSObject().put("deleted", deleted));
|
||||
}
|
||||
|
||||
private String[] buildEnv() {
|
||||
String toolsBin = toolsDir + "/bin";
|
||||
String toolsUsrBin = toolsDir + "/usr/bin";
|
||||
String appBin = homeDir + "/bin";
|
||||
String systemPath = System.getenv("PATH");
|
||||
String path = appBin + ":" + toolsBin + ":" + toolsUsrBin + ":" + systemPath;
|
||||
|
||||
return new String[]{
|
||||
"HOME=" + homeDir,
|
||||
"PATH=" + path,
|
||||
"PREFIX=" + toolsDir + "/usr",
|
||||
"TMPDIR=" + homeDir + "/tmp",
|
||||
"TERM=xterm-256color",
|
||||
"LANG=en_US.UTF-8",
|
||||
"ANDROID_HOME=" + toolsDir,
|
||||
"ANDROID_SDK_ROOT=" + toolsDir,
|
||||
"JAVA_HOME=" + toolsDir + "/java",
|
||||
"PROJECTS=" + projectsDir
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, String> toEnvMap(String[] envPairs) {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
for (String pair : envPairs) {
|
||||
int idx = pair.indexOf('=');
|
||||
if (idx > 0) {
|
||||
map.put(pair.substring(0, idx), pair.substring(idx + 1));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void executeBlocking(PluginCall call, Process process, String processId, int timeout) {
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
|
||||
StringBuilder output = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
}
|
||||
|
||||
boolean finished = process.waitFor(timeout / 1000, java.util.concurrent.TimeUnit.SECONDS);
|
||||
int exitCode = finished ? process.exitValue() : -1;
|
||||
|
||||
activeProcesses.remove(processId);
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("output", output.toString());
|
||||
result.put("exitCode", exitCode);
|
||||
result.put("pid", processId);
|
||||
if (exitCode == 0 && output.toString().contains("cd ")) {
|
||||
updateCwdFromOutput(output.toString());
|
||||
}
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
activeProcesses.remove(processId);
|
||||
call.reject("Execution error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void streamOutput(PluginCall call, Process process, String processId, String cwd) {
|
||||
call.setKeepAlive(true);
|
||||
String streamId = "shell:" + processId;
|
||||
|
||||
Thread readerThread = new Thread(() -> {
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
|
||||
char[] buffer = new char[4096];
|
||||
int read;
|
||||
while ((read = reader.read(buffer)) != -1) {
|
||||
String chunk = new String(buffer, 0, read);
|
||||
JSObject event = new JSObject();
|
||||
event.put("data", chunk);
|
||||
event.put("pid", processId);
|
||||
notifyListeners(streamId, event);
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
try {
|
||||
exitCode = process.waitFor();
|
||||
} catch (InterruptedException e) {
|
||||
process.destroyForcibly();
|
||||
exitCode = -1;
|
||||
}
|
||||
|
||||
JSObject doneEvent = new JSObject();
|
||||
doneEvent.put("data", "\n[Process exited with code " + exitCode + "]\n");
|
||||
doneEvent.put("pid", processId);
|
||||
doneEvent.put("exitCode", exitCode);
|
||||
doneEvent.put("done", true);
|
||||
notifyListeners(streamId, doneEvent);
|
||||
|
||||
activeProcesses.remove(processId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Stream error", e);
|
||||
JSObject errEvent = new JSObject();
|
||||
errEvent.put("data", "\n[Error: " + e.getMessage() + "]\n");
|
||||
errEvent.put("pid", processId);
|
||||
errEvent.put("done", true);
|
||||
errEvent.put("exitCode", -1);
|
||||
notifyListeners(streamId, errEvent);
|
||||
activeProcesses.remove(processId);
|
||||
}
|
||||
});
|
||||
readerThread.setDaemon(true);
|
||||
readerThread.start();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("pid", processId);
|
||||
result.put("streamId", streamId);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
private void updateCwdFromOutput(String output) {
|
||||
for (String line : output.split("\n")) {
|
||||
line = line.trim();
|
||||
if (line.startsWith("cd ") && !line.contains("&&") && !line.contains(";")) {
|
||||
String target = line.substring(3).trim();
|
||||
File targetDir = target.startsWith("/") ? new File(target) : new File(currentCwd, target);
|
||||
if (targetDir.isDirectory()) {
|
||||
currentCwd = targetDir.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean deleteRecursive(File file) {
|
||||
if (file.isDirectory()) {
|
||||
File[] children = file.listFiles();
|
||||
if (children != null) {
|
||||
for (File child : children) {
|
||||
deleteRecursive(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return file.delete();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
<external-path name="external" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
<files-path name="projects" path="projects/" />
|
||||
<files-path name="tools" path="tools/" />
|
||||
</paths>
|
||||
|
||||
Reference in New Issue
Block a user