v1.3.0: Full terminal with shell execution, APK build/install, AI deploy pipeline

This commit is contained in:
admin
2026-05-19 16:48:23 +04:00
Unverified
parent 426787b161
commit 83fb658a1e
12 changed files with 1483 additions and 12 deletions

View File

@@ -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:!*~'

View File

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

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

View File

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

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

View File

@@ -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>