diff --git a/package-lock.json b/package-lock.json index c9e575333..3df5a6262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4645,9 +4645,9 @@ } }, "node_modules/cordova-android/node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index 278957a56..7dbbe1723 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "cordova-plugin-browser": {}, "cordova-plugin-sftp": {}, "cordova-plugin-system": {}, - "com.foxdebug.acode.rk.exec.terminal": {}, - "com.foxdebug.acode.rk.exec.proot": {} + "com.foxdebug.acode.rk.exec.proot": {}, + "com.foxdebug.acode.rk.exec.terminal": {} }, "platforms": [ "android" @@ -129,4 +129,4 @@ "yargs": "^18.0.0" }, "browserslist": "cover 100%,not android < 5" -} \ No newline at end of file +} diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 9c2f424a1..94c1eae94 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -588,10 +588,6 @@ class TerminalManager { // Remove from map this.terminals.delete(terminalId); - if (this.getAllTerminals().size <= 0) { - Executor.stopService(); - } - console.log(`Terminal ${terminalId} closed`); } catch (error) { console.error(`Error closing terminal ${terminalId}:`, error); diff --git a/src/plugins/terminal/plugin.xml b/src/plugins/terminal/plugin.xml index fbde6010f..27e78fc14 100644 --- a/src/plugins/terminal/plugin.xml +++ b/src/plugins/terminal/plugin.xml @@ -20,9 +20,9 @@ - - + + @@ -31,25 +31,32 @@ + android:name="com.foxdebug.acode.rk.exec.terminal.AlpineDocumentProvider" + android:authorities="${applicationId}.documents" + android:exported="true" + android:grantUriPermissions="true" + android:icon="@mipmap/ic_launcher" + android:permission="android.permission.MANAGE_DOCUMENTS"> + + + + + + + @@ -57,9 +64,5 @@ - - - - \ No newline at end of file diff --git a/src/plugins/terminal/src/android/Executor.java b/src/plugins/terminal/src/android/Executor.java index 3e0b4e83d..61ebcef03 100644 --- a/src/plugins/terminal/src/android/Executor.java +++ b/src/plugins/terminal/src/android/Executor.java @@ -3,250 +3,91 @@ import org.apache.cordova.*; import org.json.*; -import android.content.ComponentName; +import org.apache.cordova.CordovaInterface; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.CallbackContext; +import org.apache.cordova.PluginResult; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.*; +import java.io.OutputStream; + +import java.util.Map; +import java.util.HashMap; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + import android.content.Context; import android.content.Intent; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.Message; -import android.os.Messenger; -import android.os.RemoteException; -import android.util.Log; - -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import android.Manifest; -import android.content.pm.PackageManager; import android.os.Build; +import android.content.pm.PackageManager; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import android.app.Activity; public class Executor extends CordovaPlugin { - private Messenger serviceMessenger; - private boolean isServiceBound; - private boolean isServiceBinding; // Track if binding is in progress + private final Map processes = new ConcurrentHashMap<>(); + private final Map processInputs = new ConcurrentHashMap<>(); + private final Map processCallbacks = new ConcurrentHashMap<>(); + private Context context; - private Activity activity; - private final Messenger handlerMessenger = new Messenger(new IncomingHandler()); - private CountDownLatch serviceConnectedLatch; - private final java.util.Map callbackContextMap = new java.util.concurrent.ConcurrentHashMap<>(); - - private static final int REQUEST_POST_NOTIFICATIONS = 1001; - - private void askNotificationPermission(Activity context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED) { - } else if (ActivityCompat.shouldShowRequestPermissionRationale( - context, Manifest.permission.POST_NOTIFICATIONS)) { - ActivityCompat.requestPermissions( - context, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - REQUEST_POST_NOTIFICATIONS - ); - } else { - ActivityCompat.requestPermissions( - context, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - REQUEST_POST_NOTIFICATIONS - ); - } - } - } - @Override - public void initialize(CordovaInterface cordova, CordovaWebView webView) { - super.initialize(cordova, webView); - this.context = cordova.getContext(); - this.activity = cordova.getActivity(); - askNotificationPermission(activity); - - // Don't bind service immediately - wait until needed - Log.d("Executor", "Plugin initialized - service will be started when needed"); - } - /** - * Ensure service is bound and ready for communication - * Returns true if service is ready, false if binding failed - */ - private boolean ensureServiceBound(CallbackContext callbackContext) { - // If already bound, return immediately - if (isServiceBound && serviceMessenger != null) { - return true; - } + @Override + public void initialize(CordovaInterface cordova, CordovaWebView webView) { + super.initialize(cordova, webView); + this.context = cordova.getContext(); + + } - // If binding is already in progress, wait for it - if (isServiceBinding) { - try { - if (serviceConnectedLatch != null && - serviceConnectedLatch.await(10, TimeUnit.SECONDS)) { - return isServiceBound; - } else { - callbackContext.error("Service binding timeout"); - return false; - } - } catch (InterruptedException e) { - callbackContext.error("Service binding interrupted: " + e.getMessage()); - return false; + private void checkStopService() { + boolean hasRunning = false; + + for (Process p : processes.values()) { + if (p != null && p.isAlive()) { + hasRunning = true; + break; } } - // Start binding process - Log.d("Executor", "Starting service binding..."); - return bindServiceNow(callbackContext); + if (!hasRunning) { + // Stop foreground service + Intent serviceIntent = new Intent(context, KeepAliveService.class); + context.stopService(serviceIntent); + } } - /** - * Immediately bind to service - */ - private boolean bindServiceNow(CallbackContext callbackContext) { - if (isServiceBinding) { - return false; // Already binding - } + private void startService(){ + Intent serviceIntent = new Intent(this.context, KeepAliveService.class); - isServiceBinding = true; - serviceConnectedLatch = new CountDownLatch(1); - - Intent intent = new Intent(context, TerminalService.class); - - // Start the service first - context.startService(intent); - - // Then bind to it - boolean bindResult = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); - - if (!bindResult) { - Log.e("Executor", "Failed to bind to service"); - isServiceBinding = false; - callbackContext.error("Failed to bind to service"); - return false; - } - - // Wait for connection - try { - if (serviceConnectedLatch.await(10, TimeUnit.SECONDS)) { - Log.d("Executor", "Service bound successfully"); - return isServiceBound; - } else { - Log.e("Executor", "Service binding timeout"); - callbackContext.error("Service binding timeout"); - isServiceBinding = false; - return false; - } - } catch (InterruptedException e) { - Log.e("Executor", "Service binding interrupted: " + e.getMessage()); - callbackContext.error("Service binding interrupted: " + e.getMessage()); - isServiceBinding = false; - return false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android 8.0+ needs startForegroundService() + this.context.startForegroundService(serviceIntent); + } else { + // Older versions use startService() + this.context.startService(serviceIntent); } } - private final ServiceConnection serviceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - Log.d("Executor", "Service connected"); - serviceMessenger = new Messenger(service); - isServiceBound = true; - isServiceBinding = false; - if (serviceConnectedLatch != null) { - serviceConnectedLatch.countDown(); - } - } + - @Override - public void onServiceDisconnected(ComponentName name) { - Log.w("Executor", "Service disconnected"); - serviceMessenger = null; - isServiceBound = false; - isServiceBinding = false; - serviceConnectedLatch = new CountDownLatch(1); - } - }; - - private class IncomingHandler extends Handler { - @Override - public void handleMessage(Message msg) { - Bundle bundle = msg.getData(); - String id = bundle.getString("id"); - String action = bundle.getString("action"); - String data = bundle.getString("data"); - - if (action.equals("exec_result")) { - CallbackContext callbackContext = getCallbackContext(id); - if (callbackContext != null) { - if (bundle.getBoolean("isSuccess", false)) { - callbackContext.success(data); - } else { - callbackContext.error(data); - } - cleanupCallback(id); - } - } else { - String pid = id; - CallbackContext callbackContext = getCallbackContext(pid); - - if (callbackContext != null) { - switch (action) { - case "stdout": - case "stderr": - PluginResult result = new PluginResult(PluginResult.Status.OK, action + ":" + data); - result.setKeepCallback(true); - callbackContext.sendPluginResult(result); - break; - case "exit": - cleanupCallback(pid); - callbackContext.success("exit:" + data); - break; - case "isRunning": - callbackContext.success(data); - cleanupCallback(pid); - break; - } - } - } - } - } @Override public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { - // For actions that don't need the service, handle them directly - if (action.equals("loadLibrary")) { - try { - System.load(args.getString(0)); - callbackContext.success("Library loaded successfully."); - } catch (Exception e) { - callbackContext.error("Failed to load library: " + e.getMessage()); - } - return true; - } + switch (action) { + case "start": - if (action.equals("stopService")) { - stopServiceNow(); - callbackContext.success("Service stopped"); - return true; - } + startService(); - // For all other actions, ensure service is bound first - if (!ensureServiceBound(callbackContext)) { - // Error already sent by ensureServiceBound - return false; - } - switch (action) { - case "start": String cmdStart = args.getString(0); String pid = UUID.randomUUID().toString(); - callbackContextMap.put(pid, callbackContext); - startProcess(pid, cmdStart, args.getString(1)); + startProcess(pid, cmdStart,args.getString(1), callbackContext); return true; case "write": String pidWrite = args.getString(0); @@ -258,14 +99,22 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo stopProcess(pidStop, callbackContext); return true; case "exec": - String execId = UUID.randomUUID().toString(); - callbackContextMap.put(execId, callbackContext); - exec(execId, args.getString(0), args.getString(1)); + + startService(); + + + exec(args.getString(0),args.getString(1), callbackContext); return true; case "isRunning": - String pidCheck = args.getString(0); - callbackContextMap.put(pidCheck, callbackContext); - isProcessRunning(pidCheck); + isProcessRunning(args.getString(0), callbackContext); + return true; + case "loadLibrary": + try { + System.load(args.getString(0)); + callbackContext.success("Library loaded successfully."); + } catch (Exception e) { + callbackContext.error("Failed to load library: " + e.getMessage()); + } return true; default: callbackContext.error("Unknown action: " + action); @@ -273,127 +122,194 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo } } - private void stopServiceNow() { - if (isServiceBound) { - try { - context.unbindService(serviceConnection); - Log.d("Executor", "Service unbound"); - } catch (IllegalArgumentException ignored) { - // already unbound - } - isServiceBound = false; - } - isServiceBinding = false; - - Intent intent = new Intent(context, TerminalService.class); - boolean stopped = context.stopService(intent); - Log.d("Executor", "Service stop result: " + stopped); - - serviceMessenger = null; - if (serviceConnectedLatch == null) { - serviceConnectedLatch = new CountDownLatch(1); - } - } + private void exec(String cmd,String alpine, CallbackContext callbackContext) { + cordova.getThreadPool().execute(() -> { + try { + if (cmd != null && !cmd.isEmpty()) { + String xcmd = cmd; + if(alpine.equals("true")){ + xcmd = "source $PREFIX/init-sandbox.sh "+cmd; + } + + ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); + + // Set environment variables + Map env = builder.environment(); + env.put("PREFIX", context.getFilesDir().getAbsolutePath()); + env.put("NATIVE_DIR", context.getApplicationInfo().nativeLibraryDir); + TimeZone tz = TimeZone.getDefault(); + String timezoneId = tz.getID(); + env.put("ANDROID_TZ", timezoneId); + + try { + int target = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.targetSdkVersion; + env.put("FDROID", String.valueOf(target <= 28)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } - private void startProcess(String pid, String cmd, String alpine) { - CallbackContext callbackContext = getCallbackContext(pid); - if (callbackContext != null) { - PluginResult result = new PluginResult(PluginResult.Status.OK, pid); - result.setKeepCallback(true); - callbackContext.sendPluginResult(result); - } - Message msg = Message.obtain(null, TerminalService.MSG_START_PROCESS); - msg.replyTo = handlerMessenger; - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - bundle.putString("cmd", cmd); - bundle.putString("alpine", alpine); - msg.setData(bundle); - try { - serviceMessenger.send(msg); - } catch (RemoteException e) { - CallbackContext errorContext = getCallbackContext(pid); - if (errorContext != null) { - errorContext.error("Failed to start process: " + e.getMessage()); - cleanupCallback(pid); + Process process = builder.start(); + + // Capture stdout + BufferedReader stdOutReader = new BufferedReader( + new InputStreamReader(process.getInputStream())); + StringBuilder stdOut = new StringBuilder(); + String line; + while ((line = stdOutReader.readLine()) != null) { + stdOut.append(line).append("\n"); + } + + // Capture stderr + BufferedReader stdErrReader = new BufferedReader( + new InputStreamReader(process.getErrorStream())); + StringBuilder stdErr = new StringBuilder(); + while ((line = stdErrReader.readLine()) != null) { + stdErr.append(line).append("\n"); + } + + int exitCode = process.waitFor(); + if (exitCode == 0) { + callbackContext.success(stdOut.toString().trim()); + } else { + String errorOutput = stdErr.toString().trim(); + if (errorOutput.isEmpty()) { + errorOutput = "Command exited with code: " + exitCode; + } + callbackContext.error(errorOutput); + } + } else { + callbackContext.error("Expected one non-empty string argument."); } + } catch (Exception e) { + e.printStackTrace(); + callbackContext.error("Exception: " + e.getMessage()); } + }); } - private void exec(String execId, String cmd, String alpine) { - Message msg = Message.obtain(null, TerminalService.MSG_EXEC); - msg.replyTo = handlerMessenger; - Bundle bundle = new Bundle(); - bundle.putString("id", execId); - bundle.putString("cmd", cmd); - bundle.putString("alpine", alpine); - msg.setData(bundle); - try { - serviceMessenger.send(msg); - } catch (RemoteException e) { - CallbackContext callbackContext = getCallbackContext(execId); - if (callbackContext != null) { - callbackContext.error("Failed to execute command: " + e.getMessage()); - cleanupCallback(execId); + private void startProcess(String pid, String cmd,String alpine, CallbackContext callbackContext) { + cordova.getThreadPool().execute(() -> { + try { + String xcmd = cmd; + if(alpine.equals("true")){ + xcmd = "source $PREFIX/init-sandbox.sh "+cmd; + } + ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); + + // Set environment variables + Map env = builder.environment(); + env.put("PREFIX", context.getFilesDir().getAbsolutePath()); + env.put("NATIVE_DIR", context.getApplicationInfo().nativeLibraryDir); + TimeZone tz = TimeZone.getDefault(); + String timezoneId = tz.getID(); + env.put("ANDROID_TZ", timezoneId); + + try { + int target = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.targetSdkVersion; + env.put("FDROID", String.valueOf(target <= 28)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + + + + Process process = builder.start(); + + processes.put(pid, process); + processInputs.put(pid, process.getOutputStream()); + processCallbacks.put(pid, callbackContext); + + PluginResult pluginResult = new PluginResult(PluginResult.Status.OK, pid); + pluginResult.setKeepCallback(true); + callbackContext.sendPluginResult(pluginResult); + + // stdout thread + new Thread(() -> streamOutput(process.getInputStream(), pid, "stdout")).start(); + // stderr thread + new Thread(() -> streamOutput(process.getErrorStream(), pid, "stderr")).start(); + + int exitCode = process.waitFor(); + sendPluginMessage(pid, "exit:" + exitCode); + cleanup(pid); + } catch (Exception e) { + callbackContext.error("Failed to start process: " + e.getMessage()); } - } + }); } private void writeToProcess(String pid, String input, CallbackContext callbackContext) { - Message msg = Message.obtain(null, TerminalService.MSG_WRITE_TO_PROCESS); - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - bundle.putString("input", input); - msg.setData(bundle); try { - serviceMessenger.send(msg); - callbackContext.success("Written to process"); - } catch (RemoteException e) { + OutputStream os = processInputs.get(pid); + if (os != null) { + os.write((input + "\n").getBytes()); + os.flush(); + callbackContext.success("Written to process"); + } else { + callbackContext.error("Process not found or closed"); + } + } catch (IOException e) { callbackContext.error("Write error: " + e.getMessage()); } } private void stopProcess(String pid, CallbackContext callbackContext) { - Message msg = Message.obtain(null, TerminalService.MSG_STOP_PROCESS); - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - msg.setData(bundle); - try { - serviceMessenger.send(msg); + Process process = processes.get(pid); + if (process != null) { + process.destroy(); + cleanup(pid); + // Check if we should stop the service + checkStopService(); callbackContext.success("Process terminated"); - } catch (RemoteException e) { - callbackContext.error("Stop error: " + e.getMessage()); + } else { + callbackContext.error("No such process"); } } - private void isProcessRunning(String pid) { - Message msg = Message.obtain(null, TerminalService.MSG_IS_RUNNING); - msg.replyTo = handlerMessenger; - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - msg.setData(bundle); - try { - serviceMessenger.send(msg); - } catch (RemoteException e) { - CallbackContext callbackContext = getCallbackContext(pid); - if (callbackContext != null) { - callbackContext.error("Check running error: " + e.getMessage()); - cleanupCallback(pid); + private void isProcessRunning(String pid, CallbackContext callbackContext) { + Process process = processes.get(pid); + + if (process != null) { + if (process.isAlive()) { + callbackContext.success("running"); + } else { + cleanup(pid); + callbackContext.success("exited"); } + } else { + callbackContext.success("not_found"); } } - private CallbackContext getCallbackContext(String id) { - return callbackContextMap.get(id); + private void streamOutput(InputStream inputStream, String pid, String streamType) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = reader.readLine()) != null) { + sendPluginMessage(pid, streamType + ":" + line); + } + } catch (IOException ignored) { + } } - private void cleanupCallback(String id) { - callbackContextMap.remove(id); + private void sendPluginMessage(String pid, String message) { + CallbackContext ctx = processCallbacks.get(pid); + if (ctx != null) { + PluginResult result = new PluginResult(PluginResult.Status.OK, message); + result.setKeepCallback(true); + ctx.sendPluginResult(result); + } } @Override public void onDestroy() { super.onDestroy(); + //Intent serviceIntent = new Intent(context, KeepAliveService.class); + //context.stopService(serviceIntent); + } + + private void cleanup(String pid) { + processes.remove(pid); + processInputs.remove(pid); + processCallbacks.remove(pid); } } \ No newline at end of file diff --git a/src/plugins/terminal/src/android/KeepAliveService.java b/src/plugins/terminal/src/android/KeepAliveService.java new file mode 100644 index 000000000..ca35546b2 --- /dev/null +++ b/src/plugins/terminal/src/android/KeepAliveService.java @@ -0,0 +1,117 @@ +package com.foxdebug.acode.rk.exec.terminal; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +public class KeepAliveService extends Service { + + private static final String CHANNEL_ID = "keepalive_channel"; + private static final int NOTIFICATION_ID = 101; + + private PowerManager.WakeLock wakeLock; + + public static final String ACTION_ACQUIRE = "ACQUIRE_WAKELOCK"; + public static final String ACTION_EXIT = "EXIT_SERVICE"; + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + startForeground(NOTIFICATION_ID, buildNotification()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + + if (intent != null && intent.getAction() != null) { + switch (intent.getAction()) { + + case ACTION_ACQUIRE: + acquireWakeLock(); + break; + + case ACTION_EXIT: + stopSelf(); + break; + } + } + + // Update notification (in case state changed) + startForeground(NOTIFICATION_ID, buildNotification()); + return START_STICKY; + } + + private void acquireWakeLock() { + if (wakeLock == null) { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "KeepAliveService:WakeLock"); + wakeLock.setReferenceCounted(false); + } + + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private Notification buildNotification() { + + // Intent: Acquire WakeLock + Intent acquireIntent = new Intent(this, KeepAliveService.class); + acquireIntent.setAction(ACTION_ACQUIRE); + + PendingIntent acquirePending = PendingIntent.getService( + this, 0, acquireIntent, PendingIntent.FLAG_IMMUTABLE); + + // Intent: Exit Service + Intent exitIntent = new Intent(this, KeepAliveService.class); + exitIntent.setAction(ACTION_EXIT); + + PendingIntent exitPending = PendingIntent.getService( + this, 1, exitIntent, PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("KeepAlive Service") + .setContentText("Running in foreground") + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .addAction(0, "Acquire WakeLock", acquirePending) + .addAction(0, "Exit", exitPending) + .setOngoing(true); + + return builder.build(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "KeepAlive", + NotificationManager.IMPORTANCE_LOW); + + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(channel); + } + } +} diff --git a/src/plugins/terminal/src/android/TerminalService.java b/src/plugins/terminal/src/android/TerminalService.java deleted file mode 100644 index f8e26c67f..000000000 --- a/src/plugins/terminal/src/android/TerminalService.java +++ /dev/null @@ -1,413 +0,0 @@ -package com.foxdebug.acode.rk.exec.terminal; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.Message; -import android.os.Messenger; -import android.os.PowerManager; -import android.os.RemoteException; -import androidx.core.app.NotificationCompat; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.lang.reflect.Field; - -import java.util.TimeZone; -import java.util.Map; -import java.util.HashMap; - - - -public class TerminalService extends Service { - - public static final int MSG_START_PROCESS = 1; - public static final int MSG_WRITE_TO_PROCESS = 2; - public static final int MSG_STOP_PROCESS = 3; - public static final int MSG_IS_RUNNING = 4; - public static final int MSG_EXEC = 5; - - public static final String CHANNEL_ID = "terminal_exec_channel"; - - public static final String ACTION_EXIT_SERVICE = "com.foxdebug.acode.ACTION_EXIT_SERVICE"; - public static final String ACTION_TOGGLE_WAKE_LOCK = "com.foxdebug.acode.ACTION_TOGGLE_WAKE_LOCK"; - - private final Map processes = new ConcurrentHashMap<>(); - private final Map processInputs = new ConcurrentHashMap<>(); - private final Map clientMessengers = new ConcurrentHashMap<>(); - private final java.util.concurrent.ExecutorService threadPool = Executors.newCachedThreadPool(); - - private final Messenger serviceMessenger = new Messenger(new ServiceHandler()); - - private PowerManager.WakeLock wakeLock; - private boolean isWakeLockHeld = false; - - @Override - public IBinder onBind(Intent intent) { - return serviceMessenger.getBinder(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null) { - String action = intent.getAction(); - if (ACTION_EXIT_SERVICE.equals(action)) { - stopForeground(true); - stopSelf(); - return START_NOT_STICKY; - } else if (ACTION_TOGGLE_WAKE_LOCK.equals(action)) { - toggleWakeLock(); - } - } - return START_STICKY; - } - - private class ServiceHandler extends Handler { - @Override - public void handleMessage(Message msg) { - Bundle bundle = msg.getData(); - String id = bundle.getString("id"); - Messenger clientMessenger = msg.replyTo; - - switch (msg.what) { - case MSG_START_PROCESS: - String cmd = bundle.getString("cmd"); - String alpine = bundle.getString("alpine"); - clientMessengers.put(id, clientMessenger); - startProcess(id, cmd, alpine); - break; - case MSG_WRITE_TO_PROCESS: - String input = bundle.getString("input"); - writeToProcess(id, input); - break; - case MSG_STOP_PROCESS: - stopProcess(id); - break; - case MSG_IS_RUNNING: - isProcessRunning(id, clientMessenger); - break; - case MSG_EXEC: - String execCmd = bundle.getString("cmd"); - String execAlpine = bundle.getString("alpine"); - clientMessengers.put(id, clientMessenger); - exec(id, execCmd, execAlpine); - break; - } - } - } - - private void toggleWakeLock() { - if (isWakeLockHeld) { - releaseWakeLock(); - } else { - acquireWakeLock(); - } - updateNotification(); - } - - private void acquireWakeLock() { - if (wakeLock == null) { - PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AcodeTerminal:WakeLock"); - } - - if (!isWakeLockHeld) { - wakeLock.acquire(); - isWakeLockHeld = true; - } - } - - private void releaseWakeLock() { - if (wakeLock != null && isWakeLockHeld) { - wakeLock.release(); - isWakeLockHeld = false; - } - } - - private void startProcess(String pid, String cmd, String alpine) { - threadPool.execute(() -> { - try { - String xcmd = alpine.equals("true") ? "source $PREFIX/init-sandbox.sh " + cmd : cmd; - ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); - - Map env = builder.environment(); - env.put("PREFIX", getFilesDir().getAbsolutePath()); - env.put("NATIVE_DIR", getApplicationInfo().nativeLibraryDir); - TimeZone tz = TimeZone.getDefault(); - String timezoneId = tz.getID(); - env.put("ANDROID_TZ", timezoneId); - - try { - int target = getPackageManager().getPackageInfo(getPackageName(), 0).applicationInfo.targetSdkVersion; - env.put("FDROID", String.valueOf(target <= 28)); - } catch (Exception e) { - e.printStackTrace(); - } - - Process process = builder.start(); - processes.put(pid, process); - processInputs.put(pid, process.getOutputStream()); - threadPool.execute(() -> streamOutput(process.getInputStream(), pid, "stdout")); - threadPool.execute(() -> streamOutput(process.getErrorStream(), pid, "stderr")); - threadPool.execute(() -> { - try { - int exitCode = process.waitFor(); - sendMessageToClient(pid, "exit", String.valueOf(exitCode)); - cleanup(pid); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - } catch (IOException e) { - e.printStackTrace(); - sendMessageToClient(pid, "stderr", "Failed to start process: " + e.getMessage()); - sendMessageToClient(pid, "exit", "1"); - cleanup(pid); - } - }); - } - - private void exec(String execId, String cmd, String alpine) { - threadPool.execute(() -> { - try { - String xcmd = alpine.equals("true") ? "source $PREFIX/init-sandbox.sh " + cmd : cmd; - ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); - Map env = builder.environment(); - env.put("PREFIX", getFilesDir().getAbsolutePath()); - env.put("NATIVE_DIR", getApplicationInfo().nativeLibraryDir); - TimeZone tz = TimeZone.getDefault(); - String timezoneId = tz.getID(); - env.put("ANDROID_TZ", timezoneId); - - try { - int target = getPackageManager().getPackageInfo(getPackageName(), 0).applicationInfo.targetSdkVersion; - env.put("FDROID", String.valueOf(target <= 28)); - } catch (Exception e) { - e.printStackTrace(); - } - - Process process = builder.start(); - BufferedReader stdOutReader = new BufferedReader( - new InputStreamReader(process.getInputStream())); - StringBuilder stdOut = new StringBuilder(); - String line; - while ((line = stdOutReader.readLine()) != null) { - stdOut.append(line).append("\n"); - } - - BufferedReader stdErrReader = new BufferedReader( - new InputStreamReader(process.getErrorStream())); - StringBuilder stdErr = new StringBuilder(); - while ((line = stdErrReader.readLine()) != null) { - stdErr.append(line).append("\n"); - } - - int exitCode = process.waitFor(); - - if (exitCode == 0) { - sendExecResultToClient(execId, true, stdOut.toString().trim()); - } else { - String errorOutput = stdErr.toString().trim(); - if (errorOutput.isEmpty()) { - errorOutput = "Command exited with code: " + exitCode; - } - sendExecResultToClient(execId, false, errorOutput); - } - - cleanup(execId); - } catch (Exception e) { - sendExecResultToClient(execId, false, "Exception: " + e.getMessage()); - cleanup(execId); - } - }); - } - - private void streamOutput(InputStream inputStream, String pid, String streamType) { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - String line; - while ((line = reader.readLine()) != null) { - sendMessageToClient(pid, streamType, line); - } - } catch (IOException ignored) { - } - } - - private void sendMessageToClient(String id, String action, String data) { - Messenger clientMessenger = clientMessengers.get(id); - if (clientMessenger != null) { - try { - Message msg = Message.obtain(); - Bundle bundle = new Bundle(); - bundle.putString("id", id); - bundle.putString("action", action); - bundle.putString("data", data); - msg.setData(bundle); - clientMessenger.send(msg); - } catch (RemoteException e) { - cleanup(id); - } - } - } - - private void sendExecResultToClient(String id, boolean isSuccess, String data) { - Messenger clientMessenger = clientMessengers.get(id); - if (clientMessenger != null) { - try { - Message msg = Message.obtain(); - Bundle bundle = new Bundle(); - bundle.putString("id", id); - bundle.putString("action", "exec_result"); - bundle.putString("data", data); - bundle.putBoolean("isSuccess", isSuccess); - msg.setData(bundle); - clientMessenger.send(msg); - } catch (RemoteException e) { - cleanup(id); - } - } - } - - private void writeToProcess(String pid, String input) { - try { - OutputStream os = processInputs.get(pid); - if (os != null) { - os.write((input + "\n").getBytes()); - os.flush(); - } - } catch (IOException e) { - e.printStackTrace(); - } - } - - private long getPid(Process process) { - try { - Field f = process.getClass().getDeclaredField("pid"); - f.setAccessible(true); - return f.getLong(process); - } catch (Exception e) { - return -1; - } -} - - - private void stopProcess(String pid) { - Process process = processes.get(pid); - if (process != null) { - try { - Runtime.getRuntime().exec("kill -9 -" + getPid(process)); - } catch (Exception ignored) {} - process.destroy(); - cleanup(pid); - } - } - - private void isProcessRunning(String pid, Messenger clientMessenger) { - Process process = processes.get(pid); - String status = process != null && isProcessAlive(process) ? "running" : "not_found"; - sendMessageToClient(pid, "isRunning", status); - } - - private boolean isProcessAlive(Process process) { - try { - process.exitValue(); - return false; - } catch(IllegalThreadStateException e) { - return true; - } - } - - private void cleanup(String id) { - processes.remove(id); - processInputs.remove(id); - clientMessengers.remove(id); - } - - @Override - public void onCreate() { - super.onCreate(); - createNotificationChannel(); - updateNotification(); - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel serviceChannel = new NotificationChannel( - CHANNEL_ID, - "Terminal Executor Channel", - NotificationManager.IMPORTANCE_LOW - ); - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager != null) { - manager.createNotificationChannel(serviceChannel); - } - } - } - - private void updateNotification() { - Intent exitIntent = new Intent(this, TerminalService.class); - exitIntent.setAction(ACTION_EXIT_SERVICE); - PendingIntent exitPendingIntent = PendingIntent.getService(this, 0, exitIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - Intent wakeLockIntent = new Intent(this, TerminalService.class); - wakeLockIntent.setAction(ACTION_TOGGLE_WAKE_LOCK); - PendingIntent wakeLockPendingIntent = PendingIntent.getService(this, 1, wakeLockIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - String contentText = "Executor service" + (isWakeLockHeld ? " (wakelock held)" : ""); - String wakeLockButtonText = isWakeLockHeld ? "Release Wake Lock" : "Acquire Wake Lock"; - - int notificationIcon = resolveDrawableId("ic_notification", "ic_launcher_foreground", "ic_launcher"); - - Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Acode Service") - .setContentText(contentText) - .setSmallIcon(notificationIcon) - .setOngoing(true) - .addAction(notificationIcon, wakeLockButtonText, wakeLockPendingIntent) - .addAction(notificationIcon, "Exit", exitPendingIntent) - .build(); - - startForeground(1, notification); - } - - @Override - public void onDestroy() { - stopForeground(true); - super.onDestroy(); - releaseWakeLock(); - - for (Process process : processes.values()) { - try { - Runtime.getRuntime().exec("kill -9 -" + getPid(process)); - } catch (Exception ignored) {} - process.destroyForcibly(); - } - - processes.clear(); - processInputs.clear(); - clientMessengers.clear(); - threadPool.shutdown(); - } - - private int resolveDrawableId(String... names) { - for (String name : names) { - int id = getResources().getIdentifier(name, "drawable", getPackageName()); - if (id != 0) return id; - } - return android.R.drawable.sym_def_app_icon; - } -} diff --git a/src/plugins/terminal/www/Executor.js b/src/plugins/terminal/www/Executor.js index 78103fafc..845e018f6 100644 --- a/src/plugins/terminal/www/Executor.js +++ b/src/plugins/terminal/www/Executor.js @@ -30,33 +30,22 @@ const Executor = { */ - start(command, onData) { - this.start(command, onData, false); + start(command,onData){ + this.start(command,onData,true) }, start(command, onData, alpine) { - console.log("start: " + command); - return new Promise((resolve, reject) => { - let first = true; - exec(async (message) => { - console.log(message); - if (first) { - first = false; - await new Promise(resolve => setTimeout(resolve, 100)); + exec( + (message) => { + // Stream stdout, stderr, or exit notifications + if (message.startsWith("stdout:")) return onData("stdout", message.slice(7)); + if (message.startsWith("stderr:")) return onData("stderr", message.slice(7)); + if (message.startsWith("exit:")) return onData("exit", message.slice(5)); + // First message is always the process UUID resolve(message); - } else { - const match = message.match(/^([^:]+):(.*)$/); - if (match) { - const prefix = match[1]; // e.g. "stdout" - const message = match[2].trim(); // output - onData(prefix, message); - } else { - onData("unknown", message); - } - } - }, + }, reject, "Executor", "start", @@ -73,10 +62,9 @@ const Executor = { * @returns {Promise} Resolves once the input is written. * * @example - * Executor.write(uuid, 'ls /sdcard'); + * Executor.write(uuid, 'ls /data'); */ write(uuid, input) { - console.log("write: " + input + " to " + uuid); return new Promise((resolve, reject) => { exec(resolve, reject, "Executor", "write", [uuid, input]); }); @@ -114,12 +102,6 @@ const Executor = { }); }, - stopService() { - return new Promise((resolve, reject) => { - exec(resolve, reject, "Executor", "stopService", []); - }); - }, - /** * Executes a shell command once and waits for it to finish. * Unlike {@link Executor.start}, this does not stream output. @@ -133,8 +115,8 @@ const Executor = { * .then(console.log) * .catch(console.error); */ - execute(command) { - this.execute(command, false); + execute(command){ + this.execute(command,false) } , execute(command, alpine) { @@ -143,7 +125,7 @@ const Executor = { }); }, - loadLibrary(path) { + loadLibrary(path){ return new Promise((resolve, reject) => { exec(resolve, reject, "Executor", "loadLibrary", [path]); });