Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

5 changed files with 58 additions and 357 deletions

View File

@ -13,17 +13,14 @@ APIs directly:
session counts session counts
- native tmux session list - native tmux session list
- Sessions page with current API/server and loaded session count - Sessions page with current API/server and loaded session count
- native Projects page for kanban project grouping, project agents, project
messages, add/remove session, create, and delete actions
- create, rename, send command, split pane, select pane, kill pane, pin, mute, - create, rename, send command, split pane, select pane, kill pane, pin, mute,
and kill session through HTTP API and kill session through HTTP API
- open one live terminal viewer through `/ws/terminal` - open one live terminal viewer through `/ws/terminal`
- native `/ws/events` listener for session invalidation and hook notifications - native `/ws/events` listener for session invalidation and hook notifications
- selectable Gitea or GitHub update source, defaulting to Gitea for mobile - selectable GitHub or Gitea update manifest source
reachability - native Tools page for health, server status, timeline, preferences,
- native Tools page for health, server status, timeline, preferences, hook kanban projects, group messages, hook events, image file/URL upload, image
events, image file/URL upload, image preview info, and native image preview preview info, and native image preview display
display
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach, - mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- automatic update checks against the selected release manifest - automatic update checks against the selected release manifest
@ -94,10 +91,10 @@ https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
``` ```
Gitea is the app's default install/update channel because phones may not be able Those GitHub links are the primary public install/update channel. Gitea releases
to reach GitHub reliably. GitHub remains an optional public source. This Gitea are mirrored as a second public source. This Gitea instance does not support the
instance does not support the GitHub-style `/releases/latest/download/...` URL, GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
so the app uses the Gitea Release API as the stable Gitea update entrypoint. Release API as the stable Gitea update entrypoint.
Plain branch builds only create Actions artifacts; they are useful for CI Plain branch builds only create Actions artifacts; they are useful for CI
verification, but releases are the stable download/update channel. verification, but releases are the stable download/update channel.
@ -114,21 +111,17 @@ messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
The first Android UI renders terminal output as monospace text with basic ANSI The first Android UI renders terminal output as monospace text with basic ANSI
SGR color support. The terminal view stays bottom-aligned when output is short, SGR color support. The terminal view stays bottom-aligned when output is short,
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
opens. Rendering is throttled and the local terminal buffer is capped so opening opens. It is enough for shell-oriented remote testing, but it is not yet a
busy sessions does not block the UI thread. Input typed before the terminal complete xterm-compatible renderer for full-screen TUIs such as `vim` or `top`.
attach message is sent is queued and flushed after the WebSocket client is ready.
It is enough for shell-oriented remote testing, but it is not yet a complete
xterm-compatible renderer for full-screen TUIs such as `vim` or `top`.
The terminal toolbar and shortcut row include tmux prefix helpers. The app sends The terminal toolbar and shortcut row include tmux prefix helpers. The app sends
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`, the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`,
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`. `Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`.
All app features are native Android controls. Complex server objects such as All app features are native Android controls. Complex server objects such as
preferences, timeline events, group messages, and image metadata currently use kanban projects, preferences, timeline events, group messages, and image metadata
native forms plus native JSON detail dialogs; kanban projects use a native currently use native forms plus native JSON detail dialogs; image preview uses a
project/agent list; image preview uses a native `ImageView`. The app does not native `ImageView`. The app does not load the browser UI.
load the browser UI.
## Permissions ## Permissions
@ -147,24 +140,28 @@ release manifest automatically, downloads the newer APK, verifies its SHA-256,
then opens Android's package installer. The user still has to approve the then opens Android's package installer. The user still has to approve the
install, and Android 8+ may require allowing this app to install unknown apps. install, and Android 8+ may require allowing this app to install unknown apps.
The default update source is the Gitea Release API: The default update manifest is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
The default source resolves `latest.json` and `tmux-android.apk` from Gitea
release assets, so APK downloads do not require GitHub. The optional GitHub
manifest is:
```text ```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
``` ```
The public manual APK download is:
```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
```
The app checks only the selected update source. It does not probe GitHub and The app checks only the selected update source. It does not probe GitHub and
Gitea during the same update check. Choose the source in the app's `Update` Gitea during the same update check. Choose the source in the app's `Update`
page, or use a custom manifest/API URL. page, or use a custom manifest/API URL.
The built-in Gitea API endpoint is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
Gitea tag-specific assets are also public, for example: Gitea tag-specific assets are also public, for example:
```text ```text

View File

@ -9,8 +9,6 @@ val repoSlug = providers.gradleProperty("repoSlug")
val defaultServerUrl = providers.gradleProperty("defaultServerUrl") val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
.orElse("http://100.89.0.116:3000") .orElse("http://100.89.0.116:3000")
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl") val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json") .orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl") val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest") .orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
@ -38,7 +36,6 @@ android {
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"") buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"") buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITHUB_UPDATE_URL", "\"${defaultGithubUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"") buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"") buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"") buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")

View File

@ -41,7 +41,6 @@ import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
@ -57,13 +56,10 @@ public final class MainActivity extends Activity {
private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L; private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L;
private static final int TERMINAL_COLS = 96; private static final int TERMINAL_COLS = 96;
private static final int TERMINAL_ROWS = 32; private static final int TERMINAL_ROWS = 32;
private static final int MAX_TERMINAL_CHARS = 40_000; private static final int MAX_TERMINAL_CHARS = 120_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_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 DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
private static final String PAGE_SESSIONS = "Sessions"; 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_TOOLS = "Tools";
private static final String PAGE_UPDATE = "Update"; private static final String PAGE_UPDATE = "Update";
private static final String PAGE_ABOUT = "About"; private static final String PAGE_ABOUT = "About";
@ -86,7 +82,6 @@ public final class MainActivity extends Activity {
private ProgressBar progressBar; private ProgressBar progressBar;
private TextView statusText; private TextView statusText;
private TextView sessionSummaryText; private TextView sessionSummaryText;
private LinearLayout projectList;
private TextView terminalText; private TextView terminalText;
private ScrollView terminalScroll; private ScrollView terminalScroll;
private EditText inputField; private EditText inputField;
@ -94,10 +89,6 @@ public final class MainActivity extends Activity {
private String activeMainPage = PAGE_SESSIONS; private String activeMainPage = PAGE_SESSIONS;
private String pendingImageUploadSession; private String pendingImageUploadSession;
private final StringBuilder terminalBuffer = new StringBuilder(); private final StringBuilder terminalBuffer = new StringBuilder();
private final StringBuilder queuedTerminalInput = new StringBuilder();
private boolean terminalConnected;
private boolean terminalRenderPending;
private long lastTerminalRenderMs;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@ -117,7 +108,6 @@ public final class MainActivity extends Activity {
.putBoolean("tailscale_defaults_applied_v1", true) .putBoolean("tailscale_defaults_applied_v1", true)
.apply(); .apply();
} }
migrateDefaultUpdateSourceToGitea();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24)); getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24)); getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
@ -292,59 +282,6 @@ public final class MainActivity extends Activity {
setStatus("Tools"); 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() { private void renderUpdateScreen() {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
@ -498,11 +435,6 @@ public final class MainActivity extends Activity {
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles())); actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
return; return;
} }
if (PAGE_PROJECTS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Refresh", view -> refreshProjects()));
actionRow.addView(toolbarButton("New", view -> promptCreateKanbanProject()));
return;
}
if (PAGE_TOOLS.equals(activeMainPage)) { if (PAGE_TOOLS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health()))); actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health())));
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles())); actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
@ -564,7 +496,6 @@ public final class MainActivity extends Activity {
renderSessionScreen(); renderSessionScreen();
refreshSessions(); refreshSessions();
})); }));
row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen()));
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen())); row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen())); row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen())); row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen()));
@ -686,8 +617,6 @@ public final class MainActivity extends Activity {
connectAppEvents(); connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) { if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions(); refreshSessions();
} else if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects();
} }
} }
@ -711,141 +640,6 @@ public final class MainActivity extends Activity {
}); });
} }
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) { private void renderSessionList(List<SessionSummary> sessions) {
LinearLayout list = root.findViewWithTag("session-list"); LinearLayout list = root.findViewWithTag("session-list");
if (list == null) { if (list == null) {
@ -959,10 +753,6 @@ public final class MainActivity extends Activity {
private void openTerminal(String sessionName) { private void openTerminal(String sessionName) {
activeSessionName = sessionName; activeSessionName = sessionName;
terminalBuffer.setLength(0); terminalBuffer.setLength(0);
queuedTerminalInput.setLength(0);
terminalConnected = false;
terminalRenderPending = false;
lastTerminalRenderMs = 0L;
root.removeAllViews(); root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap()); root.addView(createTerminalTopBar(sessionName), matchWrap());
@ -1233,10 +1023,6 @@ public final class MainActivity extends Activity {
promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, 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() { private void promptRemoveKanbanSession() {
LinearLayout form = formRoot(); LinearLayout form = formRoot();
EditText project = formField(form, "Project", ""); EditText project = formField(form, "Project", "");
@ -1440,8 +1226,8 @@ public final class MainActivity extends Activity {
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)) .append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n'); .append('\n');
text.append("Available sources:\n"); text.append("Available sources:\n");
text.append("Gitea default: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n'); text.append("GitHub: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("GitHub optional: ").append(BuildConfig.DEFAULT_GITHUB_UPDATE_URL).append('\n'); text.append("Gitea: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append('\n'); text.append('\n');
text.append(permissionSummary()); text.append(permissionSummary());
@ -1478,17 +1264,17 @@ public final class MainActivity extends Activity {
private void showUpdateSourcePicker() { private void showUpdateSourcePicker() {
String[] items = { String[] items = {
"Gitea default: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL, "GitHub: " + BuildConfig.DEFAULT_UPDATE_URL,
"GitHub optional: " + BuildConfig.DEFAULT_GITHUB_UPDATE_URL, "Gitea: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
"Custom URL" "Custom URL"
}; };
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle("Update source") .setTitle("Update source")
.setItems(items, (dialog, which) -> { .setItems(items, (dialog, which) -> {
if (which == 0) { if (which == 0) {
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL); setUpdateUrl(BuildConfig.DEFAULT_UPDATE_URL);
} else if (which == 1) { } else if (which == 1) {
setUpdateUrl(BuildConfig.DEFAULT_GITHUB_UPDATE_URL); setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
} else { } else {
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl); promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
} }
@ -1506,15 +1292,6 @@ public final class MainActivity extends Activity {
} }
} }
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() { private void probeServerProfiles() {
progressBar.setVisibility(View.VISIBLE); progressBar.setVisibility(View.VISIBLE);
setStatus("Probing Tailscale APIs..."); setStatus("Probing Tailscale APIs...");
@ -1628,9 +1405,7 @@ public final class MainActivity extends Activity {
runOnUiThread(() -> { runOnUiThread(() -> {
progressBar.setVisibility(View.GONE); progressBar.setVisibility(View.GONE);
showMessage(label + " done"); showMessage(label + " done");
if (PAGE_PROJECTS.equals(activeMainPage)) { if (activeSessionName == null) {
refreshProjects();
} else if (activeSessionName == null) {
refreshSessions(); refreshSessions();
} }
}); });
@ -1750,18 +1525,12 @@ public final class MainActivity extends Activity {
private void connectTerminal(String sessionName) { private void connectTerminal(String sessionName) {
closeTerminalSocket(); closeTerminalSocket();
queuedTerminalInput.setLength(0);
terminalConnected = false;
setStatus("Connecting " + sessionName); setStatus("Connecting " + sessionName);
appendTerminal("[connecting]\r\n"); appendTerminal("[connecting]\r\n");
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() { terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
@Override @Override
public void onConnected() { public void onConnected() {
runOnUiThread(() -> { runOnUiThread(() -> setStatus("Connected " + sessionName));
terminalConnected = true;
setStatus("Connected " + sessionName);
flushQueuedTerminalInput();
});
} }
@Override @Override
@ -1779,10 +1548,7 @@ public final class MainActivity extends Activity {
@Override @Override
public void onClosed() { public void onClosed() {
runOnUiThread(() -> { runOnUiThread(() -> setStatus("Disconnected " + sessionName));
terminalConnected = false;
setStatus("Disconnected " + sessionName);
});
} }
}); });
terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS); terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS);
@ -1806,15 +1572,10 @@ public final class MainActivity extends Activity {
return; return;
} }
TerminalSocketClient socket = terminalSocket; TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) { if (socket != null) {
socket.sendInput(data); socket.sendInput(data);
return; return;
} }
if (socket != null && !socket.isClosed()) {
queuedTerminalInput.append(data);
setStatus("Queued input until terminal connects");
return;
}
executor.execute(() -> { executor.execute(() -> {
try { try {
for (int i = 0; i < data.length(); i += 200) { for (int i = 0; i < data.length(); i += 200) {
@ -1826,15 +1587,6 @@ public final class MainActivity extends Activity {
}); });
} }
private void flushQueuedTerminalInput() {
if (!terminalConnected || terminalSocket == null || queuedTerminalInput.length() == 0) {
return;
}
String data = queuedTerminalInput.toString();
queuedTerminalInput.setLength(0);
terminalSocket.sendInput(data);
}
private void pasteClipboard() { private void pasteClipboard() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null || !clipboard.hasPrimaryClip()) { if (clipboard == null || !clipboard.hasPrimaryClip()) {
@ -1855,27 +1607,6 @@ public final class MainActivity extends Activity {
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
terminalBuffer.delete(0, 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())); terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
@ -1908,25 +1639,8 @@ public final class MainActivity extends Activity {
continue; 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(); int start = output.length();
output.append(text); output.append(item);
int finish = output.length(); int finish = output.length();
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bg != Color.TRANSPARENT) { if (bg != Color.TRANSPARENT) {
@ -1935,6 +1649,9 @@ public final class MainActivity extends Activity {
if (bold) { if (bold) {
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
} }
index++;
}
return output;
} }
private int findAnsiEnd(String text, int start) { private int findAnsiEnd(String text, int start) {
@ -2168,9 +1885,6 @@ public final class MainActivity extends Activity {
} }
private void closeTerminalSocket() { private void closeTerminalSocket() {
terminalConnected = false;
queuedTerminalInput.setLength(0);
terminalRenderPending = false;
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.close(); terminalSocket.close();
terminalSocket = null; terminalSocket = null;

View File

@ -71,10 +71,6 @@ final class TerminalSocketClient {
} }
} }
boolean isClosed() {
return closed;
}
private void run(String baseUrl, String sessionName, int cols, int rows) { private void run(String baseUrl, String sessionName, int cols, int rows) {
try { try {
URI uri = buildWsUri(baseUrl); URI uri = buildWsUri(baseUrl);
@ -82,6 +78,7 @@ final class TerminalSocketClient {
input = new BufferedInputStream(socket.getInputStream()); input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream()); output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri); handshake(uri);
listener.onConnected();
sendMessage( sendMessage(
"attach", "attach",
"tabId", "android-" + System.currentTimeMillis(), "tabId", "android-" + System.currentTimeMillis(),
@ -89,7 +86,6 @@ final class TerminalSocketClient {
"cols", cols, "cols", cols,
"rows", rows "rows", rows
); );
listener.onConnected();
readLoop(); readLoop();
} catch (Exception error) { } catch (Exception error) {
if (!closed) { if (!closed) {

View File

@ -70,8 +70,7 @@ Implemented now:
- configurable base URL, defaulting to `http://100.89.0.116:3000` - configurable base URL, defaulting to `http://100.89.0.116:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000` - support for Tailscale URLs such as `http://100.x.y.z:3000`
- native multi-page shell with `Sessions`, `Projects`, `Tools`, `Update`, and - native multi-page shell with `Sessions`, `Tools`, `Update`, and `About`
`About`
- native session list from `GET /api/sessions` - native session list from `GET /api/sessions`
- create, rename, command send, split, pane select, pane kill, pin, mute, and - create, rename, command send, split, pane select, pane kill, pin, mute, and
kill session through documented session/preference endpoints kill session through documented session/preference endpoints
@ -80,14 +79,12 @@ Implemented now:
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys, - bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste tmux prefix actions, and paste
- shortcut delivery through the terminal WebSocket `input` message - shortcut delivery through the terminal WebSocket `input` message
- native Projects page for kanban project grouping, project agents, project - native Tools page for health, server status, timeline, preferences, kanban
messages, add/remove session, create, and delete actions projects, group messages, hook events, image file/URL upload, image preview
- native Tools page for health, server status, timeline, preferences, hook metadata, and native image preview display
events, image file/URL upload, image preview metadata, and native image
preview display
- GitHub Actions APK build - GitHub Actions APK build
- release manifest `latest.json` - release manifest `latest.json`
- selected-source update checks; Gitea and GitHub are not probed in the same - selected-source update checks; GitHub and Gitea are not probed in the same
update check update check
- one-download-per-version APK cache, SHA-256 verification, and installer - one-download-per-version APK cache, SHA-256 verification, and installer
handoff handoff
@ -102,10 +99,10 @@ open Android's package installer, but the user must approve the install. On
Android 8+, the user may also need to allow this app to install unknown apps. Android 8+, the user may also need to allow this app to install unknown apps.
The app checks exactly one update source per run: the selected manifest/API URL. The app checks exactly one update source per run: the selected manifest/API URL.
Gitea is the default public source because phones may not reach GitHub reliably. GitHub is the default public source. Gitea is available as a public mirror, but
GitHub is available as an optional public source, but the app does not fall back the app does not fall back across both providers during a normal check. This
across both providers during a normal check. This keeps update behavior keeps update behavior predictable on mobile networks and avoids duplicate
predictable on mobile networks and avoids duplicate provider checks. provider checks.
Downloaded APKs are cached by `versionCode`. If a cached APK exists and its Downloaded APKs are cached by `versionCode`. If a cached APK exists and its
SHA-256 matches the manifest, the app reuses it instead of downloading the same SHA-256 matches the manifest, the app reuses it instead of downloading the same
@ -124,6 +121,6 @@ is a useful feature or test batch for phone-side validation.
To converge with the upstream mobile design, the next implementation should add To converge with the upstream mobile design, the next implementation should add
native Android modules in this order: native Android modules in this order:
1. richer native layouts for group messages, timeline, and preferences 1. richer native layouts for kanban, group messages, timeline, and preferences
2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows 2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows
3. configurable shortcut bar backed directly by WebSocket `input` 3. configurable shortcut bar backed directly by WebSocket `input`