tmux-browser-android/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java
Codex 72913c5571
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m48s
Default updates to Gitea
2026-07-07 01:37:18 +00:00

2304 lines
93 KiB
Java

package com.neatstudio.tmuxandroid;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.InputType;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.HorizontalScrollView;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class MainActivity extends Activity {
private static final int IMAGE_PICK_REQUEST = 2001;
private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L;
private static final int TERMINAL_COLS = 96;
private static final int TERMINAL_ROWS = 32;
private static final int MAX_TERMINAL_CHARS = 40_000;
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
private static final String OLD_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
private static final String PAGE_SESSIONS = "Sessions";
private static final String PAGE_PROJECTS = "Projects";
private static final String PAGE_TOOLS = "Tools";
private static final String PAGE_UPDATE = "Update";
private static final String PAGE_ABOUT = "About";
private static final String[] SERVER_PROFILES = {
"http://100.89.0.116:3000",
"http://100.89.0.2:3000",
"http://100.89.0.4:3000",
"http://100.89.0.9:3000",
"http://100.89.0.11:3000"
};
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private SharedPreferences prefs;
private UpdateManager updateManager;
private SessionApiClient api;
private TerminalSocketClient terminalSocket;
private AppEventSocketClient eventSocket;
private LinearLayout root;
private EditText urlField;
private ProgressBar progressBar;
private TextView statusText;
private TextView sessionSummaryText;
private LinearLayout projectList;
private TextView terminalText;
private ScrollView terminalScroll;
private EditText inputField;
private String activeSessionName;
private String activeMainPage = PAGE_SESSIONS;
private String pendingImageUploadSession;
private final StringBuilder terminalBuffer = new StringBuilder();
private final StringBuilder queuedTerminalInput = new StringBuilder();
private boolean terminalConnected;
private boolean terminalRenderPending;
private long lastTerminalRenderMs;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
prefs = getSharedPreferences("tmux_android", MODE_PRIVATE);
if (!prefs.contains("server_url")) {
prefs.edit()
.putString("server_url", BuildConfig.DEFAULT_SERVER_URL)
.putString("update_url", BuildConfig.DEFAULT_UPDATE_URL)
.apply();
} else if (
OLD_LOCAL_DEFAULT_URL.equals(prefs.getString("server_url", ""))
&& !prefs.getBoolean("tailscale_defaults_applied_v1", false)
) {
prefs.edit()
.putString("server_url", DEFAULT_TAILSCALE_URL)
.putBoolean("tailscale_defaults_applied_v1", true)
.apply();
}
migrateDefaultUpdateSourceToGitea();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
}
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
api = new SessionApiClient(getServerUrl());
setContentView(createRoot());
updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() {
@Override
public void onChecking(boolean checking) {
progressBar.setVisibility(checking ? View.VISIBLE : View.GONE);
}
@Override
public void onMessage(String message) {
showMessage(message);
}
});
renderSessionScreen();
refreshSessions();
connectAppEvents();
maybeCheckForUpdates();
}
private View createRoot() {
root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(Color.rgb(17, 20, 24));
progressBar = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
progressBar.setIndeterminate(true);
progressBar.setVisibility(View.GONE);
statusText = new TextView(this);
statusText.setTextColor(Color.rgb(210, 215, 224));
statusText.setTextSize(12);
statusText.setGravity(Gravity.CENTER_VERTICAL);
statusText.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true);
setStatus("Ready");
applySystemBarInsets(root);
return root;
}
private void applySystemBarInsets(View view) {
view.setOnApplyWindowInsetsListener((target, insets) -> {
int bottom = insets.getSystemWindowInsetBottom();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
bottom = Math.max(bottom, insets.getInsets(WindowInsets.Type.ime()).bottom);
}
target.setPadding(
0,
insets.getSystemWindowInsetTop(),
0,
bottom
);
return insets;
});
view.requestApplyInsets();
view.post(view::requestApplyInsets);
}
private void renderSessionScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_SESSIONS;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createServerProfileBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
));
root.addView(createMainTabs(PAGE_SESSIONS), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(sessionSummaryBlock());
content.addView(sectionTitle("Tmux sessions"));
LinearLayout list = new LinearLayout(this);
list.setOrientation(LinearLayout.VERTICAL);
list.setTag("session-list");
content.addView(list);
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
}
private View sessionSummaryBlock() {
View block = infoBlock(
"Active API",
"Server: " + getServerUrl() + "\nSessions: loading"
);
sessionSummaryText = findBodyText(block);
return block;
}
private void renderToolsScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_TOOLS;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_TOOLS), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(sectionTitle("Server"));
content.addView(actionPanel(
actionButton("Health", view -> showRaw("Health", () -> api.health())),
actionButton("Probe Tailscale", view -> probeServerProfiles()),
actionButton("Status", view -> showRaw("Server status", () -> api.serverStatus())),
actionButton("Preferences", view -> showRaw("Preferences", () -> api.preferences()))
));
content.addView(sectionTitle("Sessions"));
content.addView(actionPanel(
actionButton("Timeline", view -> showRaw("Timeline", () -> api.timeline(50))),
actionButton("All details", view -> showRaw("All session details", () -> api.sessionsAll())),
actionButton("Pane details", view -> showRaw("Pane details", () -> api.sessionsPanes()))
));
content.addView(sectionTitle("Kanban / Messages"));
content.addView(actionPanel(
actionButton("Projects", view -> showRaw("Kanban projects", () -> api.kanbanProjects())),
actionButton("New project", view -> promptCreateKanbanProject()),
actionButton("Delete project", view -> promptDeleteKanbanProject()),
actionButton("Remove session", view -> promptRemoveKanbanSession())
));
content.addView(actionPanel(
actionButton("Messages", view -> promptGroupMessages()),
actionButton("Send message", view -> promptSendGroupMessage()),
actionButton("Scan message", view -> promptScanGroupMessage()),
actionButton("Post hook", view -> promptPostHookEvent())
));
content.addView(sectionTitle("Images"));
content.addView(actionPanel(
actionButton("Upload file", view -> promptUploadImageFile()),
actionButton("Upload URL", view -> promptUploadImageUrl()),
actionButton("Preview info", view -> promptImagePreviewInfo()),
actionButton("Preview", view -> promptOpenImagePreview())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("Tools");
}
private void renderProjectsScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_PROJECTS;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_PROJECTS), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(infoBlock(
"Project groups",
"Kanban projects group tmux sessions and group messages through the server API."
));
content.addView(sectionTitle("Projects"));
content.addView(actionPanel(
actionButton("Refresh", view -> refreshProjects()),
actionButton("New project", view -> promptCreateKanbanProject()),
actionButton("Delete project", view -> promptDeleteKanbanProject()),
actionButton("Remove session", view -> promptRemoveKanbanSession())
));
projectList = new LinearLayout(this);
projectList.setOrientation(LinearLayout.VERTICAL);
content.addView(projectList, matchWrap());
content.addView(sectionTitle("Group messages"));
content.addView(actionPanel(
actionButton("Messages", view -> promptGroupMessages()),
actionButton("Send message", view -> promptSendGroupMessage()),
actionButton("Scan message", view -> promptScanGroupMessage()),
actionButton("Post hook", view -> promptPostHookEvent())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("Projects");
refreshProjects();
}
private void renderUpdateScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_UPDATE;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_UPDATE), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(infoBlock(
"Installed",
appIdentityText() + "\n"
+ "Update source: " + updateSourceHost() + "\n"
+ prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)
));
content.addView(sectionTitle("Update"));
content.addView(actionPanel(
actionButton("Check now", view -> updateManager.check(true)),
actionButton("Source", view -> showUpdateSourcePicker()),
actionButton("APK", view -> updateManager.openApkDownload())
));
content.addView(sectionTitle("Permissions"));
content.addView(infoBlock("Android", permissionSummary()));
content.addView(actionPanel(
actionButton("Install permission", view -> openInstallPermissionSettings()),
actionButton("Notifications", view -> requestNotificationPermission()),
actionButton("App settings", view -> openAppSettings()),
actionButton("Details", view -> showPermissionsAndUpdateStatus())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("Update and permissions");
}
private void renderAboutScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_ABOUT;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_ABOUT), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(infoBlock(
"tmux-browser Android",
"Native Android client for the remote tmux-browser API.\n"
+ appIdentityText() + "\n"
+ "Package " + getPackageName()
));
content.addView(infoBlock(
"Protocol",
"API base: " + getServerUrl() + "\n"
+ "HTTP on port 3000: /api/health, /api/sessions, session actions, kanban, messages, images.\n"
+ "WebSocket: /ws/terminal uses attach/input/resize/scroll/clear-history; /ws/events streams session and hook events."
));
content.addView(infoBlock(
"Update policy",
"Selected source: " + updateSourceHost() + "\n"
+ prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL) + "\n"
+ "The app checks only this source. APK downloads are cached by version and reused after Android install permission is granted."
));
content.addView(actionPanel(
actionButton("Release page", view -> updateManager.openReleasePage()),
actionButton("Update source", view -> showUpdateSourcePicker()),
actionButton("Permissions", view -> showPermissionsAndUpdateStatus())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("About");
}
private LinearLayout createServerBar() {
LinearLayout toolbar = new LinearLayout(this);
toolbar.setOrientation(LinearLayout.VERTICAL);
toolbar.setPadding(dp(8), dp(6), dp(8), dp(6));
toolbar.setBackgroundColor(Color.rgb(17, 20, 24));
LinearLayout urlRow = new LinearLayout(this);
urlRow.setOrientation(LinearLayout.HORIZONTAL);
urlRow.setGravity(Gravity.CENTER_VERTICAL);
urlField = new EditText(this);
urlField.setSingleLine(true);
urlField.setTextColor(Color.WHITE);
urlField.setHintTextColor(Color.rgb(150, 158, 168));
urlField.setTextSize(14);
urlField.setInputType(InputType.TYPE_TEXT_VARIATION_URI);
urlField.setImeOptions(EditorInfo.IME_ACTION_GO);
urlField.setText(getServerUrl());
urlField.setSelectAllOnFocus(true);
styleInput(urlField);
urlField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_GO) {
saveServerAndRefresh();
return true;
}
return false;
});
urlRow.addView(urlField, new LinearLayout.LayoutParams(0, dp(44), 1));
urlRow.addView(toolbarButton("Go", view -> saveServerAndRefresh()));
LinearLayout actionRow = new LinearLayout(this);
actionRow.setOrientation(LinearLayout.HORIZONTAL);
actionRow.setGravity(Gravity.CENTER_VERTICAL);
actionRow.setPadding(0, dp(6), 0, 0);
addContextActions(actionRow);
toolbar.addView(urlRow, matchWrap());
if (actionRow.getChildCount() > 0) {
toolbar.addView(actionRow, matchWrap());
}
return toolbar;
}
private void addContextActions(LinearLayout actionRow) {
if (PAGE_SESSIONS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("New", view -> promptCreateSession()));
actionRow.addView(toolbarButton("Refresh", view -> refreshSessions()));
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
return;
}
if (PAGE_PROJECTS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Refresh", view -> refreshProjects()));
actionRow.addView(toolbarButton("New", view -> promptCreateKanbanProject()));
return;
}
if (PAGE_TOOLS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health())));
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
return;
}
if (PAGE_UPDATE.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Check now", view -> updateManager.check(true)));
actionRow.addView(toolbarButton("Source", view -> showUpdateSourcePicker()));
return;
}
if (PAGE_ABOUT.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Release", view -> updateManager.openReleasePage()));
actionRow.addView(toolbarButton("Permissions", view -> showPermissionsAndUpdateStatus()));
}
}
private HorizontalScrollView createServerProfileBar() {
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(22, 27, 34));
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(6), dp(4), dp(6), dp(4));
for (String url : SERVER_PROFILES) {
String label = url.replace("http://", "").replace(":3000", "");
Button button = toolbarButton(label, view -> selectServer(url));
button.setMinWidth(dp(92));
button.setMinimumWidth(dp(92));
row.addView(button, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
}
row.addView(toolbarButton("Custom", view -> {
urlField.requestFocus();
urlField.selectAll();
}));
scroller.addView(row, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private HorizontalScrollView createMainTabs(String selected) {
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(17, 20, 24));
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(8), dp(4), dp(8), dp(4));
row.addView(navButton(PAGE_SESSIONS, selected, view -> {
renderSessionScreen();
refreshSessions();
}));
row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen()));
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen()));
scroller.addView(row, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private Button navButton(String label, String selected, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
boolean active = label.equals(selected);
button.setTextColor(active ? Color.rgb(8, 12, 18) : Color.rgb(235, 241, 248));
button.setBackground(active
? rounded(Color.rgb(86, 211, 219), 8, Color.rgb(86, 211, 219), 1)
: buttonBackground());
button.setMinWidth(dp(82));
button.setMinimumWidth(dp(82));
return button;
}
private LinearLayout pageContent() {
LinearLayout content = new LinearLayout(this);
content.setOrientation(LinearLayout.VERTICAL);
content.setPadding(dp(10), dp(10), dp(10), dp(14));
return content;
}
private TextView sectionTitle(String text) {
TextView title = new TextView(this);
title.setText(text);
title.setTextColor(Color.rgb(139, 148, 158));
title.setTextSize(12);
title.setTypeface(Typeface.DEFAULT_BOLD);
title.setPadding(dp(2), dp(12), dp(2), dp(6));
return title;
}
private View infoBlock(String title, String body) {
LinearLayout block = new LinearLayout(this);
block.setOrientation(LinearLayout.VERTICAL);
block.setPadding(dp(12), dp(10), dp(12), dp(10));
block.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
TextView heading = new TextView(this);
heading.setText(title);
heading.setTextColor(Color.WHITE);
heading.setTextSize(16);
heading.setTypeface(Typeface.DEFAULT_BOLD);
TextView text = bodyText(body);
text.setPadding(0, dp(6), 0, 0);
block.addView(heading);
block.addView(text);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
block.setLayoutParams(params);
return block;
}
private TextView findBodyText(View block) {
if (!(block instanceof LinearLayout)) {
return null;
}
LinearLayout layout = (LinearLayout) block;
if (layout.getChildCount() < 2 || !(layout.getChildAt(1) instanceof TextView)) {
return null;
}
return (TextView) layout.getChildAt(1);
}
private LinearLayout actionPanel(Button... buttons) {
LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setPadding(dp(7), dp(7), dp(7), dp(2));
panel.setBackground(rounded(Color.rgb(24, 30, 37), 8, Color.rgb(42, 51, 61), 1));
LinearLayout row = null;
for (int index = 0; index < buttons.length; index++) {
if (index % 2 == 0) {
row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
panel.addView(row, matchWrap());
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(42), 1);
params.leftMargin = dp(3);
params.rightMargin = dp(3);
params.bottomMargin = dp(6);
row.addView(buttons[index], params);
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
panel.setLayoutParams(params);
return panel;
}
private Button actionButton(String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return button;
}
private void selectServer(String url) {
prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url);
urlField.setText(url);
setStatus("Selected " + url);
connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions();
} else if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects();
}
}
private void refreshSessions() {
setStatus("Loading sessions from " + api.getBaseUrl());
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try {
List<SessionSummary> sessions = api.getSessions();
runOnUiThread(() -> renderSessionList(sessions));
} catch (Exception error) {
runOnUiThread(() -> {
if (sessionSummaryText != null) {
sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: failed - " + error.getMessage());
}
showMessage("Session load failed: " + error.getMessage());
});
} finally {
runOnUiThread(() -> progressBar.setVisibility(View.GONE));
}
});
}
private void refreshProjects() {
if (projectList != null) {
projectList.removeAllViews();
projectList.addView(projectStateText("Loading projects..."), matchWrap());
}
setStatus("Loading projects from " + api.getBaseUrl());
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try {
String text = api.kanbanProjects();
runOnUiThread(() -> {
renderProjectList(text);
setStatus("Loaded projects");
});
} catch (Exception error) {
runOnUiThread(() -> {
if (projectList != null) {
projectList.removeAllViews();
projectList.addView(projectStateText("Project load failed:\n" + error.getMessage()), matchWrap());
}
showMessage("Project load failed: " + error.getMessage());
});
} finally {
runOnUiThread(() -> progressBar.setVisibility(View.GONE));
}
});
}
private void renderProjectList(String text) {
if (projectList == null) {
return;
}
projectList.removeAllViews();
try {
JSONObject rootObject = new JSONObject(text == null || text.isEmpty() ? "{}" : text);
JSONArray projects = rootObject.optJSONArray("projects");
if (projects == null || projects.length() == 0) {
projectList.addView(projectStateText("No projects"), matchWrap());
return;
}
for (int index = 0; index < projects.length(); index++) {
projectList.addView(projectCard(projects.getJSONObject(index)), matchWrap());
}
} catch (Exception error) {
projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap());
}
}
private TextView projectStateText(String text) {
TextView view = bodyText(text);
view.setTypeface(Typeface.MONOSPACE);
view.setTextIsSelectable(true);
view.setPadding(dp(12), dp(10), dp(12), dp(10));
view.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(42, 51, 61), 1));
return view;
}
private View projectCard(JSONObject project) {
String name = project.optString("name", "(unnamed)");
String path = project.optString("path", "");
String server = project.isNull("server") ? "" : project.optString("server", "");
JSONArray agents = project.optJSONArray("agents");
int agentCount = agents == null ? 0 : agents.length();
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.setPadding(dp(12), dp(11), dp(12), dp(11));
card.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
TextView title = new TextView(this);
title.setText(name);
title.setTextColor(Color.WHITE);
title.setTextSize(17);
title.setTypeface(Typeface.DEFAULT_BOLD);
String detail = "path:" + defaultValue(path, "~")
+ (server.isEmpty() ? "" : " server:" + server)
+ " agents:" + agentCount;
TextView meta = bodyText(detail);
meta.setPadding(0, dp(4), 0, dp(8));
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.HORIZONTAL);
actions.addView(toolbarButton("Messages", view -> showRaw("Group messages: " + name, () -> api.groupMessages(name))));
actions.addView(toolbarButton("Add", view -> promptAddKanbanSessionToProject(name)));
actions.addView(toolbarButton("Delete", view -> runApiAction("Delete kanban project", () -> api.deleteKanbanProject(name))));
card.addView(title);
card.addView(meta);
card.addView(actions);
if (agents != null && agents.length() > 0) {
TextView agentsTitle = bodyText("Agents");
agentsTitle.setTextColor(Color.rgb(139, 148, 158));
agentsTitle.setTypeface(Typeface.DEFAULT_BOLD);
agentsTitle.setPadding(0, dp(10), 0, dp(4));
card.addView(agentsTitle);
for (int index = 0; index < agents.length(); index++) {
JSONObject agent = agents.optJSONObject(index);
if (agent != null) {
card.addView(projectAgentRow(name, agent), matchWrap());
}
}
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
card.setLayoutParams(params);
return card;
}
private View projectAgentRow(String projectName, JSONObject agent) {
String sessionName = defaultValue(agent.optString("sessionName", ""), agent.optString("name", ""));
String label = defaultValue(agent.optString("name", ""), sessionName);
String kind = agent.optString("kind", "session");
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(3), 0, dp(3));
TextView name = bodyText(kind + " " + label);
name.setSingleLine(true);
row.addView(name, new LinearLayout.LayoutParams(0, dp(38), 1));
if (!sessionName.isEmpty()) {
row.addView(toolbarButton("Open", view -> openTerminal(sessionName)));
row.addView(toolbarButton("Remove", view -> runApiAction("Remove kanban session", () ->
api.removeKanbanSession(projectName, sessionName, false))));
}
return row;
}
private void renderSessionList(List<SessionSummary> sessions) {
LinearLayout list = root.findViewWithTag("session-list");
if (list == null) {
return;
}
list.removeAllViews();
if (sessions.isEmpty()) {
TextView empty = bodyText("No tmux sessions");
empty.setPadding(dp(8), dp(24), dp(8), dp(24));
list.addView(empty);
}
for (SessionSummary session : sessions) {
list.addView(sessionRow(session), matchWrap());
}
if (sessionSummaryText != null) {
sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: " + sessions.size());
}
setStatus("Loaded " + sessions.size() + " sessions");
}
private View sessionRow(SessionSummary session) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(dp(12), dp(11), dp(12), dp(11));
row.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
row.setElevation(dp(1));
}
TextView title = new TextView(this);
title.setText(session.name);
title.setTextColor(Color.WHITE);
title.setTextSize(17);
title.setTypeface(Typeface.DEFAULT_BOLD);
String detail = session.status
+ " windows:" + session.windows
+ " panes:" + session.paneCount
+ textPart(session.currentCommand)
+ textPart(session.currentPath);
TextView meta = bodyText(detail);
meta.setPadding(0, dp(4), 0, dp(8));
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.HORIZONTAL);
actions.addView(toolbarButton("Open", view -> openTerminal(session.name)));
actions.addView(toolbarButton("Kill", view -> confirmKill(session.name)));
actions.addView(toolbarButton("More", view -> showSessionActions(session.name)));
row.addView(title);
row.addView(meta);
row.addView(actions);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
row.setLayoutParams(params);
return row;
}
private void promptCreateSession() {
EditText input = new EditText(this);
input.setSingleLine(true);
input.setHint("session-name");
input.setInputType(InputType.TYPE_CLASS_TEXT);
new AlertDialog.Builder(this)
.setTitle("Create tmux session")
.setView(input)
.setNegativeButton("Cancel", null)
.setPositiveButton("Create", (dialog, which) -> {
String name = input.getText().toString().trim();
if (!name.matches("[A-Za-z0-9._-]+")) {
showMessage("Invalid session name");
return;
}
executor.execute(() -> {
try {
api.createSession(name);
runOnUiThread(() -> {
showMessage("Created " + name);
refreshSessions();
});
} catch (Exception error) {
runOnUiThread(() -> showMessage("Create failed: " + error.getMessage()));
}
});
})
.show();
}
private void confirmKill(String sessionName) {
new AlertDialog.Builder(this)
.setTitle("Kill session")
.setMessage("Kill tmux session \"" + sessionName + "\"?")
.setNegativeButton("Cancel", null)
.setPositiveButton("Kill", (dialog, which) -> executor.execute(() -> {
try {
api.killSession(sessionName);
runOnUiThread(() -> {
showMessage("Killed " + sessionName);
refreshSessions();
});
} catch (Exception error) {
runOnUiThread(() -> showMessage("Kill failed: " + error.getMessage()));
}
}))
.show();
}
private void openTerminal(String sessionName) {
activeSessionName = sessionName;
terminalBuffer.setLength(0);
queuedTerminalInput.setLength(0);
terminalConnected = false;
terminalRenderPending = false;
lastTerminalRenderMs = 0L;
root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap());
terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true);
terminalScroll.setBackgroundColor(Color.rgb(4, 7, 10));
terminalText = new TextView(this);
terminalText.setTextColor(Color.rgb(230, 235, 242));
terminalText.setTextSize(13);
terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setIncludeFontPadding(false);
terminalText.setLineSpacing(0, 1.05f);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(false);
terminalText.setPadding(dp(10), dp(10), dp(10), dp(10));
terminalText.setBackgroundColor(Color.rgb(4, 7, 10));
terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
root.addView(terminalScroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(createInputBar(), matchWrap());
root.addView(createSoftKeyBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
connectTerminal(sessionName);
}
private LinearLayout createTerminalTopBar(String sessionName) {
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPadding(dp(8), dp(6), dp(8), dp(6));
bar.setBackgroundColor(Color.rgb(17, 20, 24));
bar.addView(toolbarButton("Back", view -> {
renderSessionScreen();
refreshSessions();
}));
TextView title = new TextView(this);
title.setText(sessionName);
title.setTextColor(Color.WHITE);
title.setTextSize(16);
title.setTypeface(Typeface.DEFAULT_BOLD);
title.setPadding(dp(10), 0, dp(10), 0);
bar.addView(title, new LinearLayout.LayoutParams(0, dp(42), 1));
bar.addView(toolbarButton("Reconnect", view -> connectTerminal(sessionName)));
bar.addView(toolbarButton("More", view -> showTerminalActions(sessionName)));
return bar;
}
private void showSessionActions(String sessionName) {
String[] items = {
"Status",
"Rename",
"Send command",
"Split horizontal",
"Split vertical",
"Select pane",
"Kill pane",
"Pin session",
"Unpin session",
"Mute session",
"Unmute session",
"Session settings",
"Add to kanban project",
"Post hook event"
};
new AlertDialog.Builder(this)
.setTitle(sessionName)
.setItems(items, (dialog, which) -> {
switch (which) {
case 0:
showRaw("Session status", () -> api.sessionStatus(sessionName));
break;
case 1:
promptRenameSession(sessionName);
break;
case 2:
promptSendCommand(sessionName);
break;
case 3:
runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"));
break;
case 4:
runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"));
break;
case 5:
promptPaneId("Select pane", paneId -> api.selectPane(sessionName, paneId));
break;
case 6:
promptPaneId("Kill pane", paneId -> api.killPane(sessionName, paneId));
break;
case 7:
runApiAction("Pin session", () -> api.setPinned(sessionName, true));
break;
case 8:
runApiAction("Unpin session", () -> api.setPinned(sessionName, false));
break;
case 9:
runApiAction("Mute session", () -> api.setMuted(sessionName, true));
break;
case 10:
runApiAction("Unmute session", () -> api.setMuted(sessionName, false));
break;
case 11:
promptSessionSettings(sessionName);
break;
case 12:
promptAddKanbanSession(sessionName);
break;
case 13:
promptPostHookEvent(sessionName);
break;
default:
break;
}
})
.show();
}
private void showTerminalActions(String sessionName) {
String[] items = {
"Clear local view and tmux history",
"Split horizontal",
"Split vertical",
"Page up",
"Page down",
"Session status",
"Send command",
"Tmux prefix",
"Detach tmux client",
"New tmux window",
"Next tmux window",
"Previous tmux window"
};
new AlertDialog.Builder(this)
.setTitle(sessionName)
.setItems(items, (dialog, which) -> {
switch (which) {
case 0:
terminalBuffer.setLength(0);
terminalText.setText("");
if (terminalSocket != null) {
terminalSocket.clearHistory();
}
break;
case 1:
runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"));
break;
case 2:
runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"));
break;
case 3:
if (terminalSocket != null) {
terminalSocket.scroll(-TERMINAL_ROWS);
}
break;
case 4:
if (terminalSocket != null) {
terminalSocket.scroll(TERMINAL_ROWS);
}
break;
case 5:
showRaw("Session status", () -> api.sessionStatus(sessionName));
break;
case 6:
promptSendCommand(sessionName);
break;
case 7:
sendTerminalInput("\u0002");
break;
case 8:
sendTerminalInput("\u0002d");
break;
case 9:
sendTerminalInput("\u0002c");
break;
case 10:
sendTerminalInput("\u0002n");
break;
case 11:
sendTerminalInput("\u0002p");
break;
default:
break;
}
})
.show();
}
private LinearLayout createInputBar() {
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPadding(dp(8), dp(5), dp(8), dp(5));
bar.setBackgroundColor(Color.rgb(17, 20, 24));
inputField = new EditText(this);
inputField.setSingleLine(true);
inputField.setTextColor(Color.WHITE);
inputField.setHintTextColor(Color.rgb(150, 158, 168));
inputField.setHint("type command or text");
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND);
inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
styleInput(inputField);
inputField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) {
sendLine();
return true;
}
return false;
});
bar.addView(inputField, new LinearLayout.LayoutParams(0, dp(42), 1));
bar.addView(toolbarButton("Send", view -> sendLine()));
return bar;
}
private void promptRenameSession(String sessionName) {
promptText("Rename session", sessionName, sessionName, nextName -> {
if (!nextName.matches("[A-Za-z0-9._-]+")) {
throw new IllegalArgumentException("Invalid session name");
}
api.renameSession(sessionName, nextName);
});
}
private void promptSendCommand(String sessionName) {
promptText("Send command", "command", "", command -> api.sendCommand(sessionName, command));
}
private void promptPaneId(String title, TextApiAction action) {
promptText(title, "%1", "", action);
}
private void promptCreateKanbanProject() {
LinearLayout form = formRoot();
EditText name = formField(form, "Project name", "project");
EditText path = formField(form, "Path", "~");
EditText server = formField(form, "SSH server optional", "");
new AlertDialog.Builder(this)
.setTitle("Create kanban project")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Create", (dialog, which) -> runApiAction("Create kanban project", () ->
api.createKanbanProject(
name.getText().toString().trim(),
defaultValue(path.getText().toString().trim(), "~"),
server.getText().toString().trim()
)
))
.show();
}
private void promptDeleteKanbanProject() {
promptText("Delete kanban project", "project", "", projectName -> api.deleteKanbanProject(projectName));
}
private void promptAddKanbanSession(String sessionName) {
promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName));
}
private void promptAddKanbanSessionToProject(String projectName) {
promptText("Add session to " + projectName, "session", "", sessionName -> api.addKanbanSession(projectName, sessionName));
}
private void promptRemoveKanbanSession() {
LinearLayout form = formRoot();
EditText project = formField(form, "Project", "");
EditText agent = formField(form, "Agent/session name", "");
EditText kill = formField(form, "Kill too: true or false", "false");
new AlertDialog.Builder(this)
.setTitle("Remove kanban session")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Remove", (dialog, which) -> runApiAction("Remove kanban session", () ->
api.removeKanbanSession(
project.getText().toString().trim(),
agent.getText().toString().trim(),
Boolean.parseBoolean(kill.getText().toString().trim())
)
))
.show();
}
private void promptSessionSettings(String sessionName) {
LinearLayout form = formRoot();
EditText fontSize = formField(form, "Font size", "14");
EditText fontFamily = formField(form, "Font family", "monospace");
EditText lineHeight = formField(form, "Line height", "1.25");
EditText themeId = formField(form, "Theme ID", "dark");
new AlertDialog.Builder(this)
.setTitle("Session settings")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Save", (dialog, which) -> runApiAction("Session settings", () ->
api.updateSessionSettings(
sessionName,
Integer.parseInt(defaultValue(fontSize.getText().toString().trim(), "14")),
defaultValue(fontFamily.getText().toString().trim(), "monospace"),
Double.parseDouble(defaultValue(lineHeight.getText().toString().trim(), "1.25")),
defaultValue(themeId.getText().toString().trim(), "dark")
)
))
.show();
}
private void promptGroupMessages() {
promptText("Group messages", "project", "", projectName ->
runOnUiThread(() -> showRaw("Group messages", () -> api.groupMessages(projectName)))
);
}
private void promptSendGroupMessage() {
LinearLayout form = formRoot();
EditText project = formField(form, "Project", "");
EditText from = formField(form, "From session", "");
EditText kind = formField(form, "Kind: task or report", "task");
EditText targetType = formField(form, "Target: session, others, role", "others");
EditText targetValue = formField(form, "Target value", "");
EditText body = formField(form, "Message", "");
body.setMinLines(3);
body.setSingleLine(false);
new AlertDialog.Builder(this)
.setTitle("Send group message")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Send", (dialog, which) -> runApiAction("Send group message", () ->
api.sendGroupMessage(
project.getText().toString().trim(),
from.getText().toString().trim(),
defaultValue(kind.getText().toString().trim(), "task"),
defaultValue(targetType.getText().toString().trim(), "others"),
targetValue.getText().toString().trim(),
body.getText().toString()
)
))
.show();
}
private void promptScanGroupMessage() {
LinearLayout form = formRoot();
EditText project = formField(form, "Project", "");
EditText message = formField(form, "Message ID", "");
new AlertDialog.Builder(this)
.setTitle("Scan group message")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Scan", (dialog, which) -> runApiAction("Scan group message", () ->
api.scanGroupMessage(
project.getText().toString().trim(),
message.getText().toString().trim()
)
))
.show();
}
private void promptPostHookEvent() {
promptPostHookEvent("");
}
private void promptPostHookEvent(String presetSessionName) {
LinearLayout form = formRoot();
EditText session = formField(form, "Session", presetSessionName);
EditText title = formField(form, "Title", "Android event");
EditText status = formField(form, "Status", "info");
EditText body = formField(form, "Body", "");
body.setMinLines(2);
body.setSingleLine(false);
new AlertDialog.Builder(this)
.setTitle("Post hook event")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Post", (dialog, which) -> runApiAction("Post hook event", () ->
api.postHookEvent(
session.getText().toString().trim(),
title.getText().toString().trim(),
defaultValue(status.getText().toString().trim(), "info"),
body.getText().toString()
)
))
.show();
}
private void promptUploadImageUrl() {
LinearLayout form = formRoot();
EditText session = formField(form, "Session optional", "");
EditText url = formField(form, "Image URL", "");
new AlertDialog.Builder(this)
.setTitle("Upload image URL")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Upload", (dialog, which) -> showRaw("Image upload", () ->
api.uploadImageUrl(
session.getText().toString().trim(),
url.getText().toString().trim()
)
))
.show();
}
private void promptUploadImageFile() {
EditText session = new EditText(this);
session.setSingleLine(true);
session.setHint("session optional");
new AlertDialog.Builder(this)
.setTitle("Upload image file")
.setView(session)
.setNegativeButton("Cancel", null)
.setPositiveButton("Choose", (dialog, which) -> {
pendingImageUploadSession = session.getText().toString().trim();
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("image/*");
intent.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(Intent.createChooser(intent, "Choose image"), IMAGE_PICK_REQUEST);
})
.show();
}
private void promptImagePreviewInfo() {
LinearLayout form = formRoot();
EditText path = formField(form, "Path", "");
EditText basePath = formField(form, "Base path optional", "");
new AlertDialog.Builder(this)
.setTitle("Image preview info")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Load", (dialog, which) -> showRaw("Image preview info", () ->
api.imagePreviewInfo(
path.getText().toString().trim(),
basePath.getText().toString().trim()
)
))
.show();
}
private void promptOpenImagePreview() {
LinearLayout form = formRoot();
EditText path = formField(form, "Path", "");
EditText basePath = formField(form, "Base path optional", "");
new AlertDialog.Builder(this)
.setTitle("Open image preview")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Open", (dialog, which) -> showImagePreview(
path.getText().toString().trim(),
basePath.getText().toString().trim()
))
.show();
}
private void showPermissionsAndUpdateStatus() {
StringBuilder text = new StringBuilder();
text.append("Installed app: ").append(appIdentityText()).append('\n');
text.append("Server: ").append(getServerUrl()).append('\n');
text.append('\n');
text.append("Manual APK download:\n");
text.append("Tap APK on the Update page. The app resolves the APK from the selected update source.\n");
text.append("Release page:\n");
text.append("Tap Release on the About page. The app resolves the page from the selected update source.\n");
text.append('\n');
text.append("In-app update:\n");
text.append("Tap Check now on the Update page. The app checks only the selected source, downloads one APK per version, verifies SHA-256, then opens Android's installer.\n");
text.append("Selected manifest: ")
.append(updateSourceHost())
.append('\n')
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n');
text.append("Available sources:\n");
text.append("Gitea default: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append("GitHub optional: ").append(BuildConfig.DEFAULT_GITHUB_UPDATE_URL).append('\n');
text.append('\n');
text.append(permissionSummary());
new AlertDialog.Builder(this)
.setTitle("Permissions / update")
.setMessage(text.toString())
.setNegativeButton("Close", null)
.setNeutralButton("Install permission", (dialog, which) -> openInstallPermissionSettings())
.setPositiveButton("Check update", (dialog, which) -> updateManager.check(true))
.show();
}
private String permissionSummary() {
StringBuilder text = new StringBuilder();
text.append("Network: declared in manifest; Android does not show a runtime grant.\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
text.append("Install unknown apps: ")
.append(getPackageManager().canRequestPackageInstalls() ? "allowed" : "not allowed")
.append(". Android requires you to allow this per app before in-app APK updates can install.\n");
} else {
text.append("Install unknown apps: allowed by this Android version.\n");
}
if (Build.VERSION.SDK_INT >= 33) {
text.append("Notifications: ")
.append(checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ? "allowed" : "not allowed")
.append(". This only affects optional status notifications, not tmux control.\n");
} else {
text.append("Notifications: no runtime permission required.\n");
}
text.append("SMS: not requested. This tmux client does not need SMS permission.");
text.append("\nOpen App settings for Android's full per-app permission and storage/network controls.");
return text.toString();
}
private void showUpdateSourcePicker() {
String[] items = {
"Gitea default: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
"GitHub optional: " + BuildConfig.DEFAULT_GITHUB_UPDATE_URL,
"Custom URL"
};
new AlertDialog.Builder(this)
.setTitle("Update source")
.setItems(items, (dialog, which) -> {
if (which == 0) {
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
} else if (which == 1) {
setUpdateUrl(BuildConfig.DEFAULT_GITHUB_UPDATE_URL);
} else {
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
}
})
.show();
}
private void setUpdateUrl(String url) {
prefs.edit().putString("update_url", url).apply();
showMessage("Update source: " + url);
if (PAGE_UPDATE.equals(activeMainPage)) {
renderUpdateScreen();
} else if (PAGE_ABOUT.equals(activeMainPage)) {
renderAboutScreen();
}
}
private void migrateDefaultUpdateSourceToGitea() {
String current = prefs.getString("update_url", "");
if (current == null || current.trim().isEmpty() || OLD_GITHUB_DEFAULT_UPDATE_URL.equals(current.trim())) {
prefs.edit()
.putString("update_url", BuildConfig.DEFAULT_GITEA_UPDATE_URL)
.apply();
}
}
private void probeServerProfiles() {
progressBar.setVisibility(View.VISIBLE);
setStatus("Probing Tailscale APIs...");
executor.execute(() -> {
StringBuilder text = new StringBuilder();
for (String url : SERVER_PROFILES) {
text.append(url).append('\n');
try {
SessionApiClient client = new SessionApiClient(url);
JSONObject health = new JSONObject(client.health());
List<SessionSummary> sessions = client.getSessions();
text.append(" ok: true\n");
text.append(" version: ")
.append(health.optString("version", "unknown"))
.append(" ")
.append(health.optString("commit", ""))
.append('\n');
text.append(" sessions: ").append(sessions.size()).append('\n');
} catch (Exception error) {
text.append(" failed: ").append(error.getMessage()).append('\n');
}
text.append('\n');
}
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showTextDialog("Tailscale APIs", text.toString());
});
});
}
private void openInstallPermissionSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
showMessage("Install permission is allowed on this Android version");
return;
}
Intent intent = new Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + getPackageName())
);
startActivity(intent);
}
private void requestNotificationPermission() {
if (Build.VERSION.SDK_INT < 33) {
showMessage("Notification permission is not required on this Android version");
return;
}
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 3001);
}
private void openAppSettings() {
Intent intent = new Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:" + getPackageName())
);
startActivity(intent);
}
private void showImagePreview(String path, String basePath) {
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try {
byte[] bytes = api.imagePreview(path, basePath);
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (bitmap == null) {
throw new IllegalStateException("Preview is not a supported bitmap");
}
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
ImageView image = new ImageView(this);
image.setImageBitmap(bitmap);
image.setAdjustViewBounds(true);
image.setPadding(dp(8), dp(8), dp(8), dp(8));
new AlertDialog.Builder(this)
.setTitle("Image preview")
.setView(image)
.setPositiveButton("Close", null)
.show();
});
} catch (Exception error) {
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showMessage("Image preview failed: " + error.getMessage());
});
}
});
}
private void promptText(String title, String hint, String value, TextApiAction action) {
EditText input = new EditText(this);
input.setSingleLine(true);
input.setHint(hint);
input.setText(value);
input.setSelectAllOnFocus(true);
new AlertDialog.Builder(this)
.setTitle(title)
.setView(input)
.setNegativeButton("Cancel", null)
.setPositiveButton("OK", (dialog, which) -> {
String text = input.getText().toString().trim();
runApiAction(title, () -> action.run(text));
})
.show();
}
private void runApiAction(String label, ApiAction action) {
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try {
action.run();
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showMessage(label + " done");
if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects();
} else if (activeSessionName == null) {
refreshSessions();
}
});
} catch (Exception error) {
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showMessage(label + " failed: " + error.getMessage());
});
}
});
}
private void showRaw(String title, Callable<String> loader) {
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try {
String text = loader.call();
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showTextDialog(title, text);
});
} catch (Exception error) {
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showMessage(title + " failed: " + error.getMessage());
});
}
});
}
private void showTextDialog(String title, String text) {
ScrollView scroll = new ScrollView(this);
TextView content = new TextView(this);
content.setText(text == null || text.isEmpty() ? "(empty)" : text);
content.setTextColor(Color.rgb(20, 24, 30));
content.setTextSize(12);
content.setTypeface(Typeface.MONOSPACE);
content.setTextIsSelectable(true);
content.setPadding(dp(12), dp(12), dp(12), dp(12));
scroll.addView(content, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
new AlertDialog.Builder(this)
.setTitle(title)
.setView(scroll)
.setPositiveButton("Close", null)
.show();
}
private LinearLayout formRoot() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
form.setPadding(dp(18), dp(8), dp(18), 0);
return form;
}
private EditText formField(LinearLayout form, String label, String value) {
TextView title = new TextView(this);
title.setText(label);
title.setTextColor(Color.DKGRAY);
title.setTextSize(12);
EditText input = new EditText(this);
input.setSingleLine(true);
input.setText(value);
input.setSelectAllOnFocus(true);
styleInput(input);
form.addView(title);
form.addView(input, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
return input;
}
private HorizontalScrollView createSoftKeyBar() {
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(22, 27, 34));
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(6), dp(4), dp(6), dp(4));
addSoftKey(row, "Enter", "\r");
addSoftKey(row, "Esc", "\u001b");
addSoftKey(row, "Tab", "\t");
addSoftKey(row, "^C", "\u0003");
addSoftKey(row, "^D", "\u0004");
addSoftKey(row, "^L", "\u000c");
addSoftKey(row, "^R", "\u0012");
addSoftKey(row, "^A", "\u0001");
addSoftKey(row, "^E", "\u0005");
addSoftKey(row, "^V", "\u0016");
addSoftKey(row, "^Z", "\u001a");
addSoftKey(row, "^\\", "\u001c");
addSoftKey(row, "Tmux", "\u0002");
addSoftKey(row, "Detach", "\u0002d");
addSoftKey(row, "NewWin", "\u0002c");
addSoftKey(row, "NextWin", "\u0002n");
addSoftKey(row, "PrevWin", "\u0002p");
addSoftKey(row, "Left", "\u001b[D");
addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Up", "\u001b[A");
addSoftKey(row, "Right", "\u001b[C");
addSoftKey(row, "PgUp", "\u001b[5~");
addSoftKey(row, "PgDn", "\u001b[6~");
addSoftKey(row, "Home", "\u001b[H");
addSoftKey(row, "End", "\u001b[F");
addSoftButton(row, "Paste", view -> pasteClipboard());
scroller.addView(row, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private void connectTerminal(String sessionName) {
closeTerminalSocket();
queuedTerminalInput.setLength(0);
terminalConnected = false;
setStatus("Connecting " + sessionName);
appendTerminal("[connecting]\r\n");
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
@Override
public void onConnected() {
runOnUiThread(() -> {
terminalConnected = true;
setStatus("Connected " + sessionName);
flushQueuedTerminalInput();
});
}
@Override
public void onOutput(String data) {
runOnUiThread(() -> appendTerminal(data));
}
@Override
public void onError(String message) {
runOnUiThread(() -> {
appendTerminal("\r\n[error] " + message + "\r\n");
setStatus("Terminal error: " + message);
});
}
@Override
public void onClosed() {
runOnUiThread(() -> {
terminalConnected = false;
setStatus("Disconnected " + sessionName);
});
}
});
terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS);
}
private void sendLine() {
if (inputField == null) {
return;
}
String text = inputField.getText().toString();
if (text.isEmpty()) {
sendTerminalInput("\r");
} else {
sendTerminalInput(text + "\r");
inputField.setText("");
}
}
private void sendTerminalInput(String data) {
if (activeSessionName == null) {
return;
}
TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) {
socket.sendInput(data);
return;
}
if (socket != null && !socket.isClosed()) {
queuedTerminalInput.append(data);
setStatus("Queued input until terminal connects");
return;
}
executor.execute(() -> {
try {
for (int i = 0; i < data.length(); i += 200) {
api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length())));
}
} catch (Exception error) {
runOnUiThread(() -> showMessage("Input failed: " + error.getMessage()));
}
});
}
private void flushQueuedTerminalInput() {
if (!terminalConnected || terminalSocket == null || queuedTerminalInput.length() == 0) {
return;
}
String data = queuedTerminalInput.toString();
queuedTerminalInput.setLength(0);
terminalSocket.sendInput(data);
}
private void pasteClipboard() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null || !clipboard.hasPrimaryClip()) {
return;
}
ClipData clip = clipboard.getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) {
return;
}
CharSequence text = clip.getItemAt(0).coerceToText(this);
if (text != null && text.length() > 0) {
sendTerminalInput(text.toString());
}
}
private void appendTerminal(String data) {
terminalBuffer.append(data);
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
}
scheduleTerminalRender();
}
private void scheduleTerminalRender() {
if (terminalRenderPending || terminalText == null) {
return;
}
terminalRenderPending = true;
long now = System.currentTimeMillis();
long delay = Math.max(0L, TERMINAL_RENDER_INTERVAL_MS - (now - lastTerminalRenderMs));
terminalText.postDelayed(() -> {
terminalRenderPending = false;
renderTerminalNow();
}, delay);
}
private void renderTerminalNow() {
if (terminalText == null || terminalScroll == null) {
return;
}
lastTerminalRenderMs = System.currentTimeMillis();
terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
}
private CharSequence renderAnsiForTerminal(String text) {
SpannableStringBuilder output = new SpannableStringBuilder();
int fg = Color.rgb(230, 235, 242);
int bg = Color.TRANSPARENT;
boolean bold = false;
int index = 0;
while (index < text.length()) {
char item = text.charAt(index);
if (item == '\r') {
index++;
continue;
}
if (item == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[') {
int end = findAnsiEnd(text, index + 2);
if (end == -1) {
break;
}
char command = text.charAt(end);
if (command == 'm') {
int[] state = applySgr(text.substring(index + 2, end), fg, bg, bold);
fg = state[0];
bg = state[1];
bold = state[2] == 1;
}
index = end + 1;
continue;
}
int runStart = index;
while (index < text.length()) {
char runItem = text.charAt(index);
if (runItem == '\r' || (runItem == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[')) {
break;
}
index++;
}
appendTerminalRun(output, text.substring(runStart, index), fg, bg, bold);
}
return output;
}
private void appendTerminalRun(SpannableStringBuilder output, String text, int fg, int bg, boolean bold) {
if (text.isEmpty()) {
return;
}
int start = output.length();
output.append(text);
int finish = output.length();
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bg != Color.TRANSPARENT) {
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (bold) {
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
private int findAnsiEnd(String text, int start) {
for (int index = start; index < text.length(); index++) {
char item = text.charAt(index);
if (item >= '@' && item <= '~') {
return index;
}
}
return -1;
}
private int[] applySgr(String params, int fg, int bg, boolean bold) {
if (params.isEmpty()) {
params = "0";
}
String[] parts = params.split(";");
for (String part : parts) {
int value;
try {
value = part.isEmpty() ? 0 : Integer.parseInt(part);
} catch (NumberFormatException ignored) {
continue;
}
if (value == 0) {
fg = Color.rgb(230, 235, 242);
bg = Color.TRANSPARENT;
bold = false;
} else if (value == 1) {
bold = true;
} else if (value == 22) {
bold = false;
} else if (value == 39) {
fg = Color.rgb(230, 235, 242);
} else if (value == 49) {
bg = Color.TRANSPARENT;
} else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) {
fg = ansiColor(value, false);
} else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) {
bg = ansiColor(value, true);
}
}
return new int[]{fg, bg, bold ? 1 : 0};
}
private int ansiColor(int code, boolean background) {
int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30);
boolean bright = code >= 90;
switch (base) {
case 0:
return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45);
case 1:
return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73);
case 2:
return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80);
case 3:
return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34);
case 4:
return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255);
case 5:
return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255);
case 6:
return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187);
case 7:
default:
return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217);
}
}
private void saveServerAndRefresh() {
String url = normalizeServerUrl(urlField.getText().toString());
prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url);
urlField.setText(url);
connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions();
} else {
setStatus("Server saved: " + url);
}
}
private String getServerUrl() {
return prefs.getString("server_url", BuildConfig.DEFAULT_SERVER_URL);
}
private String appIdentityText() {
return "Version " + BuildConfig.VERSION_NAME
+ " (" + BuildConfig.VERSION_CODE + ", "
+ (BuildConfig.DEBUG ? "debug" : "release")
+ ")";
}
private String updateSourceHost() {
String url = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
if (url == null || url.trim().isEmpty()) {
url = BuildConfig.DEFAULT_UPDATE_URL;
}
String host = Uri.parse(url).getHost();
return host == null || host.isEmpty() ? "custom" : host;
}
private String normalizeServerUrl(String raw) {
String value = raw.trim();
if (value.isEmpty()) {
value = BuildConfig.DEFAULT_SERVER_URL;
}
if (!value.startsWith("http://") && !value.startsWith("https://")) {
value = "http://" + value;
}
Uri parsed = Uri.parse(value);
if (parsed.getPort() == -1 && parsed.getHost() != null) {
Uri.Builder builder = parsed.buildUpon().encodedAuthority(parsed.getHost() + ":3000");
value = builder.build().toString();
}
while (value.endsWith("/") && value.length() > "http://x".length()) {
value = value.substring(0, value.length() - 1);
}
return value;
}
private void maybeCheckForUpdates() {
long now = System.currentTimeMillis();
long last = prefs.getLong("last_update_check_ms", 0L);
if (now - last < AUTO_UPDATE_INTERVAL_MS) {
return;
}
prefs.edit().putLong("last_update_check_ms", now).apply();
root.postDelayed(() -> updateManager.check(false), 2000L);
}
private Button toolbarButton(String label, View.OnClickListener listener) {
Button button = new Button(this);
button.setText(label);
button.setTextColor(Color.rgb(235, 241, 248));
button.setTextSize(12);
button.setAllCaps(false);
button.setTypeface(Typeface.DEFAULT_BOLD);
button.setMinWidth(0);
button.setMinimumWidth(0);
button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground());
button.setGravity(Gravity.CENTER);
button.setOnClickListener(listener);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(42)
);
params.leftMargin = dp(5);
button.setLayoutParams(params);
return button;
}
private void styleInput(EditText input) {
input.setTextColor(Color.rgb(240, 246, 252));
input.setHintTextColor(Color.rgb(139, 148, 158));
input.setTextSize(14);
input.setSingleLine(true);
input.setPadding(dp(10), 0, dp(10), 0);
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
}
private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable();
states.addState(new int[]{android.R.attr.state_pressed}, rounded(Color.rgb(64, 78, 94), 8, Color.rgb(91, 108, 128), 1));
states.addState(new int[]{android.R.attr.state_focused}, rounded(Color.rgb(48, 61, 76), 8, Color.rgb(98, 128, 164), 1));
states.addState(new int[]{}, rounded(Color.rgb(34, 43, 53), 8, Color.rgb(55, 66, 80), 1));
return states;
}
private GradientDrawable rounded(int color, int radiusDp, int strokeColor, int strokeDp) {
GradientDrawable drawable = new GradientDrawable();
drawable.setColor(color);
drawable.setCornerRadius(dp(radiusDp));
if (strokeDp > 0) {
drawable.setStroke(dp(strokeDp), strokeColor);
}
return drawable;
}
private void addSoftKey(LinearLayout row, String label, String sequence) {
addSoftButton(row, label, view -> sendTerminalInput(sequence));
}
private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setMinWidth(dp(50));
button.setMinimumWidth(dp(50));
row.addView(button, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
}
private TextView bodyText(String text) {
TextView view = new TextView(this);
view.setText(text);
view.setTextColor(Color.rgb(205, 213, 224));
view.setTextSize(13);
return view;
}
private String textPart(String value) {
return value == null || value.isEmpty() ? "" : " " + value;
}
private String defaultValue(String value, String fallback) {
return value == null || value.isEmpty() ? fallback : value;
}
private LinearLayout.LayoutParams matchWrap() {
return new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
}
private void showMessage(String message) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
setStatus(message);
}
private void setStatus(String message) {
if (statusText != null) {
statusText.setText(message);
}
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
private void closeTerminalSocket() {
terminalConnected = false;
queuedTerminalInput.setLength(0);
terminalRenderPending = false;
if (terminalSocket != null) {
terminalSocket.close();
terminalSocket = null;
}
}
private void connectAppEvents() {
if (eventSocket != null) {
eventSocket.close();
}
eventSocket = new AppEventSocketClient(new AppEventSocketClient.Listener() {
@Override
public void onMessage(String text) {
runOnUiThread(() -> handleAppEvent(text));
}
@Override
public void onClosed() {
runOnUiThread(() -> setStatus("Event stream disconnected"));
}
});
eventSocket.connect(api.getBaseUrl());
}
private void handleAppEvent(String text) {
try {
JSONObject event = new JSONObject(text);
String type = event.optString("type", "");
if ("hello".equals(type)) {
setStatus("Event stream connected");
return;
}
if ("sessions-invalidated".equals(type)) {
setStatus("Sessions changed: " + event.optString("reason", "update"));
if (activeSessionName == null) {
refreshSessions();
}
return;
}
if ("hook-event".equals(type)) {
showMessage(event.optString("title", "Hook event"));
return;
}
setStatus(type.isEmpty() ? "Event received" : type);
} catch (Exception error) {
setStatus("Event received");
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != IMAGE_PICK_REQUEST) {
return;
}
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
pendingImageUploadSession = null;
return;
}
Uri uri = data.getData();
String sessionName = pendingImageUploadSession == null ? "" : pendingImageUploadSession;
pendingImageUploadSession = null;
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try (InputStream input = getContentResolver().openInputStream(uri)) {
if (input == null) {
throw new IllegalStateException("Cannot open selected image");
}
String response = api.uploadImage(sessionName, readAllBytes(input));
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showTextDialog("Image upload", response);
});
} catch (Exception error) {
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showMessage("Image upload failed: " + error.getMessage());
});
}
});
}
private byte[] readAllBytes(InputStream input) throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
BufferedInputStream buffered = new BufferedInputStream(input);
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = buffered.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
return output.toByteArray();
}
@Override
public void onBackPressed() {
if (activeSessionName != null) {
renderSessionScreen();
refreshSessions();
return;
}
super.onBackPressed();
}
@Override
protected void onResume() {
super.onResume();
if (updateManager != null) {
updateManager.resumePendingInstall();
}
}
@Override
protected void onDestroy() {
closeTerminalSocket();
if (eventSocket != null) {
eventSocket.close();
eventSocket = null;
}
executor.shutdownNow();
super.onDestroy();
}
private interface ApiAction {
void run() throws Exception;
}
private interface TextApiAction {
void run(String text) throws Exception;
}
}