Compare commits

..
8 Commits
Author SHA1 Message Date
Codex 61e28645f1 Fix server flow and keyboard insets
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m16s
2026-07-08 15:30:13 +00:00
Codex a3bc92ddb5 Prefer Gitea for Android builds
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-08 01:30:40 +00:00
Codex 1874d23716 Make terminal accessory bar paged
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 12m27s
2026-07-08 01:14:25 +00:00
Codex c4ec2fd2ef Refine terminal input panel
Gitea Android Compile Check / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 1s
2026-07-08 00:38:31 +00:00
Codex ced6523c00 Improve terminal composer controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 12m2s
2026-07-08 00:12:16 +00:00
Codex 2e8a0a164f Use screen buffer for terminal rendering
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 12m10s
2026-07-07 11:13:38 +00:00
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
6 changed files with 1292 additions and 258 deletions
+105 -9
View File
@@ -1,4 +1,4 @@
name: Gitea Android Compile Check name: Gitea Android APK
on: [push] on: [push]
@@ -6,9 +6,13 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Compile Android APK - name: Build Android APK
env: env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }} CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: | run: |
set -eu set -eu
@@ -26,8 +30,12 @@ jobs:
rm -rf "${WORKDIR}" rm -rf "${WORKDIR}"
SERVER_URL="${GITHUB_SERVER_URL:-https://gitea.neatcn.com}" SERVER_URL="${GITHUB_SERVER_URL:-https://gitea.neatcn.com}"
REPOSITORY="${GITHUB_REPOSITORY:-tmux/tmux-browser-android}" REPOSITORY="${GITHUB_REPOSITORY:-tmux/tmux-browser-android}"
if [ -n "${CLONE_TOKEN:-}" ]; then
AUTH_SERVER_URL="$(printf '%s' "${SERVER_URL}" | sed "s#https://#https://gouki:${CLONE_TOKEN}@#")" AUTH_SERVER_URL="$(printf '%s' "${SERVER_URL}" | sed "s#https://#https://gouki:${CLONE_TOKEN}@#")"
git clone "${AUTH_SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}" git clone "${AUTH_SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
else
git clone "${SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
fi
cd "${WORKDIR}" cd "${WORKDIR}"
git checkout "${GITHUB_SHA:-main}" git checkout "${GITHUB_SHA:-main}"
@@ -53,17 +61,105 @@ jobs:
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools" sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
REF="${GITHUB_REF:-}"
REF_NAME="${GITHUB_REF_NAME:-}"
if [ -z "${REF_NAME}" ]; then
REF_NAME="${REF##*/}"
fi
PUBLISH_RELEASE=0
if [ "${REF#refs/tags/v}" != "${REF}" ]; then
PUBLISH_RELEASE=1
VERSION_NAME="${REF_NAME#v}"
MAJOR="$(printf '%s' "${VERSION_NAME}" | cut -d. -f1)"
MINOR="$(printf '%s' "${VERSION_NAME}" | cut -d. -f2)"
PATCH="$(printf '%s' "${VERSION_NAME}" | cut -d. -f3)"
VERSION_CODE=$((MAJOR * 1000000 + MINOR * 1000 + PATCH))
else
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER:-0}" VERSION_NAME="0.1.${GITHUB_RUN_NUMBER:-0}"
VERSION_CODE=$((2000 + ${GITHUB_RUN_NUMBER:-0})) VERSION_CODE=$((3000 + ${GITHUB_RUN_NUMBER:-0}))
gradle :app:assembleDebug \ fi
SIGNED=false
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
echo "${ANDROID_KEYSTORE_BASE64}" | base64 -d > release.jks
{
echo "storeFile=release.jks"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${ANDROID_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > signing.properties
SIGNED=true
fi
if [ "${PUBLISH_RELEASE}" = "1" ] && [ "${SIGNED}" != "true" ]; then
echo "Gitea release publishing requires ANDROID_KEYSTORE_BASE64 and signing secrets." >&2
exit 1
fi
if [ "${PUBLISH_RELEASE}" = "1" ] && [ -z "${CLONE_TOKEN:-}" ]; then
echo "Gitea release publishing requires TMUX_GITEA_TOKEN." >&2
exit 1
fi
if [ "${SIGNED}" = "true" ]; then
BUILD_TASK=":app:assembleRelease"
else
BUILD_TASK=":app:assembleDebug"
fi
gradle "${BUILD_TASK}" \
-PversionCode="${VERSION_CODE}" \ -PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \ -PversionName="${VERSION_NAME}" \
-PrepoSlug="neatstudio/tmux-browser-android" -PrepoSlug="neatstudio/tmux-browser-android"
mkdir -p release mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)" APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" release/tmux-android-gitea-compile-check.apk cp "${APK_PATH}" release/tmux-android.apk
ls -lh release/tmux-android-gitea-compile-check.apk cp "${APK_PATH}" "release/tmux-android-${VERSION_NAME}.apk"
sha256sum release/tmux-android-gitea-compile-check.apk SHA256="$(sha256sum release/tmux-android.apk | awk '{print $1}')"
echo "Gitea-built APK is a compile check only." TAG="v${VERSION_NAME}"
echo "Do not publish it as a release asset; release APKs are GitHub-built and mirrored byte-for-byte to Gitea." APK_URL="https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/${TAG}/tmux-android.apk"
RELEASE_PAGE_URL="https://gitea.neatcn.com/tmux/tmux-browser-android/releases/tag/${TAG}"
cat > release/latest.json << JSON
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"apkUrl": "${APK_URL}",
"sha256": "${SHA256}",
"releasePageUrl": "${RELEASE_PAGE_URL}",
"minSdk": 26
}
JSON
ls -lh release/tmux-android.apk release/latest.json
sha256sum release/tmux-android.apk
if [ "${PUBLISH_RELEASE}" != "1" ]; then
echo "Gitea main build completed as compile check."
exit 0
fi
API_ROOT="https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android"
RELEASE_BODY='{"tag_name":"'"${TAG}"'","target_commitish":"main","name":"tmux Android '"${VERSION_NAME}"'","body":"Android APK for tmux-ui remote testing.","draft":false,"prerelease":false}'
if ! curl -fsSL -X POST \
-H "Authorization: token ${CLONE_TOKEN}" \
-H "Content-Type: application/json" \
-d "${RELEASE_BODY}" \
"${API_ROOT}/releases" \
-o /tmp/gitea-release.json; then
curl -fsSL -H "Authorization: token ${CLONE_TOKEN}" \
"${API_ROOT}/releases/tags/${TAG}" \
-o /tmp/gitea-release.json
fi
RELEASE_ID="$(sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' /tmp/gitea-release.json | head -1)"
if [ -z "${RELEASE_ID}" ]; then
echo "Cannot resolve Gitea release id." >&2
cat /tmp/gitea-release.json >&2
exit 1
fi
curl -fsSL -X POST -H "Authorization: token ${CLONE_TOKEN}" \
-F "attachment=@release/tmux-android.apk;type=application/vnd.android.package-archive" \
"${API_ROOT}/releases/${RELEASE_ID}/assets?name=tmux-android.apk" \
-o /tmp/gitea-apk-asset.json
curl -fsSL -X POST -H "Authorization: token ${CLONE_TOKEN}" \
-F "attachment=@release/latest.json;type=application/json" \
"${API_ROOT}/releases/${RELEASE_ID}/assets?name=latest.json" \
-o /tmp/gitea-latest-asset.json
echo "Published Gitea release ${TAG}."
+3 -3
View File
@@ -2,8 +2,6 @@ name: Android APK
on: on:
push: push:
branches:
- main
tags: tags:
- "v*" - "v*"
pull_request: pull_request:
@@ -27,10 +25,12 @@ jobs:
run: | run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION_NAME="${GITHUB_REF_NAME#v}" VERSION_NAME="${GITHUB_REF_NAME#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION_NAME}"
VERSION_CODE=$((MAJOR * 1000000 + MINOR * 1000 + PATCH))
else else
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER}" VERSION_NAME="0.1.${GITHUB_RUN_NUMBER}"
fi
VERSION_CODE=$((1000 + GITHUB_RUN_NUMBER)) VERSION_CODE=$((1000 + GITHUB_RUN_NUMBER))
fi
echo "VERSION_NAME=${VERSION_NAME}" >> "${GITHUB_ENV}" echo "VERSION_NAME=${VERSION_NAME}" >> "${GITHUB_ENV}"
echo "VERSION_CODE=${VERSION_CODE}" >> "${GITHUB_ENV}" echo "VERSION_CODE=${VERSION_CODE}" >> "${GITHUB_ENV}"
+29 -28
View File
@@ -26,6 +26,8 @@ APIs directly:
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
- 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 - automatic update checks with Gitea first and GitHub fallback, plus manual
source-specific checks source-specific checks
- one-download-per-version APK cache, SHA-256 verification, and installer - one-download-per-version APK cache, SHA-256 verification, and installer
@@ -86,31 +88,32 @@ base64 -w 0 tmux-android-release.jks
Branch builds and manual workflow runs create Actions artifacts only. Use them Branch builds and manual workflow runs create Actions artifacts only. Use them
to verify grouped changes before publishing. to verify grouped changes before publishing.
Publish a release build by pushing a `v*` tag. A tag should be reserved for a Gitea is the primary build and update channel. Normal `main` pushes run the
coherent feature/test batch, not every small UI or text change. Tag publishing Gitea Android workflow for compile checks, while GitHub no longer builds every
creates a GitHub Release with: main push. Publish a release build by pushing a `v*` tag after a coherent
feature/test batch, not every small UI or text change.
Tag publishing can create GitHub Release assets with:
```text ```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
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 Gitea is also the app's default install/update channel because phones may not be
to reach GitHub reliably. GitHub remains an optional public source. This Gitea able to reach GitHub reliably. GitHub remains an optional public source. This
instance does not support the GitHub-style `/releases/latest/download/...` URL, Gitea instance does not support the GitHub-style
so the app uses the Gitea Release API as the stable Gitea update entrypoint. `/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 Release APKs on GitHub and Gitea should either be the exact same file or be
GitHub Release asset built by `.github/workflows/android.yml`; publish to Gitea built from the same tag with the same signing keystore, `versionCode`, and
by mirroring that same `tmux-android.apk` byte-for-byte and uploading a `versionName`. The Gitea workflow publishes release assets only when signing
Gitea-specific `latest.json` whose `apkUrl` points at the Gitea asset but whose secrets are present. Unsigned Gitea builds remain compile checks and must not be
`versionCode`, `versionName`, and `sha256` match the GitHub manifest. Do not use used for automatic in-place updates.
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 Plain branch builds are useful for CI verification, but releases are the stable
verification, but releases are the stable download/update channel. download/update channel.
Unsigned/debug workflow artifacts are useful only for smoke testing install and Unsigned/debug workflow artifacts are useful only for smoke testing install and
launch. Automatic in-place updates require release APKs signed with the same launch. Automatic in-place updates require release APKs signed with the same
@@ -121,18 +124,16 @@ 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 monospace text with basic ANSI The Android UI renders terminal output through a lightweight screen buffer with
SGR color support. The terminal view stays bottom-aligned when output is short, cursor movement, line clearing, screen clearing, basic ANSI SGR color support,
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard and throttled redraws. It is enough for shell-oriented remote testing, but it is
opens. Rendering is throttled and the local terminal buffer is capped so opening not yet a complete xterm-compatible renderer for full-screen TUIs such as `vim`
busy sessions does not block the UI thread. Input typed before the terminal 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 input area is a native multi-line composer with a paged accessory
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`, bar for editing keys, control keys, tmux prefix helpers, navigation keys, and
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`. common shell symbols. The app sends 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`.
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 preferences, timeline events, group messages, and image metadata currently use
@@ -19,19 +19,16 @@ 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.HapticFeedbackConstants; import android.view.HapticFeedbackConstants;
import android.view.KeyEvent;
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.WindowManager;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.HorizontalScrollView; import android.widget.HorizontalScrollView;
@@ -56,9 +53,12 @@ import java.util.concurrent.Executors;
public final class MainActivity extends Activity { public final class MainActivity extends Activity {
private static final int IMAGE_PICK_REQUEST = 2001; private static final int IMAGE_PICK_REQUEST = 2001;
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 DEFAULT_TERMINAL_COLS = 80;
private static final int TERMINAL_ROWS = 32; private static final int DEFAULT_TERMINAL_ROWS = 24;
private static final int MAX_TERMINAL_CHARS = 40_000; 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 STATUS_NORMAL = 0; private static final int STATUS_NORMAL = 0;
private static final int STATUS_BUSY = 1; private static final int STATUS_BUSY = 1;
private static final int STATUS_SUCCESS = 2; private static final int STATUS_SUCCESS = 2;
@@ -67,6 +67,7 @@ public final class MainActivity extends Activity {
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 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_SERVERS = "Servers";
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_PROJECTS = "Projects";
private static final String PAGE_TOOLS = "Tools"; private static final String PAGE_TOOLS = "Tools";
@@ -95,14 +96,20 @@ public final class MainActivity extends Activity {
private TextView terminalText; private TextView terminalText;
private ScrollView terminalScroll; private ScrollView terminalScroll;
private EditText inputField; private EditText inputField;
private View terminalComposerBar;
private String activeSessionName; private String activeSessionName;
private String activeMainPage = PAGE_SESSIONS; private String activeMainPage = PAGE_SERVERS;
private String pendingImageUploadSession; private String pendingImageUploadSession;
private final StringBuilder terminalBuffer = new StringBuilder(); private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
private final StringBuilder queuedTerminalInput = new StringBuilder(); private final StringBuilder queuedTerminalInput = new StringBuilder();
private boolean terminalConnected; private boolean terminalConnected;
private boolean terminalRenderPending; private boolean terminalRenderPending;
private boolean terminalSelectionEnabled;
private boolean terminalFollowOutput = true;
private int terminalKeyPage;
private long lastTerminalRenderMs; private long lastTerminalRenderMs;
private int terminalCols = DEFAULT_TERMINAL_COLS;
private int terminalRows = DEFAULT_TERMINAL_ROWS;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -127,7 +134,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); getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
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,8 +148,7 @@ public final class MainActivity extends Activity {
showMessage(message); showMessage(message);
} }
}); });
renderSessionScreen(); renderServerScreen();
refreshSessions();
connectAppEvents(); connectAppEvents();
maybeCheckForUpdates(); maybeCheckForUpdates();
} }
@@ -171,7 +177,9 @@ public final class MainActivity extends Activity {
view.setOnApplyWindowInsetsListener((target, insets) -> { view.setOnApplyWindowInsetsListener((target, insets) -> {
int bottom = insets.getSystemWindowInsetBottom(); int bottom = insets.getSystemWindowInsetBottom();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
bottom = Math.max(bottom, insets.getInsets(WindowInsets.Type.ime()).bottom); int systemBottom = insets.getInsets(WindowInsets.Type.systemBars()).bottom;
int imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
bottom = Math.max(systemBottom, imeBottom);
} }
target.setPadding( target.setPadding(
0, 0,
@@ -185,16 +193,94 @@ public final class MainActivity extends Activity {
view.post(view::requestApplyInsets); view.post(view::requestApplyInsets);
} }
private void renderServerScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_SERVERS;
projectList = null;
root.removeAllViews();
root.addView(createMainTabs(PAGE_SERVERS), 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(
"Server",
"Active API: " + getServerUrl() + "\nHTTP and WebSocket use port 3000."
));
content.addView(sectionTitle("Tailscale servers"));
for (String url : SERVER_PROFILES) {
content.addView(serverProfileCard(url), matchWrap());
}
content.addView(sectionTitle("Custom server"));
content.addView(createServerBar());
content.addView(actionPanel(
actionButton("Open sessions", view -> openSessionPage()),
actionButton("Probe all", view -> probeServerProfiles()),
actionButton("Health", view -> showRaw("Health", () -> api.health())),
actionButton("Update", view -> renderUpdateScreen())
));
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("Servers");
}
private View serverProfileCard(String url) {
String label = url.replace("http://", "").replace(":3000", "");
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(label);
title.setTextColor(Color.WHITE);
title.setTextSize(16);
title.setTypeface(Typeface.DEFAULT_BOLD);
TextView meta = bodyText(url);
meta.setPadding(0, dp(4), 0, dp(8));
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.HORIZONTAL);
actions.addView(toolbarButton("Use", view -> {
selectServer(url);
openSessionPage();
}));
actions.addView(toolbarButton("Probe", view -> probeSingleServer(url)));
card.addView(title);
card.addView(meta);
card.addView(actions);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
card.setLayoutParams(params);
return card;
}
private void renderSessionScreen() { private void renderSessionScreen() {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
activeMainPage = PAGE_SESSIONS; activeMainPage = PAGE_SESSIONS;
root.removeAllViews(); root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createServerProfileBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
));
root.addView(createMainTabs(PAGE_SESSIONS), new LinearLayout.LayoutParams( root.addView(createMainTabs(PAGE_SESSIONS), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
dp(48) dp(48)
@@ -207,11 +293,23 @@ public final class MainActivity extends Activity {
ScrollView scroll = new ScrollView(this); ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent(); LinearLayout content = pageContent();
content.addView(sessionSummaryBlock()); content.addView(sessionSummaryBlock());
content.addView(sectionTitle("Session actions"));
content.addView(actionPanel(
actionButton("New session", view -> promptCreateSession()),
actionButton("Refresh", view -> refreshSessions()),
actionButton("New project", view -> promptCreateKanbanProject()),
actionButton("Projects", view -> renderProjectsScreen())
));
content.addView(sectionTitle("Tmux sessions")); content.addView(sectionTitle("Tmux sessions"));
LinearLayout list = new LinearLayout(this); LinearLayout list = new LinearLayout(this);
list.setOrientation(LinearLayout.VERTICAL); list.setOrientation(LinearLayout.VERTICAL);
list.setTag("session-list"); list.setTag("session-list");
content.addView(list); content.addView(list);
content.addView(sectionTitle("Project groups"));
projectList = new LinearLayout(this);
projectList.setOrientation(LinearLayout.VERTICAL);
projectList.addView(projectStateText("Loading after sessions..."), matchWrap());
content.addView(projectList, matchWrap());
scroll.addView(content); scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams( root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -222,6 +320,13 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
dp(28) dp(28)
)); ));
setStatus("Sessions");
}
private void openSessionPage() {
renderSessionScreen();
refreshSessions();
refreshProjects();
} }
private View sessionSummaryBlock() { private View sessionSummaryBlock() {
@@ -237,6 +342,7 @@ public final class MainActivity extends Activity {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
activeMainPage = PAGE_TOOLS; activeMainPage = PAGE_TOOLS;
projectList = null;
root.removeAllViews(); root.removeAllViews();
root.addView(createServerBar(), matchWrap()); root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_TOOLS), new LinearLayout.LayoutParams( root.addView(createMainTabs(PAGE_TOOLS), new LinearLayout.LayoutParams(
@@ -353,6 +459,7 @@ public final class MainActivity extends Activity {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
activeMainPage = PAGE_UPDATE; activeMainPage = PAGE_UPDATE;
projectList = null;
root.removeAllViews(); root.removeAllViews();
root.addView(createServerBar(), matchWrap()); root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_UPDATE), new LinearLayout.LayoutParams( root.addView(createMainTabs(PAGE_UPDATE), new LinearLayout.LayoutParams(
@@ -406,6 +513,7 @@ public final class MainActivity extends Activity {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
activeMainPage = PAGE_ABOUT; activeMainPage = PAGE_ABOUT;
projectList = null;
root.removeAllViews(); root.removeAllViews();
root.addView(createServerBar(), matchWrap()); root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_ABOUT), new LinearLayout.LayoutParams( root.addView(createMainTabs(PAGE_ABOUT), new LinearLayout.LayoutParams(
@@ -499,6 +607,11 @@ public final class MainActivity extends Activity {
} }
private void addContextActions(LinearLayout actionRow) { private void addContextActions(LinearLayout actionRow) {
if (PAGE_SERVERS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Use", view -> saveServerAndRefresh()));
actionRow.addView(toolbarButton("Probe", view -> probeSingleServer(currentUrlInput())));
return;
}
if (PAGE_SESSIONS.equals(activeMainPage)) { if (PAGE_SESSIONS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("New", view -> promptCreateSession())); actionRow.addView(toolbarButton("New", view -> promptCreateSession()));
actionRow.addView(toolbarButton("Refresh", view -> refreshSessions())); actionRow.addView(toolbarButton("Refresh", view -> refreshSessions()));
@@ -567,10 +680,8 @@ public final class MainActivity extends Activity {
row.setOrientation(LinearLayout.HORIZONTAL); row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL); row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(8), dp(4), dp(8), dp(4)); row.setPadding(dp(8), dp(4), dp(8), dp(4));
row.addView(navButton(PAGE_SESSIONS, selected, view -> { row.addView(navButton(PAGE_SERVERS, selected, view -> renderServerScreen()));
renderSessionScreen(); row.addView(navButton(PAGE_SESSIONS, selected, view -> openSessionPage()));
refreshSessions();
}));
row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen())); 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()));
@@ -688,7 +799,9 @@ public final class MainActivity extends Activity {
private void selectServer(String url) { private void selectServer(String url) {
prefs.edit().putString("server_url", url).apply(); prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url); api = new SessionApiClient(url);
if (urlField != null) {
urlField.setText(url); urlField.setText(url);
}
setStatus("Selected " + url); setStatus("Selected " + url);
connectAppEvents(); connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) { if (PAGE_SESSIONS.equals(activeMainPage)) {
@@ -698,6 +811,14 @@ public final class MainActivity extends Activity {
} }
} }
private String currentUrlInput() {
if (urlField == null) {
return getServerUrl();
}
String text = urlField.getText().toString().trim();
return text.isEmpty() ? getServerUrl() : text;
}
private void refreshSessions() { private void refreshSessions() {
setStatus("Loading sessions from " + api.getBaseUrl()); setStatus("Loading sessions from " + api.getBaseUrl());
progressBar.setVisibility(View.VISIBLE); progressBar.setVisibility(View.VISIBLE);
@@ -965,11 +1086,17 @@ public final class MainActivity extends Activity {
private void openTerminal(String sessionName) { private void openTerminal(String sessionName) {
activeSessionName = sessionName; activeSessionName = sessionName;
terminalBuffer.setLength(0); projectList = null;
terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
queuedTerminalInput.setLength(0); queuedTerminalInput.setLength(0);
terminalConnected = false; terminalConnected = false;
terminalRenderPending = false; terminalRenderPending = false;
terminalSelectionEnabled = false;
terminalFollowOutput = true;
terminalKeyPage = 0;
lastTerminalRenderMs = 0L; lastTerminalRenderMs = 0L;
terminalCols = DEFAULT_TERMINAL_COLS;
terminalRows = DEFAULT_TERMINAL_ROWS;
root.removeAllViews(); root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap()); root.addView(createTerminalTopBar(sessionName), matchWrap());
@@ -983,28 +1110,40 @@ public final class MainActivity extends Activity {
terminalText.setIncludeFontPadding(false); terminalText.setIncludeFontPadding(false);
terminalText.setLineSpacing(0, 1.05f); terminalText.setLineSpacing(0, 1.05f);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START); terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(false); terminalText.setTextIsSelectable(terminalSelectionEnabled);
terminalText.setPadding(dp(10), dp(10), dp(10), dp(10)); terminalText.setPadding(dp(10), dp(10), dp(10), dp(10));
terminalText.setBackgroundColor(Color.rgb(4, 7, 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
)); ));
terminalScroll.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
resizeTerminalToViewport(false));
terminalScroll.setOnScrollChangeListener((view, scrollX, scrollY, oldScrollX, oldScrollY) ->
terminalFollowOutput = !view.canScrollVertically(1));
root.addView(terminalScroll, new LinearLayout.LayoutParams( root.addView(terminalScroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
0, 0,
1 1
)); ));
root.addView(createInputBar(), matchWrap());
root.addView(createSoftKeyBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
));
root.addView(statusText, new LinearLayout.LayoutParams( root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
dp(28) dp(28)
)); ));
terminalComposerBar = createComposerBar();
root.addView(terminalComposerBar, matchWrap());
root.addView(createAccessoryBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
terminalScroll.post(() -> {
resizeTerminalToViewport(false);
connectTerminal(sessionName); connectTerminal(sessionName);
});
inputField.post(() -> {
inputField.requestFocus();
hideKeyboard();
});
} }
private LinearLayout createTerminalTopBar(String sessionName) { private LinearLayout createTerminalTopBar(String sessionName) {
@@ -1013,10 +1152,7 @@ public final class MainActivity extends Activity {
bar.setGravity(Gravity.CENTER_VERTICAL); bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPadding(dp(8), dp(6), dp(8), dp(6)); bar.setPadding(dp(8), dp(6), dp(8), dp(6));
bar.setBackgroundColor(Color.rgb(17, 20, 24)); bar.setBackgroundColor(Color.rgb(17, 20, 24));
bar.addView(toolbarButton("Back", view -> { bar.addView(toolbarButton("Back", view -> openSessionPage()));
renderSessionScreen();
refreshSessions();
}));
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText(sessionName); title.setText(sessionName);
title.setTextColor(Color.WHITE); title.setTextColor(Color.WHITE);
@@ -1119,7 +1255,7 @@ public final class MainActivity extends Activity {
.setItems(items, (dialog, which) -> { .setItems(items, (dialog, which) -> {
switch (which) { switch (which) {
case 0: case 0:
terminalBuffer.setLength(0); terminalScreen.clear();
terminalText.setText(""); terminalText.setText("");
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.clearHistory(); terminalSocket.clearHistory();
@@ -1133,12 +1269,12 @@ public final class MainActivity extends Activity {
break; break;
case 3: case 3:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(-TERMINAL_ROWS); terminalSocket.scroll(-terminalRows);
} }
break; break;
case 4: case 4:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(TERMINAL_ROWS); terminalSocket.scroll(terminalRows);
} }
break; break;
case 5: case 5:
@@ -1169,30 +1305,47 @@ public final class MainActivity extends Activity {
.show(); .show();
} }
private LinearLayout createInputBar() { private LinearLayout createComposerBar() {
LinearLayout bar = new LinearLayout(this); LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL); bar.setOrientation(LinearLayout.VERTICAL);
bar.setGravity(Gravity.CENTER_VERTICAL); bar.setPadding(dp(8), dp(5), dp(8), dp(6));
bar.setPadding(dp(8), dp(5), dp(8), dp(5));
bar.setBackgroundColor(Color.rgb(17, 20, 24)); bar.setBackgroundColor(Color.rgb(17, 20, 24));
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.BOTTOM);
inputField = new EditText(this); inputField = new EditText(this);
inputField.setSingleLine(true);
inputField.setTextColor(Color.WHITE); inputField.setTextColor(Color.WHITE);
inputField.setHintTextColor(Color.rgb(150, 158, 168)); inputField.setHintTextColor(Color.rgb(150, 158, 168));
inputField.setHint("type command or text"); inputField.setHint("type, edit, paste");
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND); inputField.setSingleLine(false);
inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); inputField.setMinLines(1);
styleInput(inputField); inputField.setMaxLines(3);
inputField.setCursorVisible(true);
inputField.setFocusableInTouchMode(true);
inputField.setGravity(Gravity.TOP | Gravity.START);
inputField.setImeOptions(EditorInfo.IME_ACTION_NONE);
inputField.setInputType(InputType.TYPE_CLASS_TEXT
| InputType.TYPE_TEXT_FLAG_MULTI_LINE
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
styleComposerInput(inputField);
inputField.setOnEditorActionListener((view, actionId, event) -> { inputField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) { if (event != null
&& event.getKeyCode() == KeyEvent.KEYCODE_ENTER
&& event.isShiftPressed()
&& event.getAction() == KeyEvent.ACTION_UP) {
sendLine(); sendLine();
return true; return true;
} }
return false; return false;
}); });
bar.addView(inputField, new LinearLayout.LayoutParams(0, dp(42), 1)); row.addView(inputField, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
bar.addView(toolbarButton("Send", view -> sendLine())); row.addView(toolbarButton("Send", view -> sendLine()), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(48)
));
bar.addView(row, matchWrap());
return bar; return bar;
} }
@@ -1552,6 +1705,33 @@ public final class MainActivity extends Activity {
}); });
} }
private void probeSingleServer(String url) {
String normalized = normalizeServerUrl(url);
progressBar.setVisibility(View.VISIBLE);
setStatus("Probing " + normalized);
executor.execute(() -> {
try {
SessionApiClient client = new SessionApiClient(normalized);
JSONObject health = new JSONObject(client.health());
List<SessionSummary> sessions = client.getSessions();
String text = normalized + "\n"
+ "ok: true\n"
+ "version: " + health.optString("version", "unknown")
+ " " + health.optString("commit", "") + "\n"
+ "sessions: " + sessions.size();
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showTextDialog("API probe", text);
});
} catch (Exception error) {
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showMessage("Probe failed: " + error.getMessage());
});
}
});
}
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");
@@ -1637,6 +1817,11 @@ public final class MainActivity extends Activity {
showMessage(label + " done"); showMessage(label + " done");
if (PAGE_PROJECTS.equals(activeMainPage)) { if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects(); refreshProjects();
} else if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions();
if (projectList != null) {
refreshProjects();
}
} else if (activeSessionName == null) { } else if (activeSessionName == null) {
refreshSessions(); refreshSessions();
} }
@@ -1713,7 +1898,7 @@ public final class MainActivity extends Activity {
return input; return input;
} }
private HorizontalScrollView createSoftKeyBar() { private HorizontalScrollView createAccessoryBar() {
HorizontalScrollView scroller = new HorizontalScrollView(this); HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false); scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(22, 27, 34)); scroller.setBackgroundColor(Color.rgb(22, 27, 34));
@@ -1722,7 +1907,30 @@ public final class MainActivity extends Activity {
row.setOrientation(LinearLayout.HORIZONTAL); row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL); row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(6), dp(4), dp(6), dp(4)); row.setPadding(dp(6), dp(4), dp(6), dp(4));
addSoftKey(row, "Enter", "\r"); addAccessoryButton(row, "<", view -> setTerminalKeyPage(terminalKeyPage - 1));
addPageLabel(row);
addAccessoryPageKeys(row);
addAccessoryButton(row, ">", view -> setTerminalKeyPage(terminalKeyPage + 1));
scroller.addView(row, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private void addPageLabel(LinearLayout row) {
TextView label = new TextView(this);
label.setText(accessoryPageName());
label.setTextColor(Color.rgb(139, 148, 158));
label.setTextSize(11);
label.setGravity(Gravity.CENTER);
label.setTypeface(Typeface.DEFAULT_BOLD);
row.addView(label, new LinearLayout.LayoutParams(dp(58), ViewGroup.LayoutParams.MATCH_PARENT));
}
private void addAccessoryPageKeys(LinearLayout row) {
switch (terminalKeyPage) {
case 1:
addSoftKey(row, "Esc", "\u001b"); addSoftKey(row, "Esc", "\u001b");
addSoftKey(row, "Tab", "\t"); addSoftKey(row, "Tab", "\t");
addSoftKey(row, "^C", "\u0003"); addSoftKey(row, "^C", "\u0003");
@@ -1731,28 +1939,64 @@ public final class MainActivity extends Activity {
addSoftKey(row, "^R", "\u0012"); addSoftKey(row, "^R", "\u0012");
addSoftKey(row, "^A", "\u0001"); addSoftKey(row, "^A", "\u0001");
addSoftKey(row, "^E", "\u0005"); addSoftKey(row, "^E", "\u0005");
addSoftKey(row, "^V", "\u0016"); break;
addSoftKey(row, "^Z", "\u001a"); case 2:
addSoftKey(row, "^\\", "\u001c");
addSoftKey(row, "Tmux", "\u0002");
addSoftKey(row, "Detach", "\u0002d");
addSoftKey(row, "NewWin", "\u0002c");
addSoftKey(row, "NextWin", "\u0002n");
addSoftKey(row, "PrevWin", "\u0002p");
addSoftKey(row, "Left", "\u001b[D"); addSoftKey(row, "Left", "\u001b[D");
addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Up", "\u001b[A");
addSoftKey(row, "Right", "\u001b[C"); addSoftKey(row, "Right", "\u001b[C");
addSoftKey(row, "PgUp", "\u001b[5~"); addSoftKey(row, "Up", "\u001b[A");
addSoftKey(row, "PgDn", "\u001b[6~"); addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Home", "\u001b[H"); addSoftKey(row, "Home", "\u001b[H");
addSoftKey(row, "End", "\u001b[F"); addSoftKey(row, "End", "\u001b[F");
addSoftKey(row, "PgUp", "\u001b[5~");
addSoftKey(row, "PgDn", "\u001b[6~");
addSoftKey(row, "Tmux", "\u0002");
addSoftKey(row, "Detach", "\u0002d");
addSoftKey(row, "New", "\u0002c");
addSoftKey(row, "Prev", "\u0002p");
addSoftKey(row, "Next", "\u0002n");
break;
case 3:
addTextKey(row, "/", "/");
addTextKey(row, "-", "-");
addTextKey(row, "_", "_");
addTextKey(row, ".", ".");
addTextKey(row, "~", "~");
addTextKey(row, "|", "|");
addTextKey(row, "&", "&");
addTextKey(row, ";", ";");
addTextKey(row, "$", "$");
addTextKey(row, "Space", " ");
break;
case 0:
default:
addComposerButton(row, "Left", () -> moveComposerCursor(-1));
addComposerButton(row, "Right", () -> moveComposerCursor(1));
addSoftKey(row, "Up", "\u001b[A");
addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Enter", "\r");
addAccessoryButton(row, "NL", view -> insertComposerText("\n"));
addSoftButton(row, "Paste", view -> pasteClipboard()); addSoftButton(row, "Paste", view -> pasteClipboard());
scroller.addView(row, new HorizontalScrollView.LayoutParams( addAccessoryButton(row, "Back", view -> backspaceComposerText());
ViewGroup.LayoutParams.WRAP_CONTENT, addAccessoryButton(row, "Kbd", view -> showKeyboard());
ViewGroup.LayoutParams.MATCH_PARENT addAccessoryButton(row, "Hide", view -> hideKeyboard());
)); addAccessoryButton(row, "Bottom", view -> scrollTerminalBottom());
return scroller; addAccessoryButton(row, "Select", view -> toggleTerminalSelection());
break;
}
}
private String accessoryPageName() {
switch (terminalKeyPage) {
case 1:
return "CTRL";
case 2:
return "NAV";
case 3:
return "SYM";
case 0:
default:
return "EDIT";
}
} }
private void connectTerminal(String sessionName) { private void connectTerminal(String sessionName) {
@@ -1760,6 +2004,7 @@ public final class MainActivity extends Activity {
queuedTerminalInput.setLength(0); queuedTerminalInput.setLength(0);
terminalConnected = false; terminalConnected = false;
setStatus("Connecting " + sessionName); setStatus("Connecting " + sessionName);
resizeTerminalToViewport(false);
appendTerminal("[connecting]\r\n"); appendTerminal("[connecting]\r\n");
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() { terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
@Override @Override
@@ -1767,6 +2012,7 @@ public final class MainActivity extends Activity {
runOnUiThread(() -> { runOnUiThread(() -> {
terminalConnected = true; terminalConnected = true;
setStatus("Connected " + sessionName); setStatus("Connected " + sessionName);
resizeTerminalToViewport(true);
flushQueuedTerminalInput(); flushQueuedTerminalInput();
}); });
} }
@@ -1792,7 +2038,41 @@ 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) - 1, 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;
terminalScreen.resize(cols, rows);
scheduleTerminalRender();
TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) {
socket.resize(cols, rows);
}
} }
private void sendLine() { private void sendLine() {
@@ -1802,10 +2082,17 @@ public final class MainActivity extends Activity {
String text = inputField.getText().toString(); String text = inputField.getText().toString();
if (text.isEmpty()) { if (text.isEmpty()) {
sendTerminalInput("\r"); sendTerminalInput("\r");
} else { return;
sendTerminalInput(text + "\r");
inputField.setText("");
} }
String normalized = text.replace("\r\n", "\n").replace('\r', '\n').replace('\n', '\r');
if (!normalized.endsWith("\r")) {
normalized = normalized + "\r";
}
terminalFollowOutput = true;
sendTerminalInput(normalized);
inputField.setText("");
hideKeyboard();
setStatus("Sent " + text.length() + " chars");
} }
private void sendTerminalInput(String data) { private void sendTerminalInput(String data) {
@@ -1863,11 +2150,100 @@ public final class MainActivity extends Activity {
} }
} }
private void appendTerminal(String data) { private void insertComposerText(String text) {
terminalBuffer.append(data); if (inputField == null) {
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { return;
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
} }
int start = Math.max(0, inputField.getSelectionStart());
int end = Math.max(0, inputField.getSelectionEnd());
inputField.getText().replace(Math.min(start, end), Math.max(start, end), text);
inputField.requestFocus();
}
private void moveComposerCursor(int delta) {
if (inputField == null) {
return;
}
int current = Math.max(0, inputField.getSelectionEnd());
int next = clamp(current + delta, 0, inputField.getText().length());
inputField.setSelection(next);
inputField.requestFocus();
}
private void backspaceComposerText() {
if (inputField == null) {
return;
}
int start = Math.max(0, inputField.getSelectionStart());
int end = Math.max(0, inputField.getSelectionEnd());
int from = Math.min(start, end);
int to = Math.max(start, end);
if (from != to) {
inputField.getText().delete(from, to);
} else if (from > 0) {
inputField.getText().delete(from - 1, from);
}
inputField.requestFocus();
}
private void showKeyboard() {
if (inputField == null) {
return;
}
inputField.requestFocus();
InputMethodManager manager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (manager != null) {
manager.showSoftInput(inputField, InputMethodManager.SHOW_IMPLICIT);
}
}
private void hideKeyboard() {
if (inputField == null) {
return;
}
InputMethodManager manager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (manager != null) {
manager.hideSoftInputFromWindow(inputField.getWindowToken(), 0);
}
}
private void toggleTerminalSelection() {
terminalSelectionEnabled = !terminalSelectionEnabled;
if (terminalText != null) {
terminalText.setTextIsSelectable(terminalSelectionEnabled);
}
setStatus(terminalSelectionEnabled ? "Terminal selection on" : "Terminal selection off");
}
private void scrollTerminalBottom() {
terminalFollowOutput = true;
if (terminalScroll != null) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
}
setStatus("Following terminal output");
}
private void setTerminalKeyPage(int page) {
terminalKeyPage = (page + 4) % 4;
if (activeSessionName != null) {
renderTerminalControlsOnly();
}
}
private void renderTerminalControlsOnly() {
int composerIndex = terminalComposerBar == null ? -1 : root.indexOfChild(terminalComposerBar);
if (composerIndex < 0 || composerIndex + 1 >= root.getChildCount()) {
return;
}
root.removeViewAt(composerIndex + 1);
root.addView(createAccessoryBar(), composerIndex + 1, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
}
private void appendTerminal(String data) {
terminalScreen.write(data);
scheduleTerminalRender(); scheduleTerminalRender();
} }
@@ -1889,132 +2265,10 @@ public final class MainActivity extends Activity {
return; return;
} }
lastTerminalRenderMs = System.currentTimeMillis(); lastTerminalRenderMs = System.currentTimeMillis();
terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString())); terminalText.setText(terminalScreen.render());
if (terminalFollowOutput) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
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 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();
output.append(text);
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);
}
}
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() {
@@ -2123,6 +2377,23 @@ public final class MainActivity extends Activity {
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1)); input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
} }
private void styleComposerInput(EditText input) {
input.setTextColor(Color.rgb(240, 246, 252));
input.setHintTextColor(Color.rgb(139, 148, 158));
input.setTextSize(14);
input.setPadding(dp(10), dp(8), dp(10), dp(8));
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
}
private Button compactButton(String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setTextSize(11);
button.setPadding(dp(8), 0, dp(8), 0);
button.setMinWidth(dp(48));
button.setMinimumWidth(dp(48));
return button;
}
private StateListDrawable buttonBackground() { private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable(); 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_enabled}, rounded(Color.rgb(24, 30, 37), 8, Color.rgb(35, 42, 50), 1));
@@ -2146,10 +2417,24 @@ public final class MainActivity extends Activity {
addSoftButton(row, label, view -> sendTerminalInput(sequence)); addSoftButton(row, label, view -> sendTerminalInput(sequence));
} }
private void addTextKey(LinearLayout row, String label, String text) {
addSoftButton(row, label, view -> insertComposerText(text));
}
private void addAccessoryButton(LinearLayout row, String label, View.OnClickListener listener) {
addSoftButton(row, label, listener);
}
private void addComposerButton(LinearLayout row, String label, Runnable action) {
addSoftButton(row, label, view -> action.run());
}
private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) { private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener); Button button = toolbarButton(label, listener);
button.setMinWidth(dp(50)); button.setTextSize(11);
button.setMinimumWidth(dp(50)); button.setPadding(dp(8), 0, dp(8), 0);
button.setMinWidth(dp("Space".equals(label) ? 72 : 50));
button.setMinimumWidth(dp("Space".equals(label) ? 72 : 50));
row.addView(button, new LinearLayout.LayoutParams( row.addView(button, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
@@ -2261,6 +2546,10 @@ public final class MainActivity extends Activity {
return STATUS_NORMAL; 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) { private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density); return Math.round(value * getResources().getDisplayMetrics().density);
} }
@@ -2365,8 +2654,7 @@ public final class MainActivity extends Activity {
@Override @Override
public void onBackPressed() { public void onBackPressed() {
if (activeSessionName != null) { if (activeSessionName != null) {
renderSessionScreen(); openSessionPage();
refreshSessions();
return; return;
} }
super.onBackPressed(); super.onBackPressed();
@@ -0,0 +1,647 @@
package com.neatstudio.tmuxandroid;
import android.graphics.Color;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import java.util.ArrayList;
import java.util.List;
final class TerminalScreenBuffer {
private static final int DEFAULT_FG = 0xffe6ebf2;
private static final int DEFAULT_BG = Color.TRANSPARENT;
private int cols;
private int rows;
private Cell[][] cells;
private int cursorRow;
private int cursorCol;
private int savedRow;
private int savedCol;
private String pendingControl = "";
private boolean wrapPending;
private int fg = DEFAULT_FG;
private int bg = DEFAULT_BG;
private boolean bold;
TerminalScreenBuffer(int cols, int rows) {
resize(cols, rows);
}
void resize(int nextCols, int nextRows) {
nextCols = Math.max(1, nextCols);
nextRows = Math.max(1, nextRows);
Cell[][] previous = cells;
int previousRows = rows;
int previousCols = cols;
cols = nextCols;
rows = nextRows;
cells = new Cell[rows][cols];
for (int row = 0; row < rows; row++) {
for (int col = 0; col < cols; col++) {
cells[row][col] = new Cell();
}
}
if (previous != null) {
int copyRows = Math.min(previousRows, rows);
int copyCols = Math.min(previousCols, cols);
int previousStart = Math.max(0, previousRows - copyRows);
int nextStart = Math.max(0, rows - copyRows);
for (int row = 0; row < copyRows; row++) {
for (int col = 0; col < copyCols; col++) {
cells[nextStart + row][col].copyFrom(previous[previousStart + row][col]);
}
}
}
cursorRow = clamp(cursorRow, 0, rows - 1);
cursorCol = clamp(cursorCol, 0, cols - 1);
savedRow = clamp(savedRow, 0, rows - 1);
savedCol = clamp(savedCol, 0, cols - 1);
}
void clear() {
clearScreen();
cursorRow = 0;
cursorCol = 0;
savedRow = 0;
savedCol = 0;
pendingControl = "";
wrapPending = false;
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
}
void write(String text) {
if (!pendingControl.isEmpty()) {
text = pendingControl + text;
pendingControl = "";
}
int index = 0;
while (index < text.length()) {
char item = text.charAt(index);
if (item == '\u001b') {
int next = handleEscape(text, index);
if (next < 0) {
pendingControl = text.substring(index);
return;
}
index = next;
} else if (item == '\r') {
wrapPending = false;
cursorCol = 0;
index++;
} else if (item == '\n') {
wrapPending = false;
newLine();
index++;
} else if (item == '\b') {
wrapPending = false;
cursorCol = Math.max(0, cursorCol - 1);
index++;
} else if (item == '\t') {
int nextTab = ((cursorCol / 8) + 1) * 8;
while (cursorCol < Math.min(nextTab, cols)) {
putChar(' ');
}
index++;
} else if (isNakedDeviceAttributesTail(text, index)) {
index = skipNakedDeviceAttributesTail(text, index);
} else if (item >= 0x20 && item != 0x7f) {
putChar(item);
index++;
} else {
index++;
}
}
}
CharSequence render() {
SpannableStringBuilder output = new SpannableStringBuilder();
for (int row = 0; row < rows; row++) {
appendRow(output, row);
if (row + 1 < rows) {
output.append('\n');
}
}
return output;
}
private int handleEscape(String text, int index) {
if (index + 1 >= text.length()) {
return -1;
}
char next = text.charAt(index + 1);
wrapPending = false;
if (next == '[') {
int end = findAnsiEnd(text, index + 2);
if (end == -1) {
return -1;
}
applyCsi(text.substring(index + 2, end), text.charAt(end));
return end + 1;
}
if (next == ']') {
return skipStringEscape(text, index + 2);
}
if (next == 'P' || next == '^' || next == '_') {
return skipStringEscape(text, index + 2);
}
if (next == '(' || next == ')' || next == '*' || next == '+' || next == '-' || next == '.') {
if (index + 2 >= text.length()) {
return -1;
}
return Math.min(index + 3, text.length());
}
if (next == '7') {
saveCursor();
} else if (next == '8') {
restoreCursor();
} else if (next == 'D') {
newLine();
} else if (next == 'E') {
cursorCol = 0;
newLine();
} else if (next == 'M') {
reverseIndex();
} else if (next == 'c') {
clear();
}
return Math.min(index + 2, text.length());
}
private void applyCsi(String rawParams, char command) {
wrapPending = false;
String params = rawParams;
while (!params.isEmpty()) {
char first = params.charAt(0);
if (first == '?' || first == '>' || first == '!' || first == '=') {
params = params.substring(1);
} else {
break;
}
}
List<Integer> values = parseParams(params);
switch (command) {
case 'm':
applySgr(values);
break;
case 'H':
case 'f':
cursorRow = clamp(param(values, 0, 1) - 1, 0, rows - 1);
cursorCol = clamp(param(values, 1, 1) - 1, 0, cols - 1);
break;
case 'A':
cursorRow = clamp(cursorRow - param(values, 0, 1), 0, rows - 1);
break;
case 'B':
cursorRow = clamp(cursorRow + param(values, 0, 1), 0, rows - 1);
break;
case 'C':
cursorCol = clamp(cursorCol + param(values, 0, 1), 0, cols - 1);
break;
case 'D':
cursorCol = clamp(cursorCol - param(values, 0, 1), 0, cols - 1);
break;
case 'G':
cursorCol = clamp(param(values, 0, 1) - 1, 0, cols - 1);
break;
case 'd':
cursorRow = clamp(param(values, 0, 1) - 1, 0, rows - 1);
break;
case 'J':
eraseDisplay(param(values, 0, 0));
break;
case 'K':
eraseLine(param(values, 0, 0));
break;
case 'P':
deleteChars(param(values, 0, 1));
break;
case '@':
insertChars(param(values, 0, 1));
break;
case 'X':
eraseChars(param(values, 0, 1));
break;
case 'L':
insertLines(param(values, 0, 1));
break;
case 'M':
deleteLines(param(values, 0, 1));
break;
case 's':
saveCursor();
break;
case 'u':
restoreCursor();
break;
case 'c':
break;
default:
break;
}
}
private void applySgr(List<Integer> values) {
if (values.isEmpty()) {
values.add(0);
}
for (int index = 0; index < values.size(); index++) {
int value = values.get(index);
if (value == 0) {
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
} else if (value == 1) {
bold = true;
} else if (value == 22) {
bold = false;
} else if (value == 39) {
fg = DEFAULT_FG;
} else if (value == 49) {
bg = DEFAULT_BG;
} 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);
} else if ((value == 38 || value == 48) && index + 2 < values.size()) {
boolean background = value == 48;
int mode = values.get(index + 1);
if (mode == 5) {
int color = xtermColor(values.get(index + 2));
if (background) {
bg = color;
} else {
fg = color;
}
index += 2;
} else if (mode == 2 && index + 4 < values.size()) {
int color = Color.rgb(
clamp(values.get(index + 2), 0, 255),
clamp(values.get(index + 3), 0, 255),
clamp(values.get(index + 4), 0, 255)
);
if (background) {
bg = color;
} else {
fg = color;
}
index += 4;
}
}
}
}
private void putChar(char value) {
if (wrapPending) {
wrapPending = false;
newLine();
}
cells[cursorRow][cursorCol].set(value, fg, bg, bold);
if (cursorCol == cols - 1) {
wrapPending = true;
} else {
cursorCol++;
}
}
private void newLine() {
wrapPending = false;
cursorRow++;
if (cursorRow >= rows) {
scrollUp(1);
cursorRow = rows - 1;
}
cursorCol = 0;
}
private void reverseIndex() {
if (cursorRow == 0) {
scrollDown(1);
} else {
cursorRow--;
}
}
private void eraseDisplay(int mode) {
if (mode == 2 || mode == 3) {
clearScreen();
} else if (mode == 1) {
for (int row = 0; row < cursorRow; row++) {
clearLine(row, 0, cols - 1);
}
clearLine(cursorRow, 0, cursorCol);
} else {
clearLine(cursorRow, cursorCol, cols - 1);
for (int row = cursorRow + 1; row < rows; row++) {
clearLine(row, 0, cols - 1);
}
}
}
private void eraseLine(int mode) {
if (mode == 2) {
clearLine(cursorRow, 0, cols - 1);
} else if (mode == 1) {
clearLine(cursorRow, 0, cursorCol);
} else {
clearLine(cursorRow, cursorCol, cols - 1);
}
}
private void eraseChars(int count) {
int end = Math.min(cols - 1, cursorCol + Math.max(1, count) - 1);
clearLine(cursorRow, cursorCol, end);
}
private void deleteChars(int count) {
count = Math.max(1, count);
Cell[] line = cells[cursorRow];
for (int col = cursorCol; col < cols; col++) {
int source = col + count;
if (source < cols) {
line[col].copyFrom(line[source]);
} else {
line[col].clear();
}
}
}
private void insertChars(int count) {
count = Math.max(1, count);
Cell[] line = cells[cursorRow];
for (int col = cols - 1; col >= cursorCol; col--) {
int source = col - count;
if (source >= cursorCol) {
line[col].copyFrom(line[source]);
} else {
line[col].clear();
}
}
}
private void insertLines(int count) {
count = Math.min(Math.max(1, count), rows - cursorRow);
for (int row = rows - 1; row >= cursorRow + count; row--) {
copyLine(row, row - count);
}
for (int row = cursorRow; row < cursorRow + count; row++) {
clearLine(row, 0, cols - 1);
}
}
private void deleteLines(int count) {
count = Math.min(Math.max(1, count), rows - cursorRow);
for (int row = cursorRow; row + count < rows; row++) {
copyLine(row, row + count);
}
for (int row = rows - count; row < rows; row++) {
clearLine(row, 0, cols - 1);
}
}
private void clearScreen() {
for (int row = 0; row < rows; row++) {
clearLine(row, 0, cols - 1);
}
}
private void clearLine(int row, int start, int end) {
start = clamp(start, 0, cols - 1);
end = clamp(end, 0, cols - 1);
for (int col = start; col <= end; col++) {
cells[row][col].clear();
}
}
private void scrollUp(int count) {
count = Math.min(Math.max(1, count), rows);
for (int row = 0; row + count < rows; row++) {
copyLine(row, row + count);
}
for (int row = rows - count; row < rows; row++) {
clearLine(row, 0, cols - 1);
}
}
private void scrollDown(int count) {
count = Math.min(Math.max(1, count), rows);
for (int row = rows - 1; row - count >= 0; row--) {
copyLine(row, row - count);
}
for (int row = 0; row < count; row++) {
clearLine(row, 0, cols - 1);
}
}
private void copyLine(int target, int source) {
for (int col = 0; col < cols; col++) {
cells[target][col].copyFrom(cells[source][col]);
}
}
private void appendRow(SpannableStringBuilder output, int row) {
int col = 0;
while (col < cols) {
Cell first = cells[row][col];
int start = output.length();
int fgColor = first.fg;
int bgColor = first.bg;
boolean isBold = first.bold;
while (col < cols) {
Cell cell = cells[row][col];
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold) {
break;
}
output.append(cell.value);
col++;
}
int end = output.length();
output.setSpan(new ForegroundColorSpan(fgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bgColor != DEFAULT_BG) {
output.setSpan(new BackgroundColorSpan(bgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (isBold) {
output.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
private void saveCursor() {
savedRow = cursorRow;
savedCol = cursorCol;
}
private void restoreCursor() {
cursorRow = clamp(savedRow, 0, rows - 1);
cursorCol = clamp(savedCol, 0, cols - 1);
}
private int skipStringEscape(String text, int start) {
int cursor = start;
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 -1;
}
private boolean isNakedDeviceAttributesTail(String text, int index) {
int cursor = index;
boolean hasSemicolon = false;
if (cursor < text.length() && (text.charAt(cursor) == '?' || text.charAt(cursor) == '>')) {
cursor++;
}
while (cursor < text.length()) {
char item = text.charAt(cursor);
if (item >= '0' && item <= '9') {
cursor++;
continue;
}
if (item == ';') {
hasSemicolon = true;
cursor++;
continue;
}
return item == 'c' && hasSemicolon && cursor > index && cursor - index <= 16;
}
return false;
}
private int skipNakedDeviceAttributesTail(String text, int index) {
int cursor = index;
while (cursor < text.length() && text.charAt(cursor) != 'c') {
cursor++;
}
return Math.min(cursor + 1, text.length());
}
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 List<Integer> parseParams(String params) {
List<Integer> values = new ArrayList<>();
if (params.isEmpty()) {
return values;
}
String[] parts = params.split(";", -1);
for (String part : parts) {
String cleaned = part.trim();
if (cleaned.isEmpty()) {
values.add(0);
continue;
}
try {
values.add(Integer.parseInt(cleaned));
} catch (NumberFormatException ignored) {
values.add(0);
}
}
return values;
}
private int param(List<Integer> values, int index, int fallback) {
if (index >= values.size()) {
return fallback;
}
int value = values.get(index);
return value == 0 ? fallback : value;
}
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 int xtermColor(int value) {
value = clamp(value, 0, 255);
if (value < 16) {
if (value < 8) {
return ansiColor(30 + value, false);
}
return ansiColor(90 + value - 8, false);
}
if (value >= 232) {
int shade = 8 + (value - 232) * 10;
return Color.rgb(shade, shade, shade);
}
int index = value - 16;
int red = xtermComponent(index / 36);
int green = xtermComponent((index / 6) % 6);
int blue = xtermComponent(index % 6);
return Color.rgb(red, green, blue);
}
private int xtermComponent(int value) {
return value == 0 ? 0 : 55 + value * 40;
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private static final class Cell {
char value = ' ';
int fg = DEFAULT_FG;
int bg = DEFAULT_BG;
boolean bold;
void clear() {
value = ' ';
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
}
void set(char nextValue, int nextFg, int nextBg, boolean nextBold) {
value = nextValue;
fg = nextFg;
bg = nextBg;
bold = nextBold;
}
void copyFrom(Cell other) {
value = other.value;
fg = other.fg;
bg = other.bg;
bold = other.bold;
}
}
}
+2
View File
@@ -91,6 +91,8 @@ Implemented now:
to GitHub to GitHub
- one-download-per-version APK cache, SHA-256 verification, and installer - one-download-per-version APK cache, SHA-256 verification, and installer
handoff 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, - permission/about surfaces for unknown-app install status, notification status,
app settings, app version/build type, package name, selected update source, and app settings, app version/build type, package name, selected update source, and
HTTP/WebSocket API/protocol summary HTTP/WebSocket API/protocol summary