Compare commits

...
4 Commits
Author SHA1 Message Date
Codex db306679d7 Polish native controls
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 11m43s
2026-07-06 09:34:39 +00:00
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 248 additions and 46 deletions
+19 -10
View File
@@ -9,6 +9,8 @@ APIs directly:
- 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`,
`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
- create, rename, send command, split pane, select pane, kill pane, pin, mute,
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
```
Those GitHub links are the stable public install/update channel. Gitea releases
are mirrored for internal tracking, but the current Gitea org/repo visibility
keeps anonymous release downloads behind login, so phones should use GitHub for
no-login install and in-app updates.
Those GitHub links are the primary public install/update channel. Gitea releases
are mirrored as a second public source. This Gitea instance does not support the
GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
Release API as the stable Gitea update entrypoint.
Plain branch builds only create Actions artifacts; they are useful for CI
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
messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
The first Android UI renders terminal output as basic monospace text with ANSI
escape filtering. 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 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,
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
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 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
manifest and the Gitea release API. GitHub is the reliable no-login source today.
The Gitea API endpoint is:
manifest and the Gitea release API. The 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:
```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:
- Tap `Update` on the main screen to check `latest.json`, download the APK,
@@ -14,15 +14,23 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.InputType;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
@@ -99,6 +107,7 @@ public final class MainActivity extends Activity {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
}
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
api = new SessionApiClient(getServerUrl());
setContentView(createRoot());
updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() {
@@ -131,8 +140,8 @@ public final class MainActivity extends Activity {
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.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true);
setStatus("Ready");
applySystemBarInsets(root);
@@ -141,11 +150,15 @@ public final class MainActivity extends Activity {
private void applySystemBarInsets(View view) {
view.setOnApplyWindowInsetsListener((target, insets) -> {
int bottom = insets.getSystemWindowInsetBottom();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
bottom = Math.max(bottom, insets.getInsets(WindowInsets.Type.ime()).bottom);
}
target.setPadding(
0,
insets.getSystemWindowInsetTop(),
0,
insets.getSystemWindowInsetBottom()
bottom
);
return insets;
});
@@ -203,6 +216,7 @@ public final class MainActivity extends Activity {
urlField.setImeOptions(EditorInfo.IME_ACTION_GO);
urlField.setText(getServerUrl());
urlField.setSelectAllOnFocus(true);
styleInput(urlField);
urlField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_GO) {
saveServerAndRefresh();
@@ -303,8 +317,11 @@ public final class MainActivity extends Activity {
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));
row.setPadding(dp(12), dp(11), dp(12), dp(11));
row.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
row.setElevation(dp(1));
}
TextView title = new TextView(this);
title.setText(session.name);
@@ -394,13 +411,18 @@ public final class MainActivity extends Activity {
root.addView(createTerminalTopBar(sessionName), matchWrap());
terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true);
terminalScroll.setBackgroundColor(Color.rgb(4, 7, 10));
terminalText = new TextView(this);
terminalText.setTextColor(Color.rgb(230, 235, 242));
terminalText.setTextSize(12);
terminalText.setTextSize(13);
terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setTextIsSelectable(true);
terminalText.setPadding(dp(8), dp(8), dp(8), dp(8));
terminalText.setBackgroundColor(Color.BLACK);
terminalText.setIncludeFontPadding(false);
terminalText.setLineSpacing(0, 1.05f);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(false);
terminalText.setPadding(dp(10), dp(10), dp(10), dp(10));
terminalText.setBackgroundColor(Color.rgb(4, 7, 10));
terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
@@ -447,6 +469,7 @@ public final class MainActivity extends Activity {
private void showMainActions() {
String[] items = {
"Health",
"Probe Tailscale APIs",
"Server status",
"Timeline",
"Preferences",
@@ -476,63 +499,66 @@ public final class MainActivity extends Activity {
showRaw("Health", () -> api.health());
break;
case 1:
showRaw("Server status", () -> api.serverStatus());
probeServerProfiles();
break;
case 2:
showRaw("Timeline", () -> api.timeline(50));
showRaw("Server status", () -> api.serverStatus());
break;
case 3:
showRaw("Preferences", () -> api.preferences());
showRaw("Timeline", () -> api.timeline(50));
break;
case 4:
showRaw("All session details", () -> api.sessionsAll());
showRaw("Preferences", () -> api.preferences());
break;
case 5:
showRaw("Pane details", () -> api.sessionsPanes());
showRaw("All session details", () -> api.sessionsAll());
break;
case 6:
showRaw("Kanban projects", () -> api.kanbanProjects());
showRaw("Pane details", () -> api.sessionsPanes());
break;
case 7:
promptCreateKanbanProject();
showRaw("Kanban projects", () -> api.kanbanProjects());
break;
case 8:
promptDeleteKanbanProject();
promptCreateKanbanProject();
break;
case 9:
promptRemoveKanbanSession();
promptDeleteKanbanProject();
break;
case 10:
promptGroupMessages();
promptRemoveKanbanSession();
break;
case 11:
promptSendGroupMessage();
promptGroupMessages();
break;
case 12:
promptScanGroupMessage();
promptSendGroupMessage();
break;
case 13:
promptPostHookEvent();
promptScanGroupMessage();
break;
case 14:
promptUploadImageFile();
promptPostHookEvent();
break;
case 15:
promptUploadImageUrl();
promptUploadImageFile();
break;
case 16:
promptImagePreviewInfo();
promptUploadImageUrl();
break;
case 17:
promptOpenImagePreview();
promptImagePreviewInfo();
break;
case 18:
openExternalUrl(BuildConfig.DEFAULT_APK_URL);
promptOpenImagePreview();
break;
case 19:
showUpdateSourcePicker();
openExternalUrl(BuildConfig.DEFAULT_APK_URL);
break;
case 20:
showUpdateSourcePicker();
break;
case 21:
showPermissionsAndUpdateStatus();
break;
default:
@@ -696,6 +722,7 @@ public final class MainActivity extends Activity {
inputField.setHint("type command or text");
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND);
inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
styleInput(inputField);
inputField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) {
sendLine();
@@ -958,8 +985,8 @@ public final class MainActivity extends Activity {
text.append("Selected manifest:\n")
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n');
text.append("GitHub 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("GitHub primary manifest:\n").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea public mirror API:\n").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append('\n');
text.append("Network: manifest permission, no runtime grant required\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -1020,6 +1047,36 @@ public final class MainActivity extends Activity {
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() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
showMessage("Install permission is allowed on this Android version");
@@ -1162,6 +1219,7 @@ public final class MainActivity extends Activity {
input.setSingleLine(true);
input.setText(value);
input.setSelectAllOnFocus(true);
styleInput(input);
form.addView(title);
form.addView(input, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -1296,14 +1354,118 @@ public final class MainActivity extends Activity {
if (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));
}
private String stripAnsiForBasicView(String text) {
return text
.replaceAll("\u001B\\[[0-?]*[ -/]*[@-~]", "")
.replace("\r", "");
private CharSequence renderAnsiForTerminal(String text) {
SpannableStringBuilder output = new SpannableStringBuilder();
int fg = Color.rgb(230, 235, 242);
int bg = Color.TRANSPARENT;
boolean bold = false;
int index = 0;
while (index < text.length()) {
char item = text.charAt(index);
if (item == '\r') {
index++;
continue;
}
if (item == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[') {
int end = findAnsiEnd(text, index + 2);
if (end == -1) {
break;
}
char command = text.charAt(end);
if (command == 'm') {
int[] state = applySgr(text.substring(index + 2, end), fg, bg, bold);
fg = state[0];
bg = state[1];
bold = state[2] == 1;
}
index = end + 1;
continue;
}
int 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() {
@@ -1351,11 +1513,15 @@ public final class MainActivity extends Activity {
private Button toolbarButton(String label, View.OnClickListener listener) {
Button button = new Button(this);
button.setText(label);
button.setTextColor(Color.rgb(235, 241, 248));
button.setTextSize(12);
button.setAllCaps(false);
button.setTypeface(Typeface.DEFAULT_BOLD);
button.setMinWidth(0);
button.setMinimumWidth(0);
button.setPadding(dp(8), 0, dp(8), 0);
button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground());
button.setGravity(Gravity.CENTER);
button.setOnClickListener(listener);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
@@ -1366,6 +1532,33 @@ public final class MainActivity extends Activity {
return button;
}
private void styleInput(EditText input) {
input.setTextColor(Color.rgb(240, 246, 252));
input.setHintTextColor(Color.rgb(139, 148, 158));
input.setTextSize(14);
input.setSingleLine(true);
input.setPadding(dp(10), 0, dp(10), 0);
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
}
private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable();
states.addState(new int[]{android.R.attr.state_pressed}, rounded(Color.rgb(64, 78, 94), 8, Color.rgb(91, 108, 128), 1));
states.addState(new int[]{android.R.attr.state_focused}, rounded(Color.rgb(48, 61, 76), 8, Color.rgb(98, 128, 164), 1));
states.addState(new int[]{}, rounded(Color.rgb(34, 43, 53), 8, Color.rgb(55, 66, 80), 1));
return states;
}
private GradientDrawable rounded(int color, int radiusDp, int strokeColor, int strokeDp) {
GradientDrawable drawable = new GradientDrawable();
drawable.setColor(color);
drawable.setCornerRadius(dp(radiusDp));
if (strokeDp > 0) {
drawable.setStroke(dp(strokeDp), strokeColor);
}
return drawable;
}
private void addSoftKey(LinearLayout row, String label, String sequence) {
addSoftButton(row, label, view -> sendTerminalInput(sequence));
}