From 31705c9016ce175f2516832dba03341987757edf Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 29 Jun 2026 12:51:08 -0500 Subject: [PATCH 1/3] fix(app): fix IllegalStateException by ensuring UI thread adapter notifications Fix crash caused by modifying or notifying ListView adapter changes from background threads. --- .../src/main/java/com/termux/app/TermuxActivity.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java index 152a72dc9f..359f35de6c 100644 --- a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java @@ -9,6 +9,7 @@ import android.os.Build; import android.os.Bundle; import android.os.IBinder; +import android.os.Looper; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.Gravity; @@ -973,7 +974,12 @@ public boolean isTerminalToolbarTextInputViewSelected() { public void termuxSessionListNotifyUpdated() { - TermuxExecutor.executeOnMain(() -> mTermuxSessionListViewController.notifyDataSetChanged()); + if (mTermuxSessionListViewController == null) return; + if (Looper.myLooper() == Looper.getMainLooper()) { + mTermuxSessionListViewController.notifyDataSetChanged(); + } else { + TermuxExecutor.executeOnMain(mTermuxSessionListViewController::notifyDataSetChanged); + } } public boolean isVisible() { From 06c0ec69d8fef4f3a6972fc5f3966070bac41316 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 29 Jun 2026 13:11:45 -0500 Subject: [PATCH 2/3] fix: prevent terminal sessions ListView crash by giving adapter a UI-thread-only snapshot instead of sharing TermuxService's live session list mutated off the UI thread --- .../java/com/termux/app/TermuxActivity.java | 16 ++++++++++++---- .../main/java/com/termux/app/TermuxService.java | 10 ++++++++++ .../TermuxSessionsListViewController.java | 17 ++++++++++++++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java index 359f35de6c..357a321252 100644 --- a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java @@ -628,7 +628,7 @@ public void onResetTerminalSession() { private void setTermuxSessionsListView() { ListView termuxSessionsListView = findViewById(R.id.terminal_sessions_list); - mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions()); + mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessionsListSnapshot()); termuxSessionsListView.setAdapter(mTermuxSessionListViewController); termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); @@ -974,14 +974,22 @@ public boolean isTerminalToolbarTextInputViewSelected() { public void termuxSessionListNotifyUpdated() { - if (mTermuxSessionListViewController == null) return; + // The session list may be mutated on a background thread (e.g. onCreateNewSession() runs + // createTermuxSession() on a background executor). Re-snapshotting the service's list into + // the adapter must therefore happen on the UI thread, so the adapter's own list is only + // ever read/written by the UI thread and the ListView can never observe a concurrent change. if (Looper.myLooper() == Looper.getMainLooper()) { - mTermuxSessionListViewController.notifyDataSetChanged(); + refreshSessionsListView(); } else { - TermuxExecutor.executeOnMain(mTermuxSessionListViewController::notifyDataSetChanged); + TermuxExecutor.executeOnMain(this::refreshSessionsListView); } } + private void refreshSessionsListView() { + if (mTermuxSessionListViewController == null || mTermuxService == null) return; + mTermuxSessionListViewController.updateSessions(mTermuxService.getTermuxSessionsListSnapshot()); + } + public boolean isVisible() { return mIsVisible; } diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxService.java b/termux/termux-app/src/main/java/com/termux/app/TermuxService.java index b08aac6b8e..05447e6b40 100644 --- a/termux/termux-app/src/main/java/com/termux/app/TermuxService.java +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxService.java @@ -896,6 +896,16 @@ public synchronized List getTermuxSessions() { return mShellManager.mTermuxSessions; } + /** + * Returns a consistent copy of the current {@link TermuxSession} list. Unlike + * {@link #getTermuxSessions()}, this does not expose the live backing list, so it is safe to + * iterate from any thread (e.g. to feed a UI adapter). The copy is taken under the same monitor + * that guards all session mutations, so it can never observe a half-applied add/remove. + */ + public synchronized List getTermuxSessionsListSnapshot() { + return new ArrayList<>(mShellManager.mTermuxSessions); + } + @Nullable public synchronized TermuxSession getTermuxSession(int index) { if (index >= 0 && index < mShellManager.mTermuxSessions.size()) diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java index 8232725071..87636c409f 100644 --- a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java +++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -22,6 +22,7 @@ import com.termux.shared.theme.NightMode; import com.termux.shared.theme.ThemeUtils; import com.termux.terminal.TerminalSession; +import java.util.ArrayList; import java.util.List; public class TermuxSessionsListViewController extends ArrayAdapter implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { @@ -32,10 +33,24 @@ public class TermuxSessionsListViewController extends ArrayAdapter sessionList) { - super(activity.getApplicationContext(), R.layout.item_terminal_sessions_list, sessionList); + // Defensively copy so the adapter owns its data instead of sharing the service's live list. + // The backing list is then only ever touched on the UI thread (here and in updateSessions), + // so a background session add/remove can never mutate it while the ListView is laying out. + super(activity.getApplicationContext(), R.layout.item_terminal_sessions_list, new ArrayList<>(sessionList)); this.mActivity = activity; } + /** + * Replace the adapter's session snapshot with a copy of {@code sessions} and refresh the + * ListView in a single notification. Must be called on the UI thread. + */ + public void updateSessions(@NonNull List sessions) { + setNotifyOnChange(false); + clear(); + addAll(sessions); + notifyDataSetChanged(); + } + @SuppressLint("SetTextI18n") @NonNull @Override From 5cc2c9aec3bcfdb0acc876d67d200dbdc34045d3 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 29 Jun 2026 16:04:41 -0500 Subject: [PATCH 3/3] fix: lock onTermuxSessionExited removal and use session snapshot for working-dir lookup to keep getTermuxSessionsListSnapshot consistent and avoid concurrent-modification races --- .../termux-app/src/main/java/com/termux/app/TermuxActivity.java | 2 +- .../termux-app/src/main/java/com/termux/app/TermuxService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java index 357a321252..780b746190 100644 --- a/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxActivity.java @@ -469,7 +469,7 @@ public void onServiceConnected(ComponentName componentName, IBinder service) { } else { final Optional existingSession = workingDir == null ? Optional.empty() : - mTermuxService.getTermuxSessions().stream().filter(session -> Objects.equals( + mTermuxService.getTermuxSessionsListSnapshot().stream().filter(session -> Objects.equals( session.getTerminalSession().getCwd(), workingDir)).findFirst(); setupTermuxSessionOnServiceConnected( diff --git a/termux/termux-app/src/main/java/com/termux/app/TermuxService.java b/termux/termux-app/src/main/java/com/termux/app/TermuxService.java index 05447e6b40..628242a677 100644 --- a/termux/termux-app/src/main/java/com/termux/app/TermuxService.java +++ b/termux/termux-app/src/main/java/com/termux/app/TermuxService.java @@ -651,7 +651,7 @@ public synchronized int removeTermuxSession(TerminalSession sessionToRemove) { /** Callback received when a {@link TermuxSession} finishes. */ @Override - public void onTermuxSessionExited(final TermuxSession termuxSession) { + public synchronized void onTermuxSessionExited(final TermuxSession termuxSession) { if (termuxSession != null) { ExecutionCommand executionCommand = termuxSession.getExecutionCommand();