Compare commits

..
5 Commits
Author SHA1 Message Date
Codex 7e87adfc8d Add terminal group switcher
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m52s
2026-07-08 23:39:17 +00:00
Codex 87729df01f Refine terminal keys and send input
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m37s
2026-07-08 23:19:48 +00:00
Codex 18ad29b501 Add Gitea release mirror script
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-08 23:11:58 +00:00
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
5 changed files with 680 additions and 138 deletions
+108 -12
View File
@@ -1,4 +1,4 @@
name: Gitea Android Compile Check
name: Gitea Android APK
on: [push]
@@ -6,9 +6,13 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Compile Android APK
- name: Build Android APK
env:
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: |
set -eu
@@ -26,8 +30,12 @@ jobs:
rm -rf "${WORKDIR}"
SERVER_URL="${GITHUB_SERVER_URL:-https://gitea.neatcn.com}"
REPOSITORY="${GITHUB_REPOSITORY:-tmux/tmux-browser-android}"
AUTH_SERVER_URL="$(printf '%s' "${SERVER_URL}" | sed "s#https://#https://gouki:${CLONE_TOKEN}@#")"
git clone "${AUTH_SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
if [ -n "${CLONE_TOKEN:-}" ]; then
AUTH_SERVER_URL="$(printf '%s' "${SERVER_URL}" | sed "s#https://#https://gouki:${CLONE_TOKEN}@#")"
git clone "${AUTH_SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
else
git clone "${SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
fi
cd "${WORKDIR}"
git checkout "${GITHUB_SHA:-main}"
@@ -53,17 +61,105 @@ jobs:
yes | sdkmanager --licenses >/dev/null || true
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER:-0}"
VERSION_CODE=$((2000 + ${GITHUB_RUN_NUMBER:-0}))
gradle :app:assembleDebug \
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_CODE=$((3000 + ${GITHUB_RUN_NUMBER:-0}))
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}" \
-PversionName="${VERSION_NAME}" \
-PrepoSlug="neatstudio/tmux-browser-android"
mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
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."
cp "${APK_PATH}" release/tmux-android.apk
cp "${APK_PATH}" "release/tmux-android-${VERSION_NAME}.apk"
SHA256="$(sha256sum release/tmux-android.apk | awk '{print $1}')"
TAG="v${VERSION_NAME}"
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:
push:
branches:
- main
tags:
- "v*"
pull_request:
@@ -27,10 +25,12 @@ jobs:
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION_NAME="${GITHUB_REF_NAME#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION_NAME}"
VERSION_CODE=$((MAJOR * 1000000 + MINOR * 1000 + PATCH))
else
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER}"
VERSION_CODE=$((1000 + GITHUB_RUN_NUMBER))
fi
VERSION_CODE=$((1000 + GITHUB_RUN_NUMBER))
echo "VERSION_NAME=${VERSION_NAME}" >> "${GITHUB_ENV}"
echo "VERSION_CODE=${VERSION_CODE}" >> "${GITHUB_ENV}"
+27 -28
View File
@@ -88,31 +88,32 @@ base64 -w 0 tmux-android-release.jks
Branch builds and manual workflow runs create Actions artifacts only. Use them
to verify grouped changes before publishing.
Publish a release build by pushing a `v*` tag. A tag should be reserved for a
coherent feature/test batch, not every small UI or text change. Tag publishing
creates a GitHub Release with:
Gitea is the primary build and update channel. Normal `main` pushes run the
Gitea Android workflow for compile checks, while GitHub no longer builds every
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
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
```
Gitea is the app's default install/update channel because phones may not be able
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.
Gitea is also the app's default install/update channel because phones may not be
able 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.
Release APKs on GitHub and Gitea should either be the exact same file or be
built from the same tag with the same signing keystore, `versionCode`, and
`versionName`. The Gitea workflow publishes release assets only when signing
secrets are present. Unsigned Gitea builds remain compile checks and must not be
used for automatic in-place updates.
Plain branch builds only create Actions artifacts; they are useful for CI
verification, but releases are the stable download/update channel.
Plain branch builds are useful for CI verification, but releases are the stable
download/update channel.
Unsigned/debug workflow artifacts are useful only for smoke testing install and
launch. Automatic in-place updates require release APKs signed with the same
@@ -123,18 +124,16 @@ 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 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. Rendering is throttled and the local terminal buffer is capped so opening
busy sessions does not block the UI thread. Input typed before the terminal
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 Android UI renders terminal output through a lightweight screen buffer with
cursor movement, line clearing, screen clearing, basic ANSI SGR color support,
and throttled redraws. 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`,
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`.
The terminal input area is a native multi-line composer with a paged accessory
bar for editing keys, control keys, tmux prefix helpers, navigation keys, and
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
preferences, timeline events, group messages, and image metadata currently use
@@ -59,6 +59,7 @@ public final class MainActivity extends Activity {
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 TERMINAL_KEYS_HEIGHT_DP = 76;
private static final int STATUS_NORMAL = 0;
private static final int STATUS_BUSY = 1;
private static final int STATUS_SUCCESS = 2;
@@ -67,11 +68,13 @@ 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_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 PAGE_SERVERS = "Servers";
private static final String PAGE_SESSIONS = "Sessions";
private static final String PAGE_PROJECTS = "Projects";
private static final String PAGE_TOOLS = "Tools";
private static final String PAGE_UPDATE = "Update";
private static final String PAGE_ABOUT = "About";
private static final String TERMINAL_ENTER = "\n";
private static final String[] SERVER_PROFILES = {
"http://100.89.0.116:3000",
"http://100.89.0.2:3000",
@@ -96,8 +99,9 @@ public final class MainActivity extends Activity {
private ScrollView terminalScroll;
private EditText inputField;
private View terminalComposerBar;
private LinearLayout terminalGroupRow;
private String activeSessionName;
private String activeMainPage = PAGE_SESSIONS;
private String activeMainPage = PAGE_SERVERS;
private String pendingImageUploadSession;
private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
private final StringBuilder queuedTerminalInput = new StringBuilder();
@@ -133,7 +137,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);
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
api = new SessionApiClient(getServerUrl());
setContentView(createRoot());
updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() {
@@ -147,8 +151,7 @@ public final class MainActivity extends Activity {
showMessage(message);
}
});
renderSessionScreen();
refreshSessions();
renderServerScreen();
connectAppEvents();
maybeCheckForUpdates();
}
@@ -177,7 +180,9 @@ public final class MainActivity extends Activity {
view.setOnApplyWindowInsetsListener((target, insets) -> {
int bottom = insets.getSystemWindowInsetBottom();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
bottom = insets.getInsets(WindowInsets.Type.systemBars()).bottom;
int systemBottom = insets.getInsets(WindowInsets.Type.systemBars()).bottom;
int imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
bottom = Math.max(systemBottom, imeBottom);
}
target.setPadding(
0,
@@ -191,16 +196,94 @@ public final class MainActivity extends Activity {
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() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_SESSIONS;
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(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
@@ -213,11 +296,23 @@ public final class MainActivity extends Activity {
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
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"));
LinearLayout list = new LinearLayout(this);
list.setOrientation(LinearLayout.VERTICAL);
list.setTag("session-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);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -228,6 +323,13 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("Sessions");
}
private void openSessionPage() {
renderSessionScreen();
refreshSessions();
refreshProjects();
}
private View sessionSummaryBlock() {
@@ -243,6 +345,7 @@ public final class MainActivity extends Activity {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_TOOLS;
projectList = null;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_TOOLS), new LinearLayout.LayoutParams(
@@ -359,6 +462,7 @@ public final class MainActivity extends Activity {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_UPDATE;
projectList = null;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_UPDATE), new LinearLayout.LayoutParams(
@@ -412,6 +516,7 @@ public final class MainActivity extends Activity {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_ABOUT;
projectList = null;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_ABOUT), new LinearLayout.LayoutParams(
@@ -505,6 +610,11 @@ public final class MainActivity extends Activity {
}
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)) {
actionRow.addView(toolbarButton("New", view -> promptCreateSession()));
actionRow.addView(toolbarButton("Refresh", view -> refreshSessions()));
@@ -573,10 +683,8 @@ public final class MainActivity extends Activity {
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(8), dp(4), dp(8), dp(4));
row.addView(navButton(PAGE_SESSIONS, selected, view -> {
renderSessionScreen();
refreshSessions();
}));
row.addView(navButton(PAGE_SERVERS, selected, view -> renderServerScreen()));
row.addView(navButton(PAGE_SESSIONS, selected, view -> openSessionPage()));
row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen()));
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
@@ -694,7 +802,9 @@ public final class MainActivity extends Activity {
private void selectServer(String url) {
prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url);
urlField.setText(url);
if (urlField != null) {
urlField.setText(url);
}
setStatus("Selected " + url);
connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) {
@@ -704,6 +814,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() {
setStatus("Loading sessions from " + api.getBaseUrl());
progressBar.setVisibility(View.VISIBLE);
@@ -970,7 +1088,9 @@ public final class MainActivity extends Activity {
}
private void openTerminal(String sessionName) {
closeTerminalSocket();
activeSessionName = sessionName;
projectList = null;
terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
queuedTerminalInput.setLength(0);
terminalConnected = false;
@@ -983,6 +1103,10 @@ public final class MainActivity extends Activity {
terminalRows = DEFAULT_TERMINAL_ROWS;
root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap());
root.addView(createTerminalGroupBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(38)
));
terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true);
@@ -1018,11 +1142,12 @@ public final class MainActivity extends Activity {
root.addView(terminalComposerBar, matchWrap());
root.addView(createAccessoryBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
dp(TERMINAL_KEYS_HEIGHT_DP)
));
terminalScroll.post(() -> {
resizeTerminalToViewport(false);
connectTerminal(sessionName);
refreshTerminalGroupSessions(sessionName);
});
inputField.post(() -> {
inputField.requestFocus();
@@ -1034,24 +1159,136 @@ public final class MainActivity extends Activity {
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPadding(dp(8), dp(6), dp(8), dp(6));
bar.setPadding(dp(5), dp(5), dp(5), dp(5));
bar.setBackgroundColor(Color.rgb(17, 20, 24));
bar.addView(toolbarButton("Back", view -> {
renderSessionScreen();
refreshSessions();
}));
bar.addView(terminalToolButton("", view -> openSessionPage()));
TextView title = new TextView(this);
title.setText(sessionName);
title.setTextColor(Color.WHITE);
title.setTextSize(16);
title.setTextSize(15);
title.setTypeface(Typeface.DEFAULT_BOLD);
title.setPadding(dp(10), 0, dp(10), 0);
bar.addView(title, new LinearLayout.LayoutParams(0, dp(42), 1));
bar.addView(toolbarButton("Reconnect", view -> connectTerminal(sessionName)));
bar.addView(toolbarButton("More", view -> showTerminalActions(sessionName)));
title.setSingleLine(true);
title.setPadding(dp(8), 0, dp(8), 0);
bar.addView(title, new LinearLayout.LayoutParams(0, dp(36), 1));
bar.addView(terminalToolButton("", view -> connectTerminal(sessionName)));
bar.addView(terminalToolButton("", view -> showTerminalActions(sessionName)));
return bar;
}
private HorizontalScrollView createTerminalGroupBar() {
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(13, 17, 22));
terminalGroupRow = new LinearLayout(this);
terminalGroupRow.setOrientation(LinearLayout.HORIZONTAL);
terminalGroupRow.setGravity(Gravity.CENTER_VERTICAL);
terminalGroupRow.setPadding(dp(5), dp(3), dp(5), dp(3));
terminalGroupRow.addView(groupLabel("group..."));
scroller.addView(terminalGroupRow, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private void refreshTerminalGroupSessions(String sessionName) {
executor.execute(() -> {
try {
String text = api.kanbanProjects();
runOnUiThread(() -> renderTerminalGroupSessions(sessionName, text));
} catch (Exception error) {
runOnUiThread(() -> {
if (sessionName.equals(activeSessionName) && terminalGroupRow != null) {
terminalGroupRow.removeAllViews();
terminalGroupRow.addView(groupLabel("group failed"));
}
});
}
});
}
private void renderTerminalGroupSessions(String sessionName, String text) {
if (!sessionName.equals(activeSessionName) || terminalGroupRow == null) {
return;
}
terminalGroupRow.removeAllViews();
try {
JSONObject rootObject = new JSONObject(text == null || text.isEmpty() ? "{}" : text);
JSONArray projects = rootObject.optJSONArray("projects");
if (projects == null) {
terminalGroupRow.addView(groupLabel("no group"));
return;
}
for (int projectIndex = 0; projectIndex < projects.length(); projectIndex++) {
JSONObject project = projects.optJSONObject(projectIndex);
if (project == null) {
continue;
}
JSONArray agents = project.optJSONArray("agents");
if (!projectContainsSession(agents, sessionName)) {
continue;
}
terminalGroupRow.addView(groupLabel(compactLabel(project.optString("name", "group"), 14)));
for (int agentIndex = 0; agents != null && agentIndex < agents.length(); agentIndex++) {
JSONObject agent = agents.optJSONObject(agentIndex);
String agentSession = agentSessionName(agent);
if (!agentSession.isEmpty()) {
terminalGroupRow.addView(groupSessionButton(agentSession, agentSession.equals(sessionName)));
}
}
return;
}
terminalGroupRow.addView(groupLabel("no group"));
} catch (Exception error) {
terminalGroupRow.addView(groupLabel("group parse"));
}
}
private boolean projectContainsSession(JSONArray agents, String sessionName) {
if (agents == null) {
return false;
}
for (int index = 0; index < agents.length(); index++) {
if (sessionName.equals(agentSessionName(agents.optJSONObject(index)))) {
return true;
}
}
return false;
}
private String agentSessionName(JSONObject agent) {
if (agent == null) {
return "";
}
return defaultValue(agent.optString("sessionName", ""), agent.optString("name", ""));
}
private TextView groupLabel(String text) {
TextView label = new TextView(this);
label.setText(text);
label.setTextColor(Color.rgb(139, 148, 158));
label.setTextSize(11);
label.setTypeface(Typeface.DEFAULT_BOLD);
label.setGravity(Gravity.CENTER_VERTICAL);
label.setPadding(dp(5), 0, dp(6), 0);
return label;
}
private Button groupSessionButton(String sessionName, boolean active) {
Button button = terminalToolButton(compactLabel(sessionName, 12), view -> {
if (!sessionName.equals(activeSessionName)) {
openTerminal(sessionName);
}
});
if (active) {
button.setTextColor(Color.rgb(8, 12, 18));
button.setBackground(rounded(Color.rgb(86, 211, 219), 7, Color.rgb(86, 211, 219), 1));
}
return button;
}
private void showSessionActions(String sessionName) {
String[] items = {
"Status",
@@ -1212,12 +1449,18 @@ public final class MainActivity extends Activity {
inputField.setCursorVisible(true);
inputField.setFocusableInTouchMode(true);
inputField.setGravity(Gravity.TOP | Gravity.START);
inputField.setImeOptions(EditorInfo.IME_ACTION_NONE);
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND | EditorInfo.IME_FLAG_NO_EXTRACT_UI);
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) -> {
if (actionId == EditorInfo.IME_ACTION_SEND
|| actionId == EditorInfo.IME_ACTION_GO
|| actionId == EditorInfo.IME_ACTION_DONE) {
sendLine();
return true;
}
if (event != null
&& event.getKeyCode() == KeyEvent.KEYCODE_ENTER
&& event.isShiftPressed()
@@ -1592,6 +1835,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() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
showMessage("Install permission is allowed on this Android version");
@@ -1677,6 +1947,11 @@ public final class MainActivity extends Activity {
showMessage(label + " done");
if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects();
} else if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions();
if (projectList != null) {
refreshProjects();
}
} else if (activeSessionName == null) {
refreshSessions();
}
@@ -1758,21 +2033,41 @@ public final class MainActivity extends Activity {
scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(22, 27, 34));
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(6), dp(4), dp(6), dp(4));
addAccessoryButton(row, "<", view -> setTerminalKeyPage(terminalKeyPage - 1));
addPageLabel(row);
addAccessoryPageKeys(row);
addAccessoryButton(row, ">", view -> setTerminalKeyPage(terminalKeyPage + 1));
scroller.addView(row, new HorizontalScrollView.LayoutParams(
LinearLayout pad = new LinearLayout(this);
pad.setOrientation(LinearLayout.VERTICAL);
pad.setPadding(dp(5), dp(4), dp(5), dp(4));
LinearLayout firstRow = terminalKeyRow();
LinearLayout secondRow = terminalKeyRow();
addAccessoryButton(firstRow, "<", view -> setTerminalKeyPage(terminalKeyPage - 1));
addPageLabel(firstRow);
addAccessoryPageKeys(firstRow, secondRow);
addAccessoryButton(firstRow, ">", view -> setTerminalKeyPage(terminalKeyPage + 1));
pad.addView(firstRow, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
0,
1
));
pad.addView(secondRow, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
0,
1
));
scroller.addView(pad, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private LinearLayout terminalKeyRow() {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
return row;
}
private void addPageLabel(LinearLayout row) {
TextView label = new TextView(this);
label.setText(accessoryPageName());
@@ -1780,62 +2075,64 @@ public final class MainActivity extends Activity {
label.setTextSize(11);
label.setGravity(Gravity.CENTER);
label.setTypeface(Typeface.DEFAULT_BOLD);
row.addView(label, new LinearLayout.LayoutParams(dp(58), ViewGroup.LayoutParams.MATCH_PARENT));
row.addView(label, new LinearLayout.LayoutParams(dp(44), ViewGroup.LayoutParams.MATCH_PARENT));
}
private void addAccessoryPageKeys(LinearLayout row) {
private void addAccessoryPageKeys(LinearLayout topRow, LinearLayout bottomRow) {
switch (terminalKeyPage) {
case 1:
addSoftKey(row, "Esc", "\u001b");
addSoftKey(row, "Tab", "\t");
addSoftKey(row, "^C", "\u0003");
addSoftKey(row, "^D", "\u0004");
addSoftKey(row, "^L", "\u000c");
addSoftKey(row, "^R", "\u0012");
addSoftKey(row, "^A", "\u0001");
addSoftKey(row, "^E", "\u0005");
addSoftKey(topRow, "Esc", "\u001b");
addSoftKey(topRow, "Tab", "\t");
addSoftKey(topRow, "^C", "\u0003");
addSoftKey(topRow, "^D", "\u0004");
addSoftKey(topRow, "^L", "\u000c");
addSoftKey(bottomRow, "^R", "\u0012");
addSoftKey(bottomRow, "^A", "\u0001");
addSoftKey(bottomRow, "^E", "\u0005");
addSoftKey(bottomRow, "^U", "\u0015");
addSoftKey(bottomRow, "^K", "\u000b");
break;
case 2:
addSoftKey(row, "Left", "\u001b[D");
addSoftKey(row, "Right", "\u001b[C");
addSoftKey(row, "Up", "\u001b[A");
addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Home", "\u001b[H");
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");
addSoftKey(topRow, "", "\u001b[D");
addSoftKey(topRow, "", "\u001b[C");
addSoftKey(topRow, "", "\u001b[A");
addSoftKey(topRow, "", "\u001b[B");
addSoftKey(topRow, "Hm", "\u001b[H");
addSoftKey(bottomRow, "End", "\u001b[F");
addSoftKey(bottomRow, "Pg↑", "\u001b[5~");
addSoftKey(bottomRow, "Pg↓", "\u001b[6~");
addSoftKey(bottomRow, "^B", "\u0002");
addSoftKey(bottomRow, "Dch", "\u0002d");
addSoftKey(bottomRow, "New", "\u0002c");
addSoftKey(bottomRow, "Prev", "\u0002p");
addSoftKey(bottomRow, "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", " ");
addTextKey(topRow, "/", "/");
addTextKey(topRow, "-", "-");
addTextKey(topRow, "_", "_");
addTextKey(topRow, ".", ".");
addTextKey(topRow, "~", "~");
addTextKey(bottomRow, "|", "|");
addTextKey(bottomRow, "&", "&");
addTextKey(bottomRow, ";", ";");
addTextKey(bottomRow, "$", "$");
addTextKey(bottomRow, "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());
addAccessoryButton(row, "Back", view -> backspaceComposerText());
addAccessoryButton(row, "Kbd", view -> showKeyboard());
addAccessoryButton(row, "Hide", view -> hideKeyboard());
addAccessoryButton(row, "Bottom", view -> scrollTerminalBottom());
addAccessoryButton(row, "Select", view -> toggleTerminalSelection());
addComposerButton(topRow, "", () -> moveComposerCursor(-1));
addComposerButton(topRow, "", () -> moveComposerCursor(1));
addSoftKey(topRow, "", "\u001b[A");
addSoftKey(topRow, "", "\u001b[B");
addSoftKey(topRow, "", TERMINAL_ENTER);
addAccessoryButton(topRow, "NL", view -> insertComposerText("\n"));
addSoftButton(bottomRow, "Pst", view -> pasteClipboard());
addAccessoryButton(bottomRow, "", view -> backspaceComposerText());
addAccessoryButton(bottomRow, "", view -> showKeyboard());
addAccessoryButton(bottomRow, "", view -> hideKeyboard());
addAccessoryButton(bottomRow, "", view -> scrollTerminalBottom());
addAccessoryButton(bottomRow, "Sel", view -> toggleTerminalSelection());
break;
}
}
@@ -1936,17 +2233,16 @@ public final class MainActivity extends Activity {
}
String text = inputField.getText().toString();
if (text.isEmpty()) {
sendTerminalInput("\r");
sendTerminalInput(TERMINAL_ENTER);
return;
}
String normalized = text.replace("\r\n", "\n").replace('\r', '\n').replace('\n', '\r');
if (!normalized.endsWith("\r")) {
normalized = normalized + "\r";
String normalized = text.replace("\r\n", "\n").replace('\r', '\n');
if (!normalized.endsWith(TERMINAL_ENTER)) {
normalized = normalized + TERMINAL_ENTER;
}
terminalFollowOutput = true;
sendTerminalInput(normalized);
inputField.setText("");
hideKeyboard();
setStatus("Sent " + text.length() + " chars");
}
@@ -2093,7 +2389,7 @@ public final class MainActivity extends Activity {
root.removeViewAt(composerIndex + 1);
root.addView(createAccessoryBar(), composerIndex + 1, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
dp(TERMINAL_KEYS_HEIGHT_DP)
));
}
@@ -2223,6 +2519,22 @@ public final class MainActivity extends Activity {
return button;
}
private Button terminalToolButton(String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setTextSize(label.length() <= 2 ? 16 : 10);
button.setPadding(dp(5), 0, dp(5), 0);
button.setMinWidth(dp(label.length() <= 2 ? 36 : 48));
button.setMinimumWidth(dp(label.length() <= 2 ? 36 : 48));
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(30)
);
params.leftMargin = dp(2);
params.rightMargin = dp(2);
button.setLayoutParams(params);
return button;
}
private void styleInput(EditText input) {
input.setTextColor(Color.rgb(240, 246, 252));
input.setHintTextColor(Color.rgb(139, 148, 158));
@@ -2286,14 +2598,24 @@ public final class MainActivity extends Activity {
private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setTextSize(11);
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(
button.setTextSize(isArrowLabel(label) ? 17 : 10);
button.setPadding(dp(5), 0, dp(5), 0);
int width = "Space".equals(label) ? 64 : (isArrowLabel(label) ? 38 : 44);
button.setMinWidth(dp(width));
button.setMinimumWidth(dp(width));
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
);
params.leftMargin = dp(2);
params.rightMargin = dp(2);
params.topMargin = dp(1);
params.bottomMargin = dp(1);
row.addView(button, params);
}
private boolean isArrowLabel(String label) {
return "".equals(label) || "".equals(label) || "".equals(label) || "".equals(label);
}
private TextView bodyText(String text) {
@@ -2312,6 +2634,17 @@ public final class MainActivity extends Activity {
return value == null || value.isEmpty() ? fallback : value;
}
private String compactLabel(String value, int maxChars) {
if (value == null) {
return "";
}
String trimmed = value.trim();
if (trimmed.length() <= maxChars) {
return trimmed;
}
return trimmed.substring(0, Math.max(1, maxChars - 1)) + "";
}
private LinearLayout.LayoutParams matchWrap() {
return new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -2509,8 +2842,7 @@ public final class MainActivity extends Activity {
@Override
public void onBackPressed() {
if (activeSessionName != null) {
renderSessionScreen();
refreshSessions();
openSessionPage();
return;
}
super.onBackPressed();
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage: scripts/mirror-gitea-release.sh TAG APK_PATH [VERSION_CODE] [VERSION_NAME]
Mirrors an already-built APK to the Gitea release for TAG and uploads a
Gitea-specific latest.json.
Token source:
TMUX_GITEA_TOKEN environment variable, or hidden stdin prompt.
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then
usage
exit 2
fi
TAG="$1"
APK_PATH="$2"
VERSION_NAME="${4:-${TAG#v}}"
if [ ! -f "$APK_PATH" ]; then
echo "APK not found: $APK_PATH" >&2
exit 1
fi
if [ -n "${3:-}" ]; then
VERSION_CODE="$3"
else
IFS=. read -r MAJOR MINOR PATCH <<EOF
$VERSION_NAME
EOF
VERSION_CODE=$((MAJOR * 1000000 + MINOR * 1000 + PATCH))
fi
TOKEN="${TMUX_GITEA_TOKEN:-}"
if [ -z "$TOKEN" ]; then
printf "Gitea token: " >&2
IFS= read -rs TOKEN
printf "\n" >&2
fi
if [ -z "$TOKEN" ]; then
echo "Missing Gitea token." >&2
exit 1
fi
API_ROOT="https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android"
RELEASE_PAGE_URL="https://gitea.neatcn.com/tmux/tmux-browser-android/releases/tag/${TAG}"
APK_URL="https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/${TAG}/tmux-android.apk"
SHA256="$(sha256sum "$APK_PATH" | cut -d " " -f 1)"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
LATEST_JSON="${WORK_DIR}/latest.json"
cat > "$LATEST_JSON" <<JSON
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"apkUrl": "${APK_URL}",
"sha256": "${SHA256}",
"releasePageUrl": "${RELEASE_PAGE_URL}",
"minSdk": 26
}
JSON
RELEASE_JSON="${WORK_DIR}/release.json"
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 ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${API_ROOT}/releases" \
-o "$RELEASE_JSON"; then
curl -fsSL \
-H "Authorization: token ${TOKEN}" \
"${API_ROOT}/releases/tags/${TAG}" \
-o "$RELEASE_JSON"
fi
RELEASE_ID="$(sed -n 's/^{"id":\([0-9][0-9]*\),.*/\1/p' "$RELEASE_JSON" | head -1)"
if [ -z "$RELEASE_ID" ]; then
echo "Cannot resolve Gitea release id for ${TAG}." >&2
exit 1
fi
upload_asset() {
local file="$1"
local name="$2"
local type="$3"
curl -fsSL \
-X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${file};type=${type}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets?name=${name}" \
-o "${WORK_DIR}/${name}.asset.json"
}
upload_asset "$APK_PATH" "tmux-android.apk" "application/vnd.android.package-archive"
upload_asset "$LATEST_JSON" "latest.json" "application/json"
echo "Mirrored ${TAG} to Gitea release ${RELEASE_ID}"
echo "versionCode=${VERSION_CODE}"
echo "versionName=${VERSION_NAME}"
echo "sha256=${SHA256}"