Compare commits

...
3 Commits
Author SHA1 Message Date
Codex 19837d05bc Improve terminal rendering and keyboard insets
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 08:48:42 +00:00
Codex 73cc8c8181 Document public Gitea update mirror
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m44s
2026-07-06 08:16:13 +00:00
Codex b64ef21831 Add Tailscale API probe action
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m40s
2026-07-06 07:14:01 +00:00
2 changed files with 204 additions and 41 deletions
+19 -10
View File
@@ -9,6 +9,8 @@ APIs directly:
- server URL management for Tailscale `100.x.y.z:3000` APIs - server URL management for Tailscale `100.x.y.z:3000` APIs
- quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`, - quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`,
`100.89.0.11`, and `100.89.0.116` `100.89.0.11`, and `100.89.0.116`
- one-tap probe for the preset Tailscale APIs, showing health/version and
session counts
- native tmux session list - native tmux session list
- 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
@@ -80,10 +82,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
``` ```
Those GitHub links are the stable public install/update channel. Gitea releases Those GitHub links are the primary public install/update channel. Gitea releases
are mirrored for internal tracking, but the current Gitea org/repo visibility are mirrored as a second public source. This Gitea instance does not support the
keeps anonymous release downloads behind login, so phones should use GitHub for GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
no-login install and in-app updates. 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.
@@ -97,10 +99,11 @@ incompatible package.
The terminal screen connects to `/ws/terminal` and sends the upstream protocol The terminal screen connects to `/ws/terminal` and sends the upstream protocol
messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`. messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
The first Android UI renders terminal output as basic monospace text with ANSI The first Android UI renders terminal output as monospace text with basic ANSI
escape filtering. It is enough for shell-oriented remote testing, but it is not SGR color support. The terminal view stays bottom-aligned when output is short,
yet a complete xterm-compatible renderer for full-screen TUIs such as `vim` or auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
`top`. opens. 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`,
@@ -141,13 +144,19 @@ https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux
``` ```
The app tries the selected update source first, then falls back to the GitHub The app tries the selected update source first, then falls back to the GitHub
manifest and the Gitea release API. GitHub is the reliable no-login source today. manifest and the Gitea release API. The Gitea API endpoint is:
The Gitea API endpoint is:
```text ```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
``` ```
Gitea tag-specific assets are also public, for example:
```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/latest.json
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux-android.apk
```
In the app: In the app:
- Tap `Update` on the main screen to check `latest.json`, download the APK, - Tap `Update` on the main screen to check `latest.json`, download the APK,
@@ -18,11 +18,17 @@ import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.provider.Settings; import android.provider.Settings;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.InputType; 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.Gravity;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
@@ -99,6 +105,7 @@ public final class MainActivity extends Activity {
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));
} }
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
api = new SessionApiClient(getServerUrl()); api = new SessionApiClient(getServerUrl());
setContentView(createRoot()); setContentView(createRoot());
updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() { updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() {
@@ -141,11 +148,15 @@ public final class MainActivity extends Activity {
private void applySystemBarInsets(View view) { private void applySystemBarInsets(View view) {
view.setOnApplyWindowInsetsListener((target, insets) -> { 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( target.setPadding(
0, 0,
insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetTop(),
0, 0,
insets.getSystemWindowInsetBottom() bottom
); );
return insets; return insets;
}); });
@@ -394,13 +405,18 @@ public final class MainActivity extends Activity {
root.addView(createTerminalTopBar(sessionName), matchWrap()); root.addView(createTerminalTopBar(sessionName), matchWrap());
terminalScroll = new ScrollView(this); terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true);
terminalScroll.setBackgroundColor(Color.rgb(4, 7, 10));
terminalText = new TextView(this); terminalText = new TextView(this);
terminalText.setTextColor(Color.rgb(230, 235, 242)); terminalText.setTextColor(Color.rgb(230, 235, 242));
terminalText.setTextSize(12); terminalText.setTextSize(13);
terminalText.setTypeface(Typeface.MONOSPACE); terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setTextIsSelectable(true); terminalText.setIncludeFontPadding(false);
terminalText.setPadding(dp(8), dp(8), dp(8), dp(8)); terminalText.setLineSpacing(0, 1.05f);
terminalText.setBackgroundColor(Color.BLACK); 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( terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
@@ -447,6 +463,7 @@ public final class MainActivity extends Activity {
private void showMainActions() { private void showMainActions() {
String[] items = { String[] items = {
"Health", "Health",
"Probe Tailscale APIs",
"Server status", "Server status",
"Timeline", "Timeline",
"Preferences", "Preferences",
@@ -476,63 +493,66 @@ public final class MainActivity extends Activity {
showRaw("Health", () -> api.health()); showRaw("Health", () -> api.health());
break; break;
case 1: case 1:
showRaw("Server status", () -> api.serverStatus()); probeServerProfiles();
break; break;
case 2: case 2:
showRaw("Timeline", () -> api.timeline(50)); showRaw("Server status", () -> api.serverStatus());
break; break;
case 3: case 3:
showRaw("Preferences", () -> api.preferences()); showRaw("Timeline", () -> api.timeline(50));
break; break;
case 4: case 4:
showRaw("All session details", () -> api.sessionsAll()); showRaw("Preferences", () -> api.preferences());
break; break;
case 5: case 5:
showRaw("Pane details", () -> api.sessionsPanes()); showRaw("All session details", () -> api.sessionsAll());
break; break;
case 6: case 6:
showRaw("Kanban projects", () -> api.kanbanProjects()); showRaw("Pane details", () -> api.sessionsPanes());
break; break;
case 7: case 7:
promptCreateKanbanProject(); showRaw("Kanban projects", () -> api.kanbanProjects());
break; break;
case 8: case 8:
promptDeleteKanbanProject(); promptCreateKanbanProject();
break; break;
case 9: case 9:
promptRemoveKanbanSession(); promptDeleteKanbanProject();
break; break;
case 10: case 10:
promptGroupMessages(); promptRemoveKanbanSession();
break; break;
case 11: case 11:
promptSendGroupMessage(); promptGroupMessages();
break; break;
case 12: case 12:
promptScanGroupMessage(); promptSendGroupMessage();
break; break;
case 13: case 13:
promptPostHookEvent(); promptScanGroupMessage();
break; break;
case 14: case 14:
promptUploadImageFile(); promptPostHookEvent();
break; break;
case 15: case 15:
promptUploadImageUrl(); promptUploadImageFile();
break; break;
case 16: case 16:
promptImagePreviewInfo(); promptUploadImageUrl();
break; break;
case 17: case 17:
promptOpenImagePreview(); promptImagePreviewInfo();
break; break;
case 18: case 18:
openExternalUrl(BuildConfig.DEFAULT_APK_URL); promptOpenImagePreview();
break; break;
case 19: case 19:
showUpdateSourcePicker(); openExternalUrl(BuildConfig.DEFAULT_APK_URL);
break; break;
case 20: case 20:
showUpdateSourcePicker();
break;
case 21:
showPermissionsAndUpdateStatus(); showPermissionsAndUpdateStatus();
break; break;
default: default:
@@ -958,8 +978,8 @@ public final class MainActivity extends Activity {
text.append("Selected manifest:\n") text.append("Selected manifest:\n")
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)) .append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n'); .append('\n');
text.append("GitHub manifest:\n").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n'); text.append("GitHub primary manifest:\n").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea release API:\n").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n'); text.append("Gitea public mirror API:\n").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append('\n'); text.append('\n');
text.append("Network: manifest permission, no runtime grant required\n"); text.append("Network: manifest permission, no runtime grant required\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -1020,6 +1040,36 @@ public final class MainActivity extends Activity {
showMessage("Update source: " + url); showMessage("Update source: " + url);
} }
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() { private void openInstallPermissionSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
showMessage("Install permission is allowed on this Android version"); showMessage("Install permission is allowed on this Android version");
@@ -1296,14 +1346,118 @@ 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);
} }
terminalText.setText(stripAnsiForBasicView(terminalBuffer.toString())); terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
private String stripAnsiForBasicView(String text) { private CharSequence renderAnsiForTerminal(String text) {
return text SpannableStringBuilder output = new SpannableStringBuilder();
.replaceAll("\u001B\\[[0-?]*[ -/]*[@-~]", "") int fg = Color.rgb(230, 235, 242);
.replace("\r", ""); 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 start = output.length();
output.append(item);
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);
}
index++;
}
return output;
}
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() { private void saveServerAndRefresh() {