tmux-browser-android/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java
Codex 8709c7bae5
All checks were successful
Gitea Smoke / smoke (push) Successful in 2s
Improve Tailscale defaults and update UX
2026-07-05 05:25:54 +00:00

1429 lines
56 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.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.InputType;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
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.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 = 120_000;
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
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 terminalText;
private ScrollView terminalScroll;
private EditText inputField;
private String activeSessionName;
private String pendingImageUploadSession;
private final StringBuilder terminalBuffer = new StringBuilder();
@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();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
}
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(8), 0, dp(8), 0);
statusText.setBackgroundColor(Color.rgb(29, 34, 41));
statusText.setSingleLine(true);
setStatus("Ready");
return root;
}
private void renderSessionScreen() {
closeTerminalSocket();
activeSessionName = null;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createServerProfileBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout list = new LinearLayout(this);
list.setOrientation(LinearLayout.VERTICAL);
list.setPadding(dp(8), dp(8), dp(8), dp(8));
list.setTag("session-list");
scroll.addView(list);
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 LinearLayout createServerBar() {
LinearLayout toolbar = new LinearLayout(this);
toolbar.setOrientation(LinearLayout.HORIZONTAL);
toolbar.setGravity(Gravity.CENTER_VERTICAL);
toolbar.setPadding(dp(8), dp(6), dp(8), dp(6));
toolbar.setBackgroundColor(Color.rgb(17, 20, 24));
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);
urlField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_GO) {
saveServerAndRefresh();
return true;
}
return false;
});
toolbar.addView(urlField, new LinearLayout.LayoutParams(0, dp(42), 1));
toolbar.addView(toolbarButton("Go", view -> saveServerAndRefresh()));
toolbar.addView(toolbarButton("New", view -> promptCreateSession()));
toolbar.addView(toolbarButton("Update", view -> updateManager.check(true)));
toolbar.addView(toolbarButton("More", view -> showMainActions()));
return toolbar;
}
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 void selectServer(String url) {
prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url);
urlField.setText(url);
setStatus("Selected " + url);
connectAppEvents();
refreshSessions();
}
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(() -> showMessage("Session load failed: " + error.getMessage()));
} finally {
runOnUiThread(() -> progressBar.setVisibility(View.GONE));
}
});
}
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());
}
setStatus("Loaded " + sessions.size() + " sessions");
}
private View sessionRow(SessionSummary session) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(dp(10), dp(10), dp(10), dp(10));
row.setBackgroundColor(Color.rgb(29, 34, 41));
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);
root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap());
terminalScroll = new ScrollView(this);
terminalText = new TextView(this);
terminalText.setTextColor(Color.rgb(230, 235, 242));
terminalText.setTextSize(12);
terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setTextIsSelectable(true);
terminalText.setPadding(dp(8), dp(8), dp(8), dp(8));
terminalText.setBackgroundColor(Color.BLACK);
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 showMainActions() {
String[] items = {
"Health",
"Server status",
"Timeline",
"Preferences",
"All session details",
"Pane details",
"Kanban projects",
"Create kanban project",
"Delete kanban project",
"Remove kanban session",
"Group messages",
"Send group message",
"Scan group message",
"Post hook event",
"Upload image file",
"Upload image URL",
"Image preview info",
"Open image preview",
"Permissions / update status"
};
new AlertDialog.Builder(this)
.setTitle("Native API actions")
.setItems(items, (dialog, which) -> {
switch (which) {
case 0:
showRaw("Health", () -> api.health());
break;
case 1:
showRaw("Server status", () -> api.serverStatus());
break;
case 2:
showRaw("Timeline", () -> api.timeline(50));
break;
case 3:
showRaw("Preferences", () -> api.preferences());
break;
case 4:
showRaw("All session details", () -> api.sessionsAll());
break;
case 5:
showRaw("Pane details", () -> api.sessionsPanes());
break;
case 6:
showRaw("Kanban projects", () -> api.kanbanProjects());
break;
case 7:
promptCreateKanbanProject();
break;
case 8:
promptDeleteKanbanProject();
break;
case 9:
promptRemoveKanbanSession();
break;
case 10:
promptGroupMessages();
break;
case 11:
promptSendGroupMessage();
break;
case 12:
promptScanGroupMessage();
break;
case 13:
promptPostHookEvent();
break;
case 14:
promptUploadImageFile();
break;
case 15:
promptUploadImageUrl();
break;
case 16:
promptImagePreviewInfo();
break;
case 17:
promptOpenImagePreview();
break;
case 18:
showPermissionsAndUpdateStatus();
break;
default:
break;
}
})
.show();
}
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"
};
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;
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);
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 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("Server: ").append(getServerUrl()).append('\n');
text.append("Update manifest: ")
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n');
text.append("Network: manifest permission, no runtime grant required\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
text.append("Install unknown apps: ")
.append(getPackageManager().canRequestPackageInstalls() ? "allowed" : "not allowed")
.append('\n');
} else {
text.append("Install unknown apps: allowed by 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('\n');
} else {
text.append("Notifications: no runtime permission required\n");
}
text.append("SMS: not requested; this tmux client does not need SMS permission.\n");
new AlertDialog.Builder(this)
.setTitle("Permissions / update")
.setMessage(text.toString())
.setNegativeButton("Close", null)
.setNeutralButton("Install permission", (dialog, which) -> openInstallPermissionSettings())
.setPositiveButton("Notify permission", (dialog, which) -> requestNotificationPermission())
.show();
}
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 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 (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);
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, "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();
setStatus("Connecting " + sessionName);
appendTerminal("[connecting]\r\n");
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
@Override
public void onConnected() {
runOnUiThread(() -> setStatus("Connected " + sessionName));
}
@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(() -> 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.sendInput(data);
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 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);
}
terminalText.setText(stripAnsiForBasicView(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
}
private String stripAnsiForBasicView(String text) {
return text
.replaceAll("\u001B\\[[0-?]*[ -/]*[@-~]", "")
.replace("\r", "");
}
private void saveServerAndRefresh() {
String url = normalizeServerUrl(urlField.getText().toString());
prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url);
urlField.setText(url);
connectAppEvents();
refreshSessions();
}
private String getServerUrl() {
return prefs.getString("server_url", BuildConfig.DEFAULT_SERVER_URL);
}
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.setTextSize(12);
button.setAllCaps(false);
button.setMinWidth(0);
button.setMinimumWidth(0);
button.setPadding(dp(8), 0, dp(8), 0);
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 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() {
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 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;
}
}