Compare commits

..
54 Commits
Author SHA1 Message Date
Codex 650431e3d1 Compact action buttons across pages
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 1m1s
2026-07-15 15:15:09 +00:00
Codex 412c69cc36 Replace native dialogs with app sheets
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-15 15:03:56 +00:00
Codex 813a74f3ef Simplify server page actions
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 1m2s
2026-07-15 14:40:01 +00:00
Codex 0d32e6105a Move secondary navigation into header menu
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 1m33s
2026-07-15 10:37:22 +00:00
Codex 0cf8de3dda Fix Gitea release asset uploads
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m1s
2026-07-15 08:36:33 +00:00
Codex 4f1b24c1c3 Bound Gitea release mirror retries
Gitea Android APK / build (push) Failing after 2s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-15 08:28:54 +00:00
Codex 5c76bc87ba Mirror signed GitHub APKs to Gitea
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-15 08:16:29 +00:00
Codex 9a574ed497 Add structured agent activity panel
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m34s
2026-07-15 07:37:39 +00:00
Codex 2d052cb832 Focus Android client on terminal workflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 10m50s
2026-07-15 07:10:09 +00:00
Codex 1e5f45dcc9 Fix terminal status and content scrolling
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 1m26s
2026-07-15 06:33:30 +00:00
Codex c9284d6222 Align Android terminal themes with server
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 1m31s
2026-07-14 16:21:04 +00:00
Codex 2824fafc32 Polish session controls and server management
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Has been cancelled
2026-07-14 16:16:42 +00:00
Codex 974abed278 Connect touch scrolling to tmux history
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 55s
2026-07-14 15:52:43 +00:00
Codex 47af792d62 Pin tmux status above soft keys
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m24s
2026-07-14 15:36:49 +00:00
Codex 0c7dba0ef1 Fix terminal composer and status layout
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m20s
2026-07-14 15:23:22 +00:00
Codex f1eb30cea0 Restore terminal history navigation
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m20s
2026-07-14 14:28:04 +00:00
Codex a25a2d16b6 Make terminal content view selective
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 54s
2026-07-14 05:22:38 +00:00
Codex a88b6cf98d Polish server and session management UI
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 53s
2026-07-13 06:58:51 +00:00
Codex 41e50322be Refine chat and terminal presentation
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 12m12s
2026-07-12 10:12:09 +00:00
Codex f1db46ecbe Replace heuristic reading with chat toggle
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m3s
2026-07-12 09:45:38 +00:00
Codex c0904e6aaa Show live terminal status in chat
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 12m9s
2026-07-12 09:03:14 +00:00
Codex 1d9492aff5 Add structured session chat view
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m24s
2026-07-12 07:24:58 +00:00
Codex 9416fa6518 Show version in terminal header
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m25s
2026-07-12 02:26:32 +00:00
Codex 360e2f3af0 Top align terminal reading mode
Gitea Android APK / build (push) Failing after 57s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-12 02:19:06 +00:00
Codex 0119c7cb0e Add expandable terminal reading summaries
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m54s
2026-07-12 02:14:25 +00:00
Codex daf2990b15 Clear stale terminal frames on resize
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m24s
2026-07-12 02:00:55 +00:00
Codex 96fe5ecb8f Improve mobile session reading and pane layout
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Has been cancelled
2026-07-12 01:59:55 +00:00
Codex f103e9e065 Render ANSI dim terminal text
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m47s
2026-07-11 08:41:33 +00:00
Codex 6a1052d437 Insert uploaded image paths
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m50s
2026-07-11 08:11:46 +00:00
Codex 7e028b09d4 Reduce terminal key overflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m17s
2026-07-11 08:01:21 +00:00
Codex f6e801defc Rearrange terminal composer controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m15s
2026-07-11 03:12:52 +00:00
Codex 90f2088265 Support mixed Gitea runners
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m12s
2026-07-10 16:18:55 +00:00
Codex 0d0f218d52 Compact terminal input controls
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 15:54:51 +00:00
Codex 01f2c66f48 Refine terminal input controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m32s
2026-07-10 15:11:12 +00:00
Codex 8fda3fa2da Align primary UI with v0 design
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m54s
2026-07-10 14:23:25 +00:00
Codex 69f40e7d36 Apply v0 tmux management UI
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 10:47:27 +00:00
Codex bc918e7664 Use full terminal text width
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m19s
2026-07-10 09:11:03 +00:00
Codex aba3930417 Fix TLS socket wrapper compilation
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 03:57:57 +00:00
Codex 4476f41aa6 Reconnect terminal websockets automatically
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-10 03:51:42 +00:00
Codex 5122683761 Compact terminal composer controls
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 02:43:11 +00:00
Codex 924835dd55 Fix terminal enter and ungrouped sessions
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 11m55s
2026-07-09 16:36:52 +00:00
Codex 9fd7b02522 Ignore local Android tool cache
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m55s
2026-07-09 10:02:29 +00:00
Codex 8e871a3ce6 Add preview APK update channel 2026-07-09 10:01:14 +00:00
Codex df29546ff9 Move terminal key pager into composer
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m57s
2026-07-09 00:19:35 +00:00
Codex bbd6fe3f5f Refresh native UI styling
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m17s
2026-07-08 23:50:48 +00:00
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
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
17 changed files with 5162 additions and 732 deletions
+212 -16
View File
@@ -1,4 +1,4 @@
name: Gitea Android Compile Check
name: Gitea Android APK
on: [push]
@@ -6,19 +6,123 @@ 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
install_packages() {
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y "$@"
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache "$@"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y "$@"
else
echo "No supported package manager found." >&2
exit 1
fi
}
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
apt-get update
apt-get install -y git curl unzip
install_packages git curl unzip
fi
REF="${GITHUB_REF:-}"
REF_NAME="${GITHUB_REF_NAME:-}"
if [ -z "${REF_NAME}" ]; then
REF_NAME="${REF##*/}"
fi
if [ "${REF#refs/tags/v}" != "${REF}" ]; then
if [ -z "${CLONE_TOKEN:-}" ]; then
echo "Gitea release publishing requires TMUX_GITEA_TOKEN." >&2
exit 1
fi
TAG="${REF_NAME}"
VERSION_NAME="${TAG#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))
GITHUB_APK_URL="https://github.com/neatstudio/tmux-browser-android/releases/download/${TAG}/tmux-android.apk"
mkdir -p release
ATTEMPT=1
while [ "${ATTEMPT}" -le 6 ]; do
if curl -fsSL "${GITHUB_APK_URL}" -o release/tmux-android.apk; then
break
fi
rm -f release/tmux-android.apk
echo "Waiting for GitHub release ${TAG} (${ATTEMPT}/6)..."
sleep 10
ATTEMPT=$((ATTEMPT + 1))
done
if [ ! -s release/tmux-android.apk ]; then
echo "GitHub release APK unavailable for ${TAG}." >&2
exit 1
fi
SHA256="$(sha256sum release/tmux-android.apk | cut -d ' ' -f1)"
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
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 for ${TAG}." >&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 "Mirrored GitHub release ${TAG} to Gitea."
echo "versionCode=${VERSION_CODE}"
echo "versionName=${VERSION_NAME}"
echo "sha256=${SHA256}"
exit 0
fi
if ! command -v java >/dev/null 2>&1; then
apt-get update
apt-get install -y openjdk-17-jdk-headless
if command -v apk >/dev/null 2>&1; then
install_packages openjdk17-jdk
elif command -v dnf >/dev/null 2>&1; then
install_packages java-17-openjdk-devel
else
install_packages openjdk-17-jdk-headless
fi
fi
java -version
@@ -26,8 +130,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 +161,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}"
+1 -1
View File
@@ -1,4 +1,5 @@
.gradle/
.ci/
build/
app/build/
local.properties
@@ -6,4 +7,3 @@ signing.properties
*.jks
*.keystore
release/
+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
+3
View File
@@ -14,6 +14,8 @@ val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultPreviewUpdateUrl = providers.gradleProperty("defaultPreviewUpdateUrl")
.orElse("https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/latest.json")
val defaultApkUrl = providers.gradleProperty("defaultApkUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/tmux-android.apk")
val defaultReleasePageUrl = providers.gradleProperty("defaultReleasePageUrl")
@@ -40,6 +42,7 @@ android {
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITHUB_UPDATE_URL", "\"${defaultGithubUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_PREVIEW_UPDATE_URL", "\"${defaultPreviewUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
}
@@ -4,6 +4,7 @@ import android.util.Base64;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
@@ -14,16 +15,22 @@ import java.util.Arrays;
import javax.net.ssl.SSLSocketFactory;
final class AppEventSocketClient {
private static final long HEARTBEAT_INTERVAL_MS = 15000L;
private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000;
private static final int SOCKET_READ_TIMEOUT_MS = 45000;
interface Listener {
void onMessage(String text);
void onClosed();
}
private final Listener listener;
private final Object writeLock = new Object();
private Socket socket;
private BufferedInputStream input;
private BufferedOutputStream output;
private volatile boolean closed;
private Thread heartbeatThread;
AppEventSocketClient(Listener listener) {
this.listener = listener;
@@ -40,12 +47,11 @@ final class AppEventSocketClient {
sendFrame(8, new byte[0]);
} catch (Exception ignored) {
}
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
closeSocketQuietly();
}
boolean isClosed() {
return closed;
}
private void run(String baseUrl) {
@@ -55,6 +61,7 @@ final class AppEventSocketClient {
input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri);
startHeartbeat();
while (!closed) {
Frame frame = readFrame();
if (frame.opcode == 1) {
@@ -69,12 +76,7 @@ final class AppEventSocketClient {
} finally {
closed = true;
listener.onClosed();
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
closeSocketQuietly();
}
}
@@ -91,10 +93,39 @@ final class AppEventSocketClient {
if (port == -1) {
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
Socket raw = new Socket();
raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS);
Socket connected;
if ("wss".equalsIgnoreCase(uri.getScheme())) {
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
connected = ((SSLSocketFactory) SSLSocketFactory.getDefault())
.createSocket(raw, uri.getHost(), port, true);
} else {
connected = raw;
}
return new Socket(uri.getHost(), port);
connected.setKeepAlive(true);
connected.setTcpNoDelay(true);
connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS);
return connected;
}
private void startHeartbeat() {
heartbeatThread = new Thread(() -> {
while (!closed) {
try {
Thread.sleep(HEARTBEAT_INTERVAL_MS);
if (!closed) {
sendFrame(9, new byte[0]);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
return;
} catch (Exception error) {
closeSocketQuietly();
return;
}
}
}, "app-events-ws-heartbeat");
heartbeatThread.start();
}
private void handshake(URI uri) throws Exception {
@@ -181,32 +212,47 @@ final class AppEventSocketClient {
}
private void sendFrame(int opcode, byte[] payload) throws Exception {
if (output == null) {
return;
}
output.write(0x80 | opcode);
byte[] mask = new byte[4];
new SecureRandom().nextBytes(mask);
int length = payload.length;
if (length < 126) {
output.write(0x80 | length);
} else if (length <= 0xffff) {
output.write(0x80 | 126);
output.write((length >>> 8) & 0xff);
output.write(length & 0xff);
} else {
output.write(0x80 | 127);
for (int i = 7; i >= 0; i--) {
output.write((length >>> (8 * i)) & 0xff);
synchronized (writeLock) {
if (output == null) {
return;
}
output.write(0x80 | opcode);
byte[] mask = new byte[4];
new SecureRandom().nextBytes(mask);
int length = payload.length;
if (length < 126) {
output.write(0x80 | length);
} else if (length <= 0xffff) {
output.write(0x80 | 126);
output.write((length >>> 8) & 0xff);
output.write(length & 0xff);
} else {
output.write(0x80 | 127);
for (int i = 7; i >= 0; i--) {
output.write((length >>> (8 * i)) & 0xff);
}
}
output.write(mask);
byte[] masked = Arrays.copyOf(payload, payload.length);
for (int i = 0; i < masked.length; i++) {
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
}
output.write(masked);
output.flush();
}
output.write(mask);
byte[] masked = Arrays.copyOf(payload, payload.length);
for (int i = 0; i < masked.length; i++) {
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
}
private void closeSocketQuietly() {
if (heartbeatThread != null) {
heartbeatThread.interrupt();
heartbeatThread = null;
}
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
output.write(masked);
output.flush();
}
private static final class Frame {
@@ -0,0 +1,85 @@
package com.neatstudio.tmuxandroid;
import org.json.JSONObject;
final class ConversationMessage {
final String messageId;
final String sessionName;
final String role;
final String contentType;
final String content;
final String status;
final String toolName;
final String parentMessageId;
final String createdAt;
final boolean local;
ConversationMessage(
String messageId,
String sessionName,
String role,
String contentType,
String content,
String status,
String toolName,
String parentMessageId,
String createdAt,
boolean local
) {
this.messageId = messageId;
this.sessionName = sessionName;
this.role = role;
this.contentType = contentType;
this.content = content;
this.status = status;
this.toolName = toolName;
this.parentMessageId = parentMessageId;
this.createdAt = createdAt;
this.local = local;
}
static ConversationMessage fromJson(JSONObject object) {
return new ConversationMessage(
value(object, "messageId", object.optString("id", "")),
object.optString("sessionName", ""),
object.optString("role", "assistant"),
object.optString("contentType", "text"),
object.optString("content", ""),
object.optString("status", "complete"),
object.optString("toolName", ""),
value(object, "parentMessageId", ""),
object.optString("createdAt", ""),
false
);
}
static ConversationMessage localUser(String sessionName, String content) {
long now = System.currentTimeMillis();
return new ConversationMessage(
"local-" + now,
sessionName,
"user",
"text",
content,
"sending",
"",
"",
"~" + now,
true
);
}
boolean isTool() {
return "tool".equals(role)
|| "tool".equals(contentType)
|| "command".equals(contentType)
|| "code".equals(contentType);
}
private static String value(JSONObject object, String key, String fallback) {
if (!object.has(key) || object.isNull(key)) {
return fallback;
}
return object.optString(key, fallback);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,901 @@
package com.neatstudio.tmuxandroid;
import android.graphics.Color;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.style.BackgroundColorSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.View;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
final class TerminalScreenBuffer {
private static final int DEFAULT_FG = 0xffe6ebf2;
private static final int DEFAULT_BG = Color.TRANSPARENT;
private static final int TERMINAL_BG = 0xff0b0e13;
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;
private boolean dim;
interface FocusToggle {
void toggle(String key);
}
TerminalScreenBuffer(int cols, int rows) {
resize(cols, rows);
}
void resize(int nextCols, int nextRows) {
nextCols = Math.max(1, nextCols);
nextRows = Math.max(1, nextRows);
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();
}
}
cursorRow = 0;
cursorCol = 0;
savedRow = 0;
savedCol = 0;
wrapPending = false;
}
void clear() {
clearScreen();
cursorRow = 0;
cursorCol = 0;
savedRow = 0;
savedCol = 0;
pendingControl = "";
wrapPending = false;
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
dim = 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() {
return renderRows(0, rows);
}
CharSequence renderBody() {
return renderRows(0, Math.max(0, rows - 1));
}
CharSequence renderStatusLine() {
return renderRows(Math.max(0, rows - 1), rows);
}
private CharSequence renderRows(int startRow, int endRow) {
SpannableStringBuilder output = new SpannableStringBuilder();
for (int row = startRow; row < endRow; row++) {
appendRow(output, row);
if (row + 1 < endRow) {
output.append('\n');
}
}
return output;
}
String renderTail(int maxRows) {
List<String> visible = new ArrayList<>();
for (int row = rows - 1; row >= 0 && visible.size() < maxRows; row--) {
String text = rowText(row).trim();
if (!text.isEmpty()) {
visible.add(0, text);
}
}
if (visible.isEmpty()) {
return "Waiting for terminal output";
}
return String.join("\n", visible);
}
CharSequence renderFocused(Set<String> expandedBlocks, FocusToggle toggle) {
SpannableStringBuilder output = new SpannableStringBuilder();
int row = 0;
while (row < rows) {
String text = rowText(row).trim();
if (!isCollapsibleHeader(text)) {
if (text.isEmpty()) {
if (output.length() > 0) {
output.append('\n');
}
} else {
appendStyledRow(output, row);
}
row++;
continue;
}
int startRow = row;
List<String> hiddenRows = new ArrayList<>();
hiddenRows.add(text);
row++;
while (row < rows) {
String next = rowText(row).trim();
if (next.isEmpty() || isTopLevelLine(next)) {
break;
}
hiddenRows.add(next);
row++;
}
appendFocusBlock(output, hiddenRows, startRow, row - 1, expandedBlocks, toggle);
}
if (output.length() == 0) {
output.append("Waiting for terminal output");
}
return output;
}
private boolean isCollapsibleHeader(String text) {
boolean activity = text.startsWith("") || text.startsWith("");
String header = stripActivityMarker(text);
return activity && (header.startsWith("Ran ")
|| header.equals("Explored")
|| header.startsWith("Explored ")
|| header.startsWith("Searched ")
|| header.startsWith("Read ")
|| header.startsWith("List ")
|| header.startsWith("Viewed ")
|| header.startsWith("Opened ")
|| header.startsWith("Fetched ")
|| header.startsWith("Downloaded ")
|| header.startsWith("Wrote ")
|| header.startsWith("Edited ")
|| header.startsWith("Applied ")
|| header.startsWith("Updated ")
|| header.startsWith("Checked ")
|| header.startsWith("Inspected ")
|| header.startsWith("Waited ")
|| header.startsWith("Working")
|| header.startsWith("Stop hook"))
|| text.startsWith("─ Worked for ");
}
private boolean isTopLevelLine(String text) {
return text.startsWith("")
|| text.startsWith("")
|| text.startsWith(" ")
|| text.startsWith("> ")
|| text.startsWith("")
|| isCollapsibleHeader(text);
}
private String stripActivityMarker(String text) {
if (text.startsWith("") || text.startsWith("")) {
return text.substring(2).trim();
}
return text;
}
private void appendFocusBlock(
SpannableStringBuilder output,
List<String> lines,
int startRow,
int endRow,
Set<String> expandedBlocks,
FocusToggle toggle
) {
String key = startRow + ":" + endRow;
boolean expanded = expandedBlocks.contains(key);
String summary = focusSummary(lines);
int actionStart = output.length();
appendLine(output, (expanded ? "" : "") + summary);
int actionEnd = output.length();
output.setSpan(new ClickableSpan() {
@Override
public void onClick(View widget) {
toggle.toggle(key);
}
@Override
public void updateDrawState(TextPaint paint) {
paint.setColor(0xff67da91);
paint.setUnderlineText(false);
paint.setFakeBoldText(true);
}
}, actionStart, actionEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (expanded) {
for (String line : lines) {
appendLine(output, " " + line);
}
}
}
private String focusSummary(List<String> lines) {
String first = stripActivityMarker(lines.get(0));
String joined = String.join(" ", lines).toLowerCase(Locale.ROOT);
String type;
if (first.startsWith("Explored") || first.startsWith("Searched")
|| first.startsWith("Read ") || first.startsWith("List ")
|| first.startsWith("Viewed ") || first.startsWith("Inspected ")) {
type = "Explored";
} else if (first.startsWith("Working") || first.startsWith("Stop hook")
|| first.startsWith("─ Worked for ")) {
type = "Status";
} else if (joined.contains("error") || joined.contains("failed") || joined.contains("exception")) {
type = "Command failed";
} else {
type = "Command output";
}
return type + " · " + lines.size() + " lines · " + compactSummary(first, 48);
}
private String compactSummary(String text, int maxChars) {
String compact = text.replaceAll("\\s+", " ").trim();
if (compact.length() <= maxChars) {
return compact;
}
return compact.substring(0, maxChars - 1) + "";
}
private void appendLine(SpannableStringBuilder output, String text) {
if (output.length() > 0) {
output.append('\n');
}
output.append(text);
}
private void appendStyledRow(SpannableStringBuilder output, int row) {
if (output.length() > 0) {
output.append('\n');
}
int limit = cols;
while (limit > 0 && cells[row][limit - 1].value == ' '
&& cells[row][limit - 1].bg == DEFAULT_BG) {
limit--;
}
appendRow(output, row, limit);
}
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;
dim = false;
} else if (value == 1) {
bold = true;
} else if (value == 2) {
dim = true;
} else if (value == 22) {
bold = false;
dim = 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();
}
int width = isWideCharacter(value) ? 2 : 1;
if (width == 2 && cursorCol == cols - 1) {
newLine();
}
cells[cursorRow][cursorCol].set(value, fg, bg, bold, dim);
if (width == 2) {
cells[cursorRow][cursorCol + 1].setContinuation(fg, bg, bold, dim);
}
if (cursorCol + width >= cols) {
cursorCol = cols - 1;
wrapPending = true;
} else {
cursorCol += width;
}
}
private boolean isWideCharacter(char value) {
return value >= '\u1100' && (value <= '\u115f'
|| value == '\u2329' || value == '\u232a'
|| (value >= '\u2e80' && value <= '\ua4cf' && value != '\u303f')
|| (value >= '\uac00' && value <= '\ud7a3')
|| (value >= '\uf900' && value <= '\ufaff')
|| (value >= '\ufe10' && value <= '\ufe19')
|| (value >= '\ufe30' && value <= '\ufe6f')
|| (value >= '\uff00' && value <= '\uff60')
|| (value >= '\uffe0' && value <= '\uffe6'));
}
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) {
appendRow(output, row, cols);
}
private void appendRow(SpannableStringBuilder output, int row, int limit) {
int col = 0;
while (col < limit) {
Cell first = cells[row][col];
int start = output.length();
int fgColor = first.fg;
int bgColor = first.bg;
boolean isBold = first.bold;
boolean isDim = first.dim;
while (col < limit) {
Cell cell = cells[row][col];
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold || cell.dim != isDim) {
break;
}
if (!cell.continuation) {
output.append(cell.value);
}
col++;
}
int end = output.length();
if (isDim) {
fgColor = blendColor(fgColor, bgColor == DEFAULT_BG ? TERMINAL_BG : bgColor, 0.55f);
}
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 String rowText(int row) {
StringBuilder text = new StringBuilder(cols);
for (int col = 0; col < cols; col++) {
Cell cell = cells[row][col];
if (!cell.continuation) {
text.append(cell.value);
}
}
return text.toString();
}
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 blendColor(int foreground, int background, float foregroundRatio) {
float backgroundRatio = 1f - foregroundRatio;
return Color.rgb(
Math.round(Color.red(foreground) * foregroundRatio + Color.red(background) * backgroundRatio),
Math.round(Color.green(foreground) * foregroundRatio + Color.green(background) * backgroundRatio),
Math.round(Color.blue(foreground) * foregroundRatio + Color.blue(background) * backgroundRatio)
);
}
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;
boolean dim;
boolean continuation;
void clear() {
value = ' ';
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
dim = false;
continuation = false;
}
void set(char nextValue, int nextFg, int nextBg, boolean nextBold, boolean nextDim) {
value = nextValue;
fg = nextFg;
bg = nextBg;
bold = nextBold;
dim = nextDim;
continuation = false;
}
void setContinuation(int nextFg, int nextBg, boolean nextBold, boolean nextDim) {
value = ' ';
fg = nextFg;
bg = nextBg;
bold = nextBold;
dim = nextDim;
continuation = true;
}
void copyFrom(Cell other) {
value = other.value;
fg = other.fg;
bg = other.bg;
bold = other.bold;
dim = other.dim;
continuation = other.continuation;
}
}
}
@@ -6,6 +6,7 @@ import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
@@ -19,6 +20,10 @@ import java.util.concurrent.RejectedExecutionException;
import javax.net.ssl.SSLSocketFactory;
final class TerminalSocketClient {
private static final long HEARTBEAT_INTERVAL_MS = 15000L;
private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000;
private static final int SOCKET_READ_TIMEOUT_MS = 45000;
interface Listener {
void onConnected();
void onOutput(String data);
@@ -35,6 +40,7 @@ final class TerminalSocketClient {
private BufferedOutputStream output;
private volatile boolean closed;
private Thread thread;
private Thread heartbeatThread;
TerminalSocketClient(Listener listener) {
this.listener = listener;
@@ -97,6 +103,7 @@ final class TerminalSocketClient {
"rows", rows
);
listener.onConnected();
startHeartbeat();
readLoop();
} catch (Exception error) {
if (!closed) {
@@ -123,10 +130,39 @@ final class TerminalSocketClient {
if (port == -1) {
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
Socket raw = new Socket();
raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS);
Socket connected;
if ("wss".equalsIgnoreCase(uri.getScheme())) {
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
connected = ((SSLSocketFactory) SSLSocketFactory.getDefault())
.createSocket(raw, uri.getHost(), port, true);
} else {
connected = raw;
}
return new Socket(uri.getHost(), port);
connected.setKeepAlive(true);
connected.setTcpNoDelay(true);
connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS);
return connected;
}
private void startHeartbeat() {
heartbeatThread = new Thread(() -> {
while (!closed) {
try {
Thread.sleep(HEARTBEAT_INTERVAL_MS);
if (!closed) {
sendFrame(9, new byte[0]);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
return;
} catch (Exception error) {
closeSocketQuietly();
return;
}
}
}, "terminal-ws-heartbeat");
heartbeatThread.start();
}
private void handshake(URI uri) throws Exception {
@@ -279,6 +315,10 @@ final class TerminalSocketClient {
}
private void closeSocketQuietly() {
if (heartbeatThread != null) {
heartbeatThread.interrupt();
heartbeatThread = null;
}
try {
if (socket != null) {
socket.close();
@@ -80,6 +80,14 @@ final class UpdateManager {
);
}
void checkPreview(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"Preview",
new String[]{BuildConfig.DEFAULT_PREVIEW_UPDATE_URL}
);
}
void checkWithFallback(boolean userInitiated) {
startUpdateCheck(
userInitiated,
+5 -4
View File
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#111418</item>
<item name="android:navigationBarColor">#111418</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:statusBarColor">#090B0D</item>
<item name="android:navigationBarColor">#090B0D</item>
<item name="android:windowBackground">#090B0D</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+41
View File
@@ -0,0 +1,41 @@
# Preview APK workflow
Use this path for fast UI testing before a formal tag/release.
The preview build is a debug APK with package id `com.neatstudio.tmuxandroid.debug`.
It can be installed next to the formal release app, so preview version codes do not
block future formal releases.
## Build locally
```bash
scripts/setup-android-local.sh
scripts/build-preview-apk.sh
```
Output:
- `release/preview/tmux-android-preview.apk`
- `release/preview/latest.json`
## Upload to Gitea preview release
```bash
scripts/upload-gitea-preview.sh
```
The script reads `TMUX_GITEA_TOKEN` or prompts for a hidden token.
Before uploading, it deletes existing assets with the same names, so the preview
release keeps only one current APK and one current manifest.
Fixed preview URLs:
- Manifest: `https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/latest.json`
- APK: `https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/tmux-android-preview.apk`
## Notes
- Preview APKs are not formal releases and should not be tagged as `v*`.
- Preview uses debug signing unless a separate debug signing setup is added.
- The installed preview app is separate from the release app because Gradle applies
`applicationIdSuffix = ".debug"` for debug builds.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="${ROOT_DIR}/.ci"
GRADLE_VERSION="${GRADLE_VERSION:-8.10.2}"
GRADLE_HOME="${CI_DIR}/gradle-${GRADLE_VERSION}"
ANDROID_HOME="${ANDROID_HOME:-${CI_DIR}/android-sdk}"
CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
BUILD_NUMBER="${BUILD_NUMBER:-$(date -u +%m%d%H%M)}"
VERSION_CODE="${VERSION_CODE:-$((900000000 + 10#${BUILD_NUMBER}))}"
VERSION_NAME="${VERSION_NAME:-preview-${BUILD_NUMBER}}"
if [ ! -x "${GRADLE_HOME}/bin/gradle" ] || [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
"${ROOT_DIR}/scripts/setup-android-local.sh"
fi
export ANDROID_HOME
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export PATH="${GRADLE_HOME}/bin:${CMDLINE_TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}"
cd "${ROOT_DIR}"
gradle :app:assembleDebug \
-PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \
-PrepoSlug="neatstudio/tmux-browser-android"
OUT_DIR="${ROOT_DIR}/release/preview"
mkdir -p "${OUT_DIR}"
APK_PATH="$(find app/build/outputs/apk/debug -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" "${OUT_DIR}/tmux-android-preview.apk"
SHA256="$(sha256sum "${OUT_DIR}/tmux-android-preview.apk" | cut -d " " -f 1)"
cat > "${OUT_DIR}/latest.json" <<JSON
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"apkUrl": "https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/tmux-android-preview.apk",
"sha256": "${SHA256}",
"releasePageUrl": "https://gitea.neatcn.com/tmux/tmux-browser-android/releases/tag/preview",
"minSdk": 26
}
JSON
ls -lh "${OUT_DIR}/tmux-android-preview.apk" "${OUT_DIR}/latest.json"
sha256sum "${OUT_DIR}/tmux-android-preview.apk"
+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}"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="${ROOT_DIR}/.ci"
GRADLE_VERSION="${GRADLE_VERSION:-8.10.2}"
ANDROID_TOOLS_ZIP="${ANDROID_TOOLS_ZIP:-commandlinetools-linux-11076708_latest.zip}"
GRADLE_HOME="${CI_DIR}/gradle-${GRADLE_VERSION}"
ANDROID_HOME="${ANDROID_HOME:-${CI_DIR}/android-sdk}"
CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
mkdir -p "${CI_DIR}"
if [ ! -x "${GRADLE_HOME}/bin/gradle" ]; then
curl -fSL --connect-timeout 20 --retry 3 --retry-delay 2 --max-time 600 \
-o "${CI_DIR}/gradle.zip" \
"https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip"
unzip -q "${CI_DIR}/gradle.zip" -d "${CI_DIR}"
fi
if [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
mkdir -p "${ANDROID_HOME}/cmdline-tools"
curl -fSL --connect-timeout 20 --retry 3 --retry-delay 2 --max-time 600 \
-o "${CI_DIR}/android-tools.zip" \
"https://dl.google.com/android/repository/${ANDROID_TOOLS_ZIP}"
unzip -q "${CI_DIR}/android-tools.zip" -d "${ANDROID_HOME}/cmdline-tools"
rm -rf "${CMDLINE_TOOLS}"
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${CMDLINE_TOOLS}"
fi
export ANDROID_HOME
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export PATH="${GRADLE_HOME}/bin:${CMDLINE_TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
echo "ANDROID_HOME=${ANDROID_HOME}"
echo "GRADLE_HOME=${GRADLE_HOME}"
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APK_PATH="${1:-${ROOT_DIR}/release/preview/tmux-android-preview.apk}"
LATEST_JSON="${2:-${ROOT_DIR}/release/preview/latest.json}"
TAG="preview"
API_ROOT="https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android"
if [ ! -f "${APK_PATH}" ]; then
echo "APK not found: ${APK_PATH}" >&2
exit 1
fi
if [ ! -f "${LATEST_JSON}" ]; then
echo "latest.json not found: ${LATEST_JSON}" >&2
exit 1
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
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "${WORK_DIR}"' EXIT
RELEASE_JSON="${WORK_DIR}/release.json"
BODY='{"tag_name":"preview","target_commitish":"main","name":"tmux Android Preview","body":"Mutable preview APK for fast UI testing. This is not a formal release.","draft":false,"prerelease":true}'
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 preview." >&2
exit 1
fi
upload_asset() {
local file="$1"
local name="$2"
local type="$3"
local assets_json old_ids old_id
assets_json="${WORK_DIR}/assets-${name}.json"
curl -fsSL -H "Authorization: token ${TOKEN}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets" \
-o "${assets_json}"
old_ids="$(python3 - "${assets_json}" "${name}" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as handle:
assets = json.load(handle)
for asset in assets:
if asset.get("name") == sys.argv[2]:
print(asset.get("id"))
PY
)"
for old_id in ${old_ids}; do
curl -fsSL -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets/${old_id}" \
-o /dev/null
done
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-preview.apk" "application/vnd.android.package-archive"
upload_asset "${LATEST_JSON}" "latest.json" "application/json"
echo "Uploaded preview release ${RELEASE_ID}"
echo "Manifest: https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/latest.json"
echo "APK: https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/tmux-android-preview.apk"