Skip to content
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|density|screenLayout|keyboard|keyboardHidden|navigation"
android:label="@string/application_name"
android:launchMode="singleTask"
android:launchMode="standard"
android:resizeableActivity="true"
android:theme="@style/Theme.TermuxActivity.DayNight.NoActionBar"
tools:targetApi="n">
Expand Down
86 changes: 83 additions & 3 deletions app/src/main/java/com/termux/app/TermuxActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.view.ContextMenu;
Expand All @@ -22,6 +23,7 @@
import android.view.WindowManager;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.RelativeLayout;
import android.widget.Toast;
Expand Down Expand Up @@ -251,6 +253,8 @@ public void onCreate(Bundle savedInstanceState) {

setToggleKeyboardView();

setNewWindowButtonView();

registerForContextMenu(mTerminalView);

FileReceiverActivity.updateFileReceiverActivityComponentsState(this);
Expand All @@ -277,6 +281,11 @@ public void onCreate(Bundle savedInstanceState) {
// Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux
// app has been opened.
TermuxUtils.sendTermuxOpenedBroadcast(this);

// Set initial multi-window UI state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
onMultiWindowModeChanged(isInMultiWindowMode());
}
}

@Override
Expand Down Expand Up @@ -352,9 +361,23 @@ public void onDestroy() {

if (mIsInvalidState) return;

// Detach the current session when the activity is destroyed
// and reset its client to the service client to avoid memory leaks
if (mTerminalView != null && mTermuxService != null) {
TerminalSession currentSession = getCurrentSession();
if (currentSession != null) {
// Reset this session's client to the service client
mTermuxService.resetSessionClient(currentSession);
}
mTerminalView.detachSession();

// Notify other windows that session attachment state changed
mTermuxService.notifyAllSessionListsUpdated();
}

if (mTermuxService != null) {
// Do not leave service and session clients with references to activity.
mTermuxService.unsetTermuxTerminalSessionClient();
// Remove this activity's client from the service's set
mTermuxService.removeTermuxTerminalSessionClient(mTermuxTerminalSessionActivityClient);
mTermuxService = null;
}

Expand All @@ -374,6 +397,35 @@ public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
savedInstanceState.putBoolean(ARG_ACTIVITY_RECREATED, true);
}

@Override
public void onMultiWindowModeChanged(boolean isInMultiWindowMode) {
super.onMultiWindowModeChanged(isInMultiWindowMode);
Logger.logDebug(LOG_TAG, "onMultiWindowModeChanged: " + isInMultiWindowMode);

// Show or hide the new window button based on multi-window mode
View newWindowButton = findViewById(R.id.new_window_button);
if (newWindowButton != null) {
newWindowButton.setVisibility(isInMultiWindowMode ? View.VISIBLE : View.GONE);
}

// Change drawer buttons orientation to vertical in multi-window mode to save horizontal space
LinearLayout drawerButtons = findViewById(R.id.drawer_buttons);
if (drawerButtons != null) {
drawerButtons.setOrientation(isInMultiWindowMode ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL);
}
}

/** Add a new window */
public void addNewWindow() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Intent intent = new Intent(this, TermuxActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
startActivity(intent);
}
}




Expand Down Expand Up @@ -421,7 +473,22 @@ public void onServiceConnected(ComponentName componentName, IBinder service) {
boolean isFailSafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false);
mTermuxTerminalSessionActivityClient.addNewSession(isFailSafe, null);
} else {
mTermuxTerminalSessionActivityClient.setCurrentSession(mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast());
// In multi-window mode, try to atomically claim first unattached session
TerminalSession sessionToAttach = mTermuxService.claimFirstUnattachedSession();
if (sessionToAttach != null) {
mTermuxTerminalSessionActivityClient.setCurrentSession(sessionToAttach);
} else {
// All sessions are attached, create a new one or use stored session
TerminalSession storedSession = mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast();
if (storedSession != null && !storedSession.mAttached) {
mTermuxTerminalSessionActivityClient.setCurrentSession(storedSession);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) {
// In multi-window mode with all sessions attached, create a new session
mTermuxTerminalSessionActivityClient.addNewSession(false, null);
} else {
mTermuxTerminalSessionActivityClient.setCurrentSession(storedSession);
}
}
}
}

Expand Down Expand Up @@ -594,6 +661,19 @@ private void setToggleKeyboardView() {
});
}

private void setNewWindowButtonView() {
View newWindowButton = findViewById(R.id.new_window_button);
if (newWindowButton != null) {
newWindowButton.setOnClickListener(v -> addNewWindow());
// Initially hidden, shown only in multi-window mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) {
newWindowButton.setVisibility(View.VISIBLE);
} else {
newWindowButton.setVisibility(View.GONE);
}
}
}




Expand Down
113 changes: 80 additions & 33 deletions app/src/main/java/com/termux/app/TermuxService.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
import com.termux.terminal.TerminalSessionClient;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
* A service holding a list of {@link TermuxSession} in {@link TermuxShellManager#mTermuxSessions} and background {@link AppShell}
Expand All @@ -77,11 +79,12 @@ class LocalBinder extends Binder {
private final Handler mHandler = new Handler();


/** The full implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that holds activity references for activity related functions.
* Note that the service may often outlive the activity, so need to clear this reference.
/** The full implementations of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that hold activity references for activity related functions. In multi-window mode, multiple
* activities may be bound simultaneously. Note that the service may often outlive the activities,
* so need to clear these references when activities are destroyed.
*/
private TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
private final Set<TermuxTerminalSessionActivityClient> mActivityClients = new HashSet<>();

/** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that does not hold activity references and only a service reference.
Expand Down Expand Up @@ -195,7 +198,7 @@ public boolean onUnbind(Intent intent) {
// Since we cannot rely on {@link TermuxActivity.onDestroy()} to always complete,
// we unset clients here as well if it failed, so that we do not leave service and session
// clients with references to the activity.
if (mTermuxTerminalSessionActivityClient != null)
if (!mActivityClients.isEmpty())
unsetTermuxTerminalSessionClient();
return false;
}
Expand Down Expand Up @@ -612,10 +615,8 @@ public synchronized TermuxSession createTermuxSession(ExecutionCommand execution
if (executionCommand.isPluginExecutionCommand)
mShellManager.mPendingPluginExecutionCommands.remove(executionCommand);

// Notify {@link TermuxSessionsListViewController} that sessions list has been updated if
// activity in is foreground
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated();
// Notify all activities that sessions list has been updated
notifyAllSessionListsUpdated();

updateNotification();

Expand Down Expand Up @@ -649,10 +650,8 @@ public void onTermuxSessionExited(final TermuxSession termuxSession) {

mShellManager.mTermuxSessions.remove(termuxSession);

// Notify {@link TermuxSessionsListViewController} that sessions list has been updated if
// activity in is foreground
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated();
// Notify all activities that sessions list has been updated
notifyAllSessionListsUpdated();
}

updateNotification();
Expand Down Expand Up @@ -687,8 +686,8 @@ private void handleSessionAction(int sessionAction, TerminalSession newTerminalS
switch (sessionAction) {
case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY:
setCurrentStoredTerminalSession(newTerminalSession);
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.setCurrentSession(newTerminalSession);
if (!mActivityClients.isEmpty())
mActivityClients.iterator().next().setCurrentSession(newTerminalSession);
startTermuxActivity();
break;
case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY:
Expand All @@ -698,8 +697,8 @@ private void handleSessionAction(int sessionAction, TerminalSession newTerminalS
break;
case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY:
setCurrentStoredTerminalSession(newTerminalSession);
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.setCurrentSession(newTerminalSession);
if (!mActivityClients.isEmpty())
mActivityClients.iterator().next().setCurrentSession(newTerminalSession);
break;
case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY:
if (getTermuxSessionsSize() == 1)
Expand Down Expand Up @@ -731,37 +730,42 @@ private void startTermuxActivity() {



/** If {@link TermuxActivity} has not bound to the {@link TermuxService} yet or is destroyed, then
/** If no {@link TermuxActivity} has bound to the {@link TermuxService} yet or all are destroyed, then
* interface functions requiring the activity should not be available to the terminal sessions,
* so we just return the {@link #mTermuxTerminalSessionServiceClient}. Once {@link TermuxActivity} bind
* callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the
* {@link TermuxService#mTermuxTerminalSessionActivityClient} so that further terminal sessions are directly
* passed the {@link TermuxTerminalSessionActivityClient} object which fully implements the
* callback is received, it should call {@link #setTermuxTerminalSessionClient} to add its client
* to {@link TermuxService#mActivityClients} so that further terminal sessions are directly
* passed a {@link TermuxTerminalSessionActivityClient} object which fully implements the
* {@link TerminalSessionClient} interface.
*
* @return Returns the {@link TermuxTerminalSessionActivityClient} if {@link TermuxActivity} has bound with
* {@link TermuxService}, otherwise {@link TermuxTerminalSessionServiceClient}.
* @return Returns the first available {@link TermuxTerminalSessionActivityClient} if any {@link TermuxActivity}
* has bound with {@link TermuxService}, otherwise {@link TermuxTerminalSessionServiceClient}.
*/
public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() {
if (mTermuxTerminalSessionActivityClient != null)
return mTermuxTerminalSessionActivityClient;
if (!mActivityClients.isEmpty())
return mActivityClients.iterator().next();
else
return mTermuxTerminalSessionServiceClient;
}

/** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the
* {@link TermuxService#mTermuxTerminalSessionActivityClient} variable and update the {@link TerminalSession}
* and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionServiceClient}
* earlier.
/** This should be called when {@link TermuxActivity#onServiceConnected} is called to add
* the activity's client to {@link TermuxService#mActivityClients}. In multi-window mode,
* multiple activities may be bound simultaneously.
*
* @param termuxTerminalSessionActivityClient The {@link TermuxTerminalSessionActivityClient} object that fully
* implements the {@link TerminalSessionClient} interface.
*/
public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
mActivityClients.add(termuxTerminalSessionActivityClient);

for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++)
mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionActivityClient);
// Don't update all sessions' clients here - in multi-window mode, each activity
// should only set the client for its own attached session. The client is set
// when setCurrentSession() is called in TermuxTerminalSessionActivityClient.
}

/** Remove an activity client when its activity is destroyed. */
public synchronized void removeTermuxTerminalSessionClient(TermuxTerminalSessionActivityClient client) {
mActivityClients.remove(client);
}

/** This should be called when {@link TermuxActivity} has been destroyed and in {@link #onUnbind(Intent)}
Expand All @@ -772,7 +776,23 @@ public synchronized void unsetTermuxTerminalSessionClient() {
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++)
mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionServiceClient);

mTermuxTerminalSessionActivityClient = null;
mActivityClients.clear();
}

/** Reset a specific session's client to the service client. Used when an activity is destroyed
* in multi-window mode to avoid resetting all sessions' clients. */
public synchronized void resetSessionClient(TerminalSession session) {
if (session != null) {
session.updateTerminalSessionClient(mTermuxTerminalSessionServiceClient);
}
}

/** Notify all bound activities to update their session lists. Used when session attachment
* state changes in multi-window mode. */
public synchronized void notifyAllSessionListsUpdated() {
for (TermuxTerminalSessionActivityClient client : mActivityClients) {
client.termuxSessionListNotifyUpdated();
}
}


Expand Down Expand Up @@ -906,6 +926,33 @@ public synchronized TermuxSession getLastTermuxSession() {
return mShellManager.mTermuxSessions.isEmpty() ? null : mShellManager.mTermuxSessions.get(mShellManager.mTermuxSessions.size() - 1);
}

/** Get the first session that is not attached to any window. Used for multi-window support. */
@Nullable
public synchronized TerminalSession getFirstUnattachedSession() {
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) {
TerminalSession session = mShellManager.mTermuxSessions.get(i).getTerminalSession();
if (!session.mAttached) {
return session;
}
}
return null;
}

/** Atomically claim an unattached session by marking it as attached. Returns the session if
* successful, null if no unattached sessions are available. This prevents race conditions
* when multiple windows try to claim a session simultaneously. */
@Nullable
public synchronized TerminalSession claimFirstUnattachedSession() {
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) {
TerminalSession session = mShellManager.mTermuxSessions.get(i).getTerminalSession();
if (!session.mAttached) {
session.mAttached = true;
return session;
}
}
return null;
}

public synchronized int getIndexOfSession(TerminalSession terminalSession) {
if (terminalSession == null) return -1;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,42 @@ public View getView(int position, View convertView, @NonNull ViewGroup parent) {
int defaultColor = shouldEnableDarkTheme ? Color.WHITE : Color.BLACK;
int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED;
sessionTitleView.setTextColor(color);

// Gray out sessions attached to other windows
TerminalSession currentSession = mActivity.getCurrentSession();
boolean isAttachedToOtherWindow = sessionAtRow.mAttached && sessionAtRow != currentSession;
if (isAttachedToOtherWindow) {
sessionTitleView.setAlpha(0.5f);
} else {
sessionTitleView.setAlpha(1.0f);
}

return sessionRowView;
}

@Override
public boolean isEnabled(int position) {
TermuxSession termuxSession = getItem(position);
if (termuxSession == null) return false;

TerminalSession session = termuxSession.getTerminalSession();
TerminalSession currentSession = mActivity.getCurrentSession();

// Disable (gray out) sessions attached to other windows
// Sessions attached to current window should remain clickable
return !session.mAttached || session == currentSession;
}

@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
TermuxSession clickedSession = getItem(position);
mActivity.getTermuxTerminalSessionClient().setCurrentSession(clickedSession.getTerminalSession());
mActivity.getDrawer().closeDrawers();
TerminalSession session = clickedSession.getTerminalSession();
TerminalSession currentSession = mActivity.getCurrentSession();
// Only switch if the session is not attached to another window
if (!session.mAttached || session == currentSession) {
mActivity.getTermuxTerminalSessionClient().setCurrentSession(session);
mActivity.getDrawer().closeDrawers();
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I had always been wondering about this. Do you think it is possible to make touching one of the other sessions "bring that activity (window) to front" and switch to that session, or is it more straightforward to just make them "grayed out" so that you just need to manually switch?

Copy link
Copy Markdown
Author

@ozwaldorf ozwaldorf Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm not sure. This was mostly preserving the behavior from the other pr, however I was also thinking maybe this could be improved.

I think bringing the window into focus is a great idea!

}

@Override
Expand Down
Loading