345 lines
15 KiB
Java
345 lines
15 KiB
Java
package com.termux.app;
|
|
|
|
import android.annotation.SuppressLint;
|
|
import android.app.Notification;
|
|
import android.app.NotificationManager;
|
|
import android.app.PendingIntent;
|
|
import android.app.Service;
|
|
import android.content.Context;
|
|
import android.content.Intent;
|
|
import android.content.res.Resources;
|
|
import android.net.Uri;
|
|
import android.net.wifi.WifiManager;
|
|
import android.os.Binder;
|
|
import android.os.Handler;
|
|
import android.os.IBinder;
|
|
import android.os.PowerManager;
|
|
import android.support.v4.content.WakefulBroadcastReceiver;
|
|
import android.util.Log;
|
|
import android.widget.ArrayAdapter;
|
|
|
|
import com.termux.R;
|
|
import com.termux.terminal.EmulatorDebug;
|
|
import com.termux.terminal.TerminalSession;
|
|
import com.termux.terminal.TerminalSession.SessionChangedCallback;
|
|
|
|
import java.io.File;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* A service holding a list of terminal sessions, {@link #mTerminalSessions}, showing a foreground notification while
|
|
* running so that it is not terminated. The user interacts with the session through {@link TermuxActivity}, but this
|
|
* service may outlive the activity when the user or the system disposes of the activity. In that case the user may
|
|
* restart {@link TermuxActivity} later to yet again access the sessions.
|
|
* <p/>
|
|
* In order to keep both terminal sessions and spawned processes (who may outlive the terminal sessions) alive as long
|
|
* as wanted by the user this service is a foreground service, {@link Service#startForeground(int, Notification)}.
|
|
* <p/>
|
|
* Optionally may hold a wake and a wifi lock, in which case that is shown in the notification - see
|
|
* {@link #buildNotification()}.
|
|
*/
|
|
public final class TermuxService extends Service implements SessionChangedCallback {
|
|
|
|
/** Note that this is a symlink on the Android M preview. */
|
|
@SuppressLint("SdCardPath")
|
|
public static final String FILES_PATH = "/data/data/com.termux/files";
|
|
public static final String PREFIX_PATH = FILES_PATH + "/usr";
|
|
public static final String HOME_PATH = FILES_PATH + "/home";
|
|
|
|
private static final int NOTIFICATION_ID = 1337;
|
|
|
|
private static final String ACTION_STOP_SERVICE = "com.termux.service_stop";
|
|
private static final String ACTION_LOCK_WAKE = "com.termux.service_wake_lock";
|
|
private static final String ACTION_UNLOCK_WAKE = "com.termux.service_wake_unlock";
|
|
/** Intent action to launch a new terminal session. Executed from TermuxWidgetProvider. */
|
|
public static final String ACTION_EXECUTE = "com.termux.service_execute";
|
|
|
|
public static final String EXTRA_ARGUMENTS = "com.termux.execute.arguments";
|
|
|
|
public static final String EXTRA_CURRENT_WORKING_DIRECTORY = "com.termux.execute.cwd";
|
|
private static final String EXTRA_EXECUTE_IN_BACKGROUND = "com.termux.execute.background";
|
|
|
|
/** This service is only bound from inside the same process and never uses IPC. */
|
|
class LocalBinder extends Binder {
|
|
public final TermuxService service = TermuxService.this;
|
|
}
|
|
|
|
private final IBinder mBinder = new LocalBinder();
|
|
|
|
private final Handler mHandler = new Handler();
|
|
|
|
/**
|
|
* The terminal sessions which this service manages.
|
|
* <p/>
|
|
* Note that this list is observed by {@link TermuxActivity#mListViewAdapter}, so any changes must be made on the UI
|
|
* thread and followed by a call to {@link ArrayAdapter#notifyDataSetChanged()} }.
|
|
*/
|
|
final List<TerminalSession> mTerminalSessions = new ArrayList<>();
|
|
|
|
final List<BackgroundJob> mBackgroundTasks = new ArrayList<>();
|
|
|
|
/** Note that the service may often outlive the activity, so need to clear this reference. */
|
|
SessionChangedCallback mSessionChangeCallback;
|
|
|
|
/** The wake lock and wifi lock are always acquired and released together. */
|
|
private PowerManager.WakeLock mWakeLock;
|
|
private WifiManager.WifiLock mWifiLock;
|
|
|
|
/** If the user has executed the {@link #ACTION_STOP_SERVICE} intent. */
|
|
boolean mWantsToStop = false;
|
|
|
|
@SuppressLint("Wakelock")
|
|
@Override
|
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
|
String action = intent.getAction();
|
|
if (ACTION_STOP_SERVICE.equals(action)) {
|
|
mWantsToStop = true;
|
|
for (int i = 0; i < mTerminalSessions.size(); i++)
|
|
mTerminalSessions.get(i).finishIfRunning();
|
|
stopSelf();
|
|
} else if (ACTION_LOCK_WAKE.equals(action)) {
|
|
if (mWakeLock == null) {
|
|
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
|
mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, EmulatorDebug.LOG_TAG);
|
|
mWakeLock.acquire();
|
|
|
|
// http://tools.android.com/tech-docs/lint-in-studio-2-3#TOC-WifiManager-Leak
|
|
WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
|
|
mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, EmulatorDebug.LOG_TAG);
|
|
mWifiLock.acquire();
|
|
|
|
updateNotification();
|
|
}
|
|
} else if (ACTION_UNLOCK_WAKE.equals(action)) {
|
|
if (mWakeLock != null) {
|
|
mWakeLock.release();
|
|
mWakeLock = null;
|
|
|
|
mWifiLock.release();
|
|
mWifiLock = null;
|
|
|
|
updateNotification();
|
|
}
|
|
} else if (ACTION_EXECUTE.equals(action)) {
|
|
Uri executableUri = intent.getData();
|
|
String executablePath = (executableUri == null ? null : executableUri.getPath());
|
|
|
|
String[] arguments = (executableUri == null ? null : intent.getStringArrayExtra(EXTRA_ARGUMENTS));
|
|
String cwd = intent.getStringExtra(EXTRA_CURRENT_WORKING_DIRECTORY);
|
|
|
|
if (intent.getBooleanExtra(EXTRA_EXECUTE_IN_BACKGROUND, false)) {
|
|
BackgroundJob task = new BackgroundJob(cwd, executablePath, arguments, this);
|
|
mBackgroundTasks.add(task);
|
|
updateNotification();
|
|
} else {
|
|
TerminalSession newSession = createTermSession(executablePath, arguments, cwd, false);
|
|
|
|
// Transform executable path to session name, e.g. "/bin/do-something.sh" => "do something.sh".
|
|
if (executablePath != null) {
|
|
int lastSlash = executablePath.lastIndexOf('/');
|
|
String name = (lastSlash == -1) ? executablePath : executablePath.substring(lastSlash + 1);
|
|
name = name.replace('-', ' ');
|
|
newSession.mSessionName = name;
|
|
}
|
|
|
|
// Make the newly created session the current one to be displayed:
|
|
TermuxPreferences.storeCurrentSession(this, newSession);
|
|
|
|
// Launch the main Termux app, which will now show the current session:
|
|
startActivity(new Intent(this, TermuxActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
|
|
}
|
|
} else if (action != null) {
|
|
Log.e(EmulatorDebug.LOG_TAG, "Unknown TermuxService action: '" + action + "'");
|
|
}
|
|
|
|
if ((flags & START_FLAG_REDELIVERY) == 0) {
|
|
// Service is started by WBR, not restarted by system, so release the WakeLock from WBR.
|
|
WakefulBroadcastReceiver.completeWakefulIntent(intent);
|
|
}
|
|
|
|
// If this service really do get killed, there is no point restarting it automatically - let the user do on next
|
|
// start of {@link Term):
|
|
return Service.START_NOT_STICKY;
|
|
}
|
|
|
|
@Override
|
|
public IBinder onBind(Intent intent) {
|
|
return mBinder;
|
|
}
|
|
|
|
@Override
|
|
public void onCreate() {
|
|
startForeground(NOTIFICATION_ID, buildNotification());
|
|
}
|
|
|
|
/** Update the shown foreground service notification after making any changes that affect it. */
|
|
void updateNotification() {
|
|
if (mWakeLock == null && mTerminalSessions.isEmpty() && mBackgroundTasks.isEmpty()) {
|
|
// Exit if we are updating after the user disabled all locks with no sessions or tasks running.
|
|
stopSelf();
|
|
} else {
|
|
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, buildNotification());
|
|
}
|
|
}
|
|
|
|
private Notification buildNotification() {
|
|
Intent notifyIntent = new Intent(this, TermuxActivity.class);
|
|
// PendingIntent#getActivity(): "Note that the activity will be started outside of the context of an existing
|
|
// activity, so you must use the Intent.FLAG_ACTIVITY_NEW_TASK launch flag in the Intent":
|
|
notifyIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notifyIntent, 0);
|
|
|
|
int sessionCount = mTerminalSessions.size();
|
|
int taskCount = mBackgroundTasks.size();
|
|
String contentText = sessionCount + " session" + (sessionCount == 1 ? "" : "s");
|
|
if (taskCount > 0) {
|
|
contentText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s");
|
|
}
|
|
|
|
final boolean wakeLockHeld = mWakeLock != null;
|
|
if (wakeLockHeld) contentText += " (wake lock held)";
|
|
|
|
Notification.Builder builder = new Notification.Builder(this);
|
|
builder.setContentTitle(getText(R.string.application_name));
|
|
builder.setContentText(contentText);
|
|
builder.setSmallIcon(R.drawable.ic_service_notification);
|
|
builder.setContentIntent(pendingIntent);
|
|
builder.setOngoing(true);
|
|
|
|
// If holding a wake or wifi lock consider the notification of high priority since it's using power,
|
|
// otherwise use a minimal priority since this is just a background service notification:
|
|
builder.setPriority((wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_MIN);
|
|
|
|
// No need to show a timestamp:
|
|
builder.setShowWhen(false);
|
|
|
|
// Background color for small notification icon:
|
|
builder.setColor(0xFF000000);
|
|
|
|
Resources res = getResources();
|
|
Intent exitIntent = new Intent(this, TermuxService.class).setAction(ACTION_STOP_SERVICE);
|
|
builder.addAction(android.R.drawable.ic_delete, res.getString(R.string.notification_action_exit), PendingIntent.getService(this, 0, exitIntent, 0));
|
|
|
|
String newWakeAction = wakeLockHeld ? ACTION_UNLOCK_WAKE : ACTION_LOCK_WAKE;
|
|
Intent toggleWakeLockIntent = new Intent(this, TermuxService.class).setAction(newWakeAction);
|
|
String actionTitle = res.getString(wakeLockHeld ?
|
|
R.string.notification_action_wake_unlock :
|
|
R.string.notification_action_wake_lock);
|
|
int actionIcon = wakeLockHeld ? android.R.drawable.ic_lock_idle_lock : android.R.drawable.ic_lock_lock;
|
|
builder.addAction(actionIcon, actionTitle, PendingIntent.getService(this, 0, toggleWakeLockIntent, 0));
|
|
|
|
return builder.build();
|
|
}
|
|
|
|
@Override
|
|
public void onDestroy() {
|
|
if (mWakeLock != null) mWakeLock.release();
|
|
if (mWifiLock != null) mWifiLock.release();
|
|
|
|
stopForeground(true);
|
|
|
|
for (int i = 0; i < mTerminalSessions.size(); i++)
|
|
mTerminalSessions.get(i).finishIfRunning();
|
|
mTerminalSessions.clear();
|
|
}
|
|
|
|
public List<TerminalSession> getSessions() {
|
|
return mTerminalSessions;
|
|
}
|
|
|
|
TerminalSession createTermSession(String executablePath, String[] arguments, String cwd, boolean failSafe) {
|
|
new File(HOME_PATH).mkdirs();
|
|
|
|
if (cwd == null) cwd = HOME_PATH;
|
|
|
|
String[] env = BackgroundJob.buildEnvironment(failSafe, cwd);
|
|
boolean isLoginShell = false;
|
|
|
|
if (executablePath == null) {
|
|
for (String shellBinary : new String[]{"login", "bash", "zsh"}) {
|
|
File shellFile = new File(PREFIX_PATH + "/bin/" + shellBinary);
|
|
if (shellFile.canExecute()) {
|
|
executablePath = shellFile.getAbsolutePath();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (executablePath == null) {
|
|
// Fall back to system shell as last resort:
|
|
executablePath = "/system/bin/sh";
|
|
}
|
|
isLoginShell = true;
|
|
}
|
|
|
|
String[] processArgs = BackgroundJob.setupProcessArgs(executablePath, arguments);
|
|
executablePath = processArgs[0];
|
|
int lastSlashIndex = executablePath.lastIndexOf('/');
|
|
String processName = (isLoginShell ? "-" : "") +
|
|
(lastSlashIndex == -1 ? executablePath : executablePath.substring(lastSlashIndex + 1));
|
|
|
|
String[] args = new String[processArgs.length];
|
|
args[0] = processName;
|
|
if (processArgs.length > 1) System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1);
|
|
|
|
TerminalSession session = new TerminalSession(executablePath, cwd, args, env, this);
|
|
mTerminalSessions.add(session);
|
|
updateNotification();
|
|
return session;
|
|
}
|
|
|
|
public int removeTermSession(TerminalSession sessionToRemove) {
|
|
int indexOfRemoved = mTerminalSessions.indexOf(sessionToRemove);
|
|
mTerminalSessions.remove(indexOfRemoved);
|
|
if (mTerminalSessions.isEmpty() && mWakeLock == null) {
|
|
// Finish if there are no sessions left and the wake lock is not held, otherwise keep the service alive if
|
|
// holding wake lock since there may be daemon processes (e.g. sshd) running.
|
|
stopSelf();
|
|
} else {
|
|
updateNotification();
|
|
}
|
|
return indexOfRemoved;
|
|
}
|
|
|
|
@Override
|
|
public void onTitleChanged(TerminalSession changedSession) {
|
|
if (mSessionChangeCallback != null) mSessionChangeCallback.onTitleChanged(changedSession);
|
|
}
|
|
|
|
@Override
|
|
public void onSessionFinished(final TerminalSession finishedSession) {
|
|
if (mSessionChangeCallback != null)
|
|
mSessionChangeCallback.onSessionFinished(finishedSession);
|
|
}
|
|
|
|
@Override
|
|
public void onTextChanged(TerminalSession changedSession) {
|
|
if (mSessionChangeCallback != null) mSessionChangeCallback.onTextChanged(changedSession);
|
|
}
|
|
|
|
@Override
|
|
public void onClipboardText(TerminalSession session, String text) {
|
|
if (mSessionChangeCallback != null) mSessionChangeCallback.onClipboardText(session, text);
|
|
}
|
|
|
|
@Override
|
|
public void onBell(TerminalSession session) {
|
|
if (mSessionChangeCallback != null) mSessionChangeCallback.onBell(session);
|
|
}
|
|
|
|
@Override
|
|
public void onColorsChanged(TerminalSession session) {
|
|
if (mSessionChangeCallback != null) mSessionChangeCallback.onColorsChanged(session);
|
|
}
|
|
|
|
public void onBackgroundJobExited(final BackgroundJob task) {
|
|
mHandler.post(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
mBackgroundTasks.remove(task);
|
|
updateNotification();
|
|
}
|
|
});
|
|
}
|
|
}
|