Compare commits

...
4 Commits
Author SHA1 Message Date
Codex c4713dccbd Handle terminal control sequences
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 12m18s
2026-07-07 10:55:06 +00:00
Codex 981e94efc3 Resize terminal to phone viewport
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 11m58s
2026-07-07 04:39:08 +00:00
Codex 434cb67372 Add interactive UI feedback
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 11m58s
2026-07-07 04:11:56 +00:00
Codex 10e8139a32 Document mirrored release APK policy
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Has been cancelled
2026-07-07 04:00:00 +00:00
4 changed files with 246 additions and 17 deletions
+7 -5
View File
@@ -1,4 +1,4 @@
name: Gitea Android APK
name: Gitea Android Compile Check
on: [push]
@@ -6,7 +6,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Build APK
- name: Compile Android APK
env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: |
@@ -62,6 +62,8 @@ jobs:
mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" release/tmux-android-gitea.apk
ls -lh release/tmux-android-gitea.apk
sha256sum release/tmux-android-gitea.apk
cp "${APK_PATH}" release/tmux-android-gitea-compile-check.apk
ls -lh release/tmux-android-gitea-compile-check.apk
sha256sum release/tmux-android-gitea-compile-check.apk
echo "Gitea-built APK is a compile check only."
echo "Do not publish it as a release asset; release APKs are GitHub-built and mirrored byte-for-byte to Gitea."
+11
View File
@@ -26,6 +26,8 @@ APIs directly:
display
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- terminal viewport resize based on the phone's visible text area, including
keyboard height changes, so tmux output wraps at the same width the user sees
- automatic update checks with Gitea first and GitHub fallback, plus manual
source-specific checks
- one-download-per-version APK cache, SHA-256 verification, and installer
@@ -100,6 +102,15 @@ to reach GitHub reliably. GitHub remains an optional 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.
Release APKs must be identical on GitHub and Gitea. The canonical APK is the
GitHub Release asset built by `.github/workflows/android.yml`; publish to Gitea
by mirroring that same `tmux-android.apk` byte-for-byte and uploading a
Gitea-specific `latest.json` whose `apkUrl` points at the Gitea asset but whose
`versionCode`, `versionName`, and `sha256` match the GitHub manifest. Do not use
a separately built Gitea APK as a release asset unless it is proven to have the
same SHA-256 as the GitHub APK. This keeps Android signatures and update
compatibility identical no matter which platform the phone can reach.
Plain branch builds only create Actions artifacts; they are useful for CI
verification, but releases are the stable download/update channel.
@@ -26,6 +26,7 @@ import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.Gravity;
import android.view.HapticFeedbackConstants;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
@@ -55,9 +56,17 @@ 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 DEFAULT_TERMINAL_COLS = 80;
private static final int DEFAULT_TERMINAL_ROWS = 24;
private static final int MIN_TERMINAL_COLS = 36;
private static final int MAX_TERMINAL_COLS = 140;
private static final int MIN_TERMINAL_ROWS = 8;
private static final int MAX_TERMINAL_ROWS = 80;
private static final int MAX_TERMINAL_CHARS = 40_000;
private static final int STATUS_NORMAL = 0;
private static final int STATUS_BUSY = 1;
private static final int STATUS_SUCCESS = 2;
private static final int STATUS_ERROR = 3;
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
private static final String OLD_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
@@ -98,6 +107,8 @@ public final class MainActivity extends Activity {
private boolean terminalConnected;
private boolean terminalRenderPending;
private long lastTerminalRenderMs;
private int terminalCols = DEFAULT_TERMINAL_COLS;
private int terminalRows = DEFAULT_TERMINAL_ROWS;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -156,7 +167,6 @@ public final class MainActivity extends Activity {
statusText.setTextSize(12);
statusText.setGravity(Gravity.CENTER_VERTICAL);
statusText.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true);
setStatus("Ready");
applySystemBarInsets(root);
@@ -966,6 +976,8 @@ public final class MainActivity extends Activity {
terminalConnected = false;
terminalRenderPending = false;
lastTerminalRenderMs = 0L;
terminalCols = DEFAULT_TERMINAL_COLS;
terminalRows = DEFAULT_TERMINAL_ROWS;
root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap());
@@ -986,6 +998,8 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
terminalScroll.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
resizeTerminalToViewport(false));
root.addView(terminalScroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
@@ -1000,7 +1014,10 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
connectTerminal(sessionName);
terminalScroll.post(() -> {
resizeTerminalToViewport(false);
connectTerminal(sessionName);
});
}
private LinearLayout createTerminalTopBar(String sessionName) {
@@ -1129,12 +1146,12 @@ public final class MainActivity extends Activity {
break;
case 3:
if (terminalSocket != null) {
terminalSocket.scroll(-TERMINAL_ROWS);
terminalSocket.scroll(-terminalRows);
}
break;
case 4:
if (terminalSocket != null) {
terminalSocket.scroll(TERMINAL_ROWS);
terminalSocket.scroll(terminalRows);
}
break;
case 5:
@@ -1756,6 +1773,7 @@ public final class MainActivity extends Activity {
queuedTerminalInput.setLength(0);
terminalConnected = false;
setStatus("Connecting " + sessionName);
resizeTerminalToViewport(false);
appendTerminal("[connecting]\r\n");
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
@Override
@@ -1763,6 +1781,7 @@ public final class MainActivity extends Activity {
runOnUiThread(() -> {
terminalConnected = true;
setStatus("Connected " + sessionName);
resizeTerminalToViewport(true);
flushQueuedTerminalInput();
});
}
@@ -1788,7 +1807,39 @@ public final class MainActivity extends Activity {
});
}
});
terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS);
terminalSocket.connect(api.getBaseUrl(), sessionName, terminalCols, terminalRows);
}
private void resizeTerminalToViewport(boolean forceSend) {
if (terminalText == null || terminalScroll == null) {
return;
}
int width = terminalScroll.getWidth();
int height = terminalScroll.getHeight();
if (width <= 0 || height <= 0) {
return;
}
int horizontalPadding = terminalText.getPaddingLeft() + terminalText.getPaddingRight();
int verticalPadding = terminalText.getPaddingTop() + terminalText.getPaddingBottom();
float charWidth = terminalText.getPaint().measureText("W");
if (charWidth <= 0f) {
charWidth = dp(8);
}
int lineHeight = terminalText.getLineHeight();
if (lineHeight <= 0) {
lineHeight = dp(16);
}
int cols = clamp((int) Math.floor((width - horizontalPadding) / charWidth), MIN_TERMINAL_COLS, MAX_TERMINAL_COLS);
int rows = clamp((height - verticalPadding) / lineHeight, MIN_TERMINAL_ROWS, MAX_TERMINAL_ROWS);
if (cols == terminalCols && rows == terminalRows && !forceSend) {
return;
}
terminalCols = cols;
terminalRows = rows;
TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) {
socket.resize(cols, rows);
}
}
private void sendLine() {
@@ -1811,6 +1862,7 @@ public final class MainActivity extends Activity {
TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) {
socket.sendInput(data);
setStatus("Sent input");
return;
}
if (socket != null && !socket.isClosed()) {
@@ -1823,6 +1875,7 @@ public final class MainActivity extends Activity {
for (int i = 0; i < data.length(); i += 200) {
api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length())));
}
runOnUiThread(() -> setStatus("Sent input"));
} catch (Exception error) {
runOnUiThread(() -> showMessage("Input failed: " + error.getMessage()));
}
@@ -1841,15 +1894,19 @@ public final class MainActivity extends Activity {
private void pasteClipboard() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null || !clipboard.hasPrimaryClip()) {
showMessage("Clipboard is empty");
return;
}
ClipData clip = clipboard.getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) {
showMessage("Clipboard is empty");
return;
}
CharSequence text = clip.getItemAt(0).coerceToText(this);
if (text != null && text.length() > 0) {
sendTerminalInput(text.toString());
} else {
showMessage("Clipboard is empty");
}
}
@@ -1892,6 +1949,14 @@ public final class MainActivity extends Activity {
while (index < text.length()) {
char item = text.charAt(index);
if (item == '\r') {
deleteCurrentTerminalLine(output);
index++;
continue;
}
if (item == '\b') {
if (output.length() > 0 && output.charAt(output.length() - 1) != '\n') {
output.delete(output.length() - 1, output.length());
}
index++;
continue;
}
@@ -1906,15 +1971,32 @@ public final class MainActivity extends Activity {
fg = state[0];
bg = state[1];
bold = state[2] == 1;
} else if (command == 'K') {
String params = text.substring(index + 2, end);
if (params.startsWith("2")) {
deleteCurrentTerminalLine(output);
}
} else if (command == 'J') {
String params = text.substring(index + 2, end);
if (params.startsWith("2") || params.startsWith("3")) {
output.clear();
}
}
index = end + 1;
continue;
}
if (item == '\u001b') {
int skipped = skipNonCsiEscape(text, index);
if (skipped > index) {
index = skipped;
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) == '[')) {
if (runItem == '\r' || runItem == '\b' || runItem == '\u001b') {
break;
}
index++;
@@ -1924,6 +2006,41 @@ public final class MainActivity extends Activity {
return output;
}
private void deleteCurrentTerminalLine(SpannableStringBuilder output) {
int start = output.length();
while (start > 0 && output.charAt(start - 1) != '\n') {
start--;
}
if (start < output.length()) {
output.delete(start, output.length());
}
}
private int skipNonCsiEscape(String text, int index) {
if (index + 1 >= text.length()) {
return index + 1;
}
char next = text.charAt(index + 1);
if (next == ']' || next == 'P' || next == '^' || next == '_') {
int cursor = index + 2;
while (cursor < text.length()) {
char item = text.charAt(cursor);
if (item == '\u0007') {
return cursor + 1;
}
if (item == '\u001b' && cursor + 1 < text.length() && text.charAt(cursor + 1) == '\\') {
return cursor + 2;
}
cursor++;
}
return text.length();
}
if (next == '(' || next == ')' || next == '*' || next == '+' || next == '-' || next == '.') {
return Math.min(index + 3, text.length());
}
return Math.min(index + 2, text.length());
}
private void appendTerminalRun(SpannableStringBuilder output, String text, int fg, int bg, boolean bold) {
if (text.isEmpty()) {
return;
@@ -2081,7 +2198,20 @@ public final class MainActivity extends Activity {
button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground());
button.setGravity(Gravity.CENTER);
button.setOnClickListener(listener);
button.setHapticFeedbackEnabled(true);
button.setOnClickListener(view -> {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
view.animate().cancel();
view.setScaleX(0.96f);
view.setScaleY(0.96f);
view.animate()
.scaleX(1f)
.scaleY(1f)
.setDuration(120L)
.start();
setStatus(label);
listener.onClick(view);
});
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(42)
@@ -2102,6 +2232,7 @@ public final class MainActivity extends Activity {
private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable();
states.addState(new int[]{-android.R.attr.state_enabled}, rounded(Color.rgb(24, 30, 37), 8, Color.rgb(35, 42, 50), 1));
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));
@@ -2156,16 +2287,91 @@ public final class MainActivity extends Activity {
}
private void showMessage(String message) {
if (message == null || message.trim().isEmpty()) {
return;
}
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
setStatus(message);
}
private void setStatus(String message) {
setStatus(message, inferStatusTone(message));
}
private void setStatus(String message, int tone) {
if (statusText != null) {
statusText.setText(message);
String value = message == null || message.trim().isEmpty() ? "Ready" : message.trim();
statusText.setText(value);
int bg = Color.rgb(22, 27, 34);
int stroke = Color.TRANSPARENT;
int text = Color.rgb(210, 215, 224);
if (tone == STATUS_BUSY) {
bg = Color.rgb(20, 44, 68);
stroke = Color.rgb(44, 96, 142);
text = Color.rgb(220, 238, 255);
} else if (tone == STATUS_SUCCESS) {
bg = Color.rgb(20, 56, 40);
stroke = Color.rgb(42, 118, 78);
text = Color.rgb(218, 245, 228);
} else if (tone == STATUS_ERROR) {
bg = Color.rgb(72, 28, 31);
stroke = Color.rgb(154, 66, 72);
text = Color.rgb(255, 226, 226);
}
statusText.setTextColor(text);
statusText.setBackground(rounded(bg, 0, stroke, tone == STATUS_NORMAL ? 0 : 1));
}
}
private int inferStatusTone(String message) {
if (message == null) {
return STATUS_NORMAL;
}
String value = message.toLowerCase(java.util.Locale.ROOT);
if (value.contains("failed")
|| value.contains("error")
|| value.contains("invalid")
|| value.contains("cannot")
|| value.contains("mismatch")
|| value.contains("disconnected")
|| value.contains("empty")
|| value.contains("no package")
|| value.contains("no app")) {
return STATUS_ERROR;
}
if (value.contains("loading")
|| value.contains("checking")
|| value.contains("connecting")
|| value.contains("probing")
|| value.contains("downloading")
|| value.contains("preparing")
|| value.contains("verifying")
|| value.contains("retrying")
|| value.contains("queued")
|| value.contains("resolving")) {
return STATUS_BUSY;
}
if (value.contains("done")
|| value.contains("loaded")
|| value.contains("connected")
|| value.contains("created")
|| value.contains("killed")
|| value.contains("opened")
|| value.contains("sent")
|| value.contains("selected")
|| value.contains("saved")
|| value.contains("using downloaded")
|| value.contains("update found")
|| value.contains("already up to date")) {
return STATUS_SUCCESS;
}
return STATUS_NORMAL;
}
private int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
+12 -2
View File
@@ -87,10 +87,12 @@ Implemented now:
preview display
- GitHub Actions APK build
- release manifest `latest.json`
- selected-source update checks; Gitea and GitHub are not probed in the same
update check
- Auto/Gitea/GitHub/Selected update checks, with Auto falling back from Gitea
to GitHub
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- terminal viewport resize derived from the Android text area, including
keyboard height changes, to avoid fixed-width tmux output wrapping on phones
- permission/about surfaces for unknown-app install status, notification status,
app settings, app version/build type, package name, selected update source, and
HTTP/WebSocket API/protocol summary
@@ -119,6 +121,14 @@ runs are for CI artifacts and should be used to validate grouped changes. Do not
publish a new tag for every small UI copy or layout change; publish when there
is a useful feature or test batch for phone-side validation.
Release APKs must be identical across the public GitHub and Gitea download
channels. Treat the GitHub Release APK as the canonical build artifact, then
mirror that same APK byte-for-byte to the matching Gitea Release. The Gitea
`latest.json` should point to the Gitea APK URL, but it must keep the same
`versionCode`, `versionName`, and `sha256` as the GitHub manifest. A Gitea-built
APK is only a compile check unless its SHA-256 exactly matches the GitHub
release APK.
## Native Roadmap
To converge with the upstream mobile design, the next implementation should add