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