Compare commits

...

31 Commits
v0.1.4 ... main

Author SHA1 Message Date
Codex
dd056c8027 Add projects page and smooth terminal input
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m19s
2026-07-06 18:49:28 +00:00
Codex
16086a593f Show build type and update host
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m56s
2026-07-06 13:17:34 +00:00
Codex
c683f135e3 Add sessions page status panel
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 13:12:01 +00:00
Codex
1585d9ee07 Add app settings permission shortcut
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 1s
2026-07-06 13:06:56 +00:00
Codex
149d675910 Expand about protocol details
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 13:01:55 +00:00
Codex
0609c73d35 Resolve manual update links from selected source
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-06 12:56:08 +00:00
Codex
79fd148511 Make main toolbar page contextual
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-06 12:50:09 +00:00
Codex
147cec7c6b Restrict releases to version tags
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:43:11 +00:00
Codex
67968654cb Resume pending APK install
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:38:44 +00:00
Codex
fc1cf589a7 Document update release policy
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-06 12:33:04 +00:00
Codex
e2d9810b34 Add update and about pages
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:27:45 +00:00
Codex
db306679d7 Polish native controls
All checks were successful
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 11m43s
2026-07-06 09:34:39 +00:00
Codex
19837d05bc Improve terminal rendering and keyboard insets
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 08:48:42 +00:00
Codex
73cc8c8181 Document public Gitea update mirror
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m44s
2026-07-06 08:16:13 +00:00
Codex
b64ef21831 Add Tailscale API probe action
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m40s
2026-07-06 07:14:01 +00:00
Codex
e077fa5d14 Add tmux terminal shortcut controls
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m0s
2026-07-06 03:47:56 +00:00
Codex
d655fd7e09 Clarify app download and update paths
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m42s
2026-07-05 19:06:31 +00:00
Codex
4c5cd385b7 Use headless JDK in Gitea Android workflow
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m51s
2026-07-05 17:42:04 +00:00
Codex
3174b77923 Use Android SDK container for Gitea build
Some checks failed
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:38:21 +00:00
Codex
226f2e781b Use clean Gitea Android build workflow
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:37:12 +00:00
Codex
f35595706e Install JDK in Gitea build bootstrap
Some checks failed
Gitea Smoke / smoke (push) Waiting to run
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / tools (push) Failing after 0s
Gitea Android APK / lsremote (push) Successful in 3s
Gitea Android APK / secret (push) Successful in 1s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:31:51 +00:00
Codex
5d821de7f7 Add Gitea git access diagnostics
Some checks failed
Gitea Android APK / probe (push) Successful in 2s
Gitea Android APK / tools (push) Failing after 0s
Gitea Android APK / lsremote (push) Successful in 0s
Gitea Android APK / secret (push) Successful in 0s
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / checkout (push) Successful in 4s
2026-07-05 17:30:10 +00:00
Codex
eb646cc0d9 Diagnose Gitea clone secret and auth format
Some checks failed
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / secret (push) Successful in 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / probe (push) Successful in 2s
2026-07-05 17:29:11 +00:00
Codex
46b94ebdad Use Gitea clone token secret in workflow
Some checks failed
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:27:45 +00:00
Codex
72780eb98f Add Gitea workflow diagnostic jobs
Some checks failed
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:25:56 +00:00
Codex
e9615cb8fe Avoid unsupported Gitea workflow token syntax
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 4s
2026-07-05 17:24:53 +00:00
Codex
9e3e6d99cc Install Gitea workflow tools before checkout
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:23:57 +00:00
Codex
fc673a0df9 Use Gitea job token for workflow clone
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:20:51 +00:00
Codex
e144c1d3fb Fix Gitea workflow checkout directory
Some checks failed
Gitea Android APK / build (push) Failing after 2s
Gitea Smoke / smoke (push) Successful in 1s
2026-07-05 17:18:45 +00:00
Codex
90db1dc04c Make Gitea Android workflow self contained
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 3s
2026-07-05 17:17:33 +00:00
Codex
e47c8c15d4 Simplify Gitea Android workflow
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:10:54 +00:00
8 changed files with 1250 additions and 368 deletions

View File

@ -1,160 +1,67 @@
name: Android APK name: Gitea Android APK
on: on: [push]
push:
branches:
- main
tags:
- "v*"
pull_request:
workflow_dispatch:
inputs:
publish_release:
description: "Create or update a GitHub Release"
required: true
default: "false"
type: choice
options:
- "false"
- "true"
permissions:
contents: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || inputs.publish_release == 'true' }}
steps: steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set version
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION_NAME="${GITHUB_REF_NAME#v}"
else
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER}"
fi
VERSION_CODE=$((1000 + GITHUB_RUN_NUMBER))
echo "VERSION_NAME=${VERSION_NAME}" >> "${GITHUB_ENV}"
echo "VERSION_CODE=${VERSION_CODE}" >> "${GITHUB_ENV}"
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android packages
shell: bash
run: sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.10.2"
- name: Configure signing
id: signing
shell: bash
env:
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: |
if [[ -z "${ANDROID_KEYSTORE_BASE64}" ]]; then
echo "signed=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
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
echo "signed=true" >> "${GITHUB_OUTPUT}"
- name: Build APK - name: Build APK
shell: bash env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: | run: |
if [[ "${{ steps.signing.outputs.signed }}" == "true" ]]; then set -eu
BUILD_TASK=":app:assembleRelease"
else if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
BUILD_TASK=":app:assembleDebug" apt-get update
apt-get install -y git curl unzip
fi fi
gradle "${BUILD_TASK}" \ if ! command -v java >/dev/null 2>&1; then
apt-get update
apt-get install -y openjdk-17-jdk-headless
fi
java -version
WORKDIR="${PWD}/source"
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}"
cd "${WORKDIR}"
git checkout "${GITHUB_SHA:-main}"
export GRADLE_HOME="${PWD}/.ci/gradle-8.10.2"
mkdir -p .ci
if [ ! -x "${GRADLE_HOME}/bin/gradle" ]; then
curl -fsSL -o .ci/gradle.zip https://services.gradle.org/distributions/gradle-8.10.2-bin.zip
unzip -q .ci/gradle.zip -d .ci
fi
export PATH="${GRADLE_HOME}/bin:${PATH}"
export ANDROID_HOME="${PWD}/.ci/android-sdk"
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
if [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
mkdir -p "${ANDROID_HOME}/cmdline-tools"
curl -fsSL -o .ci/android-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q .ci/android-tools.zip -d "${ANDROID_HOME}/cmdline-tools"
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${CMDLINE_TOOLS}"
fi
export PATH="${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"
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER:-0}"
VERSION_CODE=$((2000 + ${GITHUB_RUN_NUMBER:-0}))
gradle :app:assembleDebug \
-PversionCode="${VERSION_CODE}" \ -PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \ -PversionName="${VERSION_NAME}" \
-PrepoSlug="${GITHUB_REPOSITORY}" -PrepoSlug="neatstudio/tmux-browser-android"
- name: Prepare artifacts
shell: bash
run: |
mkdir -p release mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)" APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" "release/tmux-android.apk" cp "${APK_PATH}" release/tmux-android-gitea.apk
cp "${APK_PATH}" "release/tmux-android-${VERSION_NAME}.apk" ls -lh release/tmux-android-gitea.apk
SHA256="$(sha256sum release/tmux-android.apk | awk '{print $1}')" sha256sum release/tmux-android-gitea.apk
APK_URL="https://github.com/${GITHUB_REPOSITORY}/releases/latest/download/tmux-android.apk"
RELEASE_PAGE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/latest"
cat > release/latest.json << JSON
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"apkUrl": "${APK_URL}",
"sha256": "${SHA256}",
"releasePageUrl": "${RELEASE_PAGE_URL}",
"minSdk": 26
}
JSON
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: tmux-android-${{ env.VERSION_NAME }}
path: |
release/tmux-android.apk
release/tmux-android-${{ env.VERSION_NAME }}.apk
release/latest.json
- name: Require signing for release publishing
if: env.PUBLISH_RELEASE == 'true' && steps.signing.outputs.signed != 'true'
shell: bash
run: |
echo "Release publishing requires ANDROID_KEYSTORE_BASE64 and signing secrets." >&2
exit 1
- name: Publish GitHub Release
if: env.PUBLISH_RELEASE == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="v${VERSION_NAME}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -f "${TAG}"
git push -f origin "${TAG}"
fi
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" release/tmux-android.apk release/latest.json --clobber
else
gh release create "${TAG}" release/tmux-android.apk release/latest.json \
--latest \
--title "tmux Android ${VERSION_NAME}" \
--notes "Android APK for tmux-ui remote testing."
fi

View File

@ -8,15 +8,6 @@ on:
- "v*" - "v*"
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs:
publish_release:
description: "Create or update a GitHub Release"
required: true
default: "false"
type: choice
options:
- "false"
- "true"
permissions: permissions:
contents: write contents: write
@ -25,7 +16,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || inputs.publish_release == 'true' }} PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps: steps:
- name: Checkout - name: Checkout
@ -139,15 +130,7 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then TAG="${GITHUB_REF_NAME}"
TAG="${GITHUB_REF_NAME}"
else
TAG="v${VERSION_NAME}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -f "${TAG}"
git push -f origin "${TAG}"
fi
if gh release view "${TAG}" >/dev/null 2>&1; then if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" release/tmux-android.apk release/latest.json --clobber gh release upload "${TAG}" release/tmux-android.apk release/latest.json --clobber
@ -157,4 +140,3 @@ jobs:
--title "tmux Android ${VERSION_NAME}" \ --title "tmux Android ${VERSION_NAME}" \
--notes "Android APK for tmux-ui remote testing." --notes "Android APK for tmux-ui remote testing."
fi fi

101
README.md
View File

@ -9,18 +9,27 @@ APIs directly:
- server URL management for Tailscale `100.x.y.z:3000` APIs - server URL management for Tailscale `100.x.y.z:3000` APIs
- quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`, - quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`,
`100.89.0.11`, and `100.89.0.116` `100.89.0.11`, and `100.89.0.116`
- one-tap probe for the preset Tailscale APIs, showing health/version and
session counts
- native tmux session list - native tmux session list
- Sessions page with current API/server and loaded session count
- native Projects page for kanban project grouping, project agents, project
messages, add/remove session, create, and delete actions
- create, rename, send command, split pane, select pane, kill pane, pin, mute, - create, rename, send command, split pane, select pane, kill pane, pin, mute,
and kill session through HTTP API and kill session through HTTP API
- open one live terminal viewer through `/ws/terminal` - open one live terminal viewer through `/ws/terminal`
- native `/ws/events` listener for session invalidation and hook notifications - native `/ws/events` listener for session invalidation and hook notifications
- GitHub and Gitea update manifest mirrors with fallback - selectable GitHub or Gitea update manifest source
- native API action center for health, server status, timeline, preferences, - native Tools page for health, server status, timeline, preferences, hook
kanban projects, group messages, hook events, image file/URL upload, image events, image file/URL upload, image preview info, and native image preview
preview info, and native image preview display display
- mobile soft-key row for tmux-oriented input - mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
- automatic update checks against a GitHub Release manifest new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- APK download, SHA-256 verification, and installer handoff - automatic update checks against the selected release manifest
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- native Update and About pages for version/build type, protocol, permission,
selected update source, and release information
## Server URL ## Server URL
@ -72,15 +81,23 @@ Create the base64 value from your release keystore:
base64 -w 0 tmux-android-release.jks base64 -w 0 tmux-android-release.jks
``` ```
Publish a test build by pushing a `v*` tag. That creates a GitHub Release with: 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:
```text ```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/tmux-android.apk
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json
``` ```
Those GitHub links are the primary public install/update channel. Gitea releases
are mirrored as a second public source. This Gitea instance does not support the
GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
Release API as the stable Gitea update entrypoint.
Plain branch builds only create Actions artifacts; they are useful for CI Plain branch builds only create Actions artifacts; they are useful for CI
verification, but releases are the stable download/update channel. verification, but releases are the stable download/update channel.
@ -93,15 +110,24 @@ incompatible package.
The terminal screen connects to `/ws/terminal` and sends the upstream protocol The terminal screen connects to `/ws/terminal` and sends the upstream protocol
messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`. messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
The first Android UI renders terminal output as basic monospace text with ANSI The first Android UI renders terminal output as monospace text with basic ANSI
escape filtering. It is enough for shell-oriented remote testing, but it is not SGR color support. The terminal view stays bottom-aligned when output is short,
yet a complete xterm-compatible renderer for full-screen TUIs such as `vim` or auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
`top`. 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 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`.
All app features are native Android controls. Complex server objects such as All app features are native Android controls. Complex server objects such as
kanban projects, preferences, timeline events, group messages, and image metadata preferences, timeline events, group messages, and image metadata currently use
currently use native forms plus native JSON detail dialogs; image preview uses a native forms plus native JSON detail dialogs; kanban projects use a native
native `ImageView`. The app does not load the browser UI. project/agent list; image preview uses a native `ImageView`. The app does not
load the browser UI.
## Permissions ## Permissions
@ -126,13 +152,44 @@ The default update manifest is:
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
``` ```
The Gitea mirror is: The public manual APK download is:
```text ```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
``` ```
The app tries the selected update source first, then falls back to the GitHub The app checks only the selected update source. It does not probe GitHub and
and Gitea mirrors. Gitea during the same update check. Choose the source in the app's `Update`
page, or use a custom manifest/API URL.
The workflow uploads both the APK and `latest.json` to each release. The built-in Gitea API endpoint is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
Gitea tag-specific assets are also public, for example:
```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/latest.json
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux-android.apk
```
In the app:
- Open the `Update` page to check `latest.json`, download the APK, verify
SHA-256, and open Android's installer.
- If the same version APK was already downloaded and its SHA-256 still matches,
the app reuses that file instead of downloading it again.
- If Android sends you to the unknown-app install permission screen, return to
the app after allowing it; the app continues installing the already downloaded
APK without another update check or download.
- Open the `Update` page and tap `APK` to resolve the APK from the selected
update source and open it in a browser.
- Open the `Update` page and tap `Details` to see the installed version,
selected update source, APK/Release resolver behavior, install permission
state, and a `Check update` button.
- Open the `Update` page and tap `App settings` for Android's full per-app
permission/settings screen.
- Open the `About` page to see the app version/build type, package name,
API/protocol summary, selected update source, and update policy.

View File

@ -12,6 +12,10 @@ val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json") .orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl") val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest") .orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultApkUrl = providers.gradleProperty("defaultApkUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/tmux-android.apk")
val defaultReleasePageUrl = providers.gradleProperty("defaultReleasePageUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest")
val signingProps = Properties() val signingProps = Properties()
val signingFile = rootProject.file("signing.properties") val signingFile = rootProject.file("signing.properties")
@ -33,6 +37,8 @@ android {
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"") buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"") buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"") buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
} }
buildFeatures { buildFeatures {

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,10 @@ final class TerminalSocketClient {
} }
} }
boolean isClosed() {
return closed;
}
private void run(String baseUrl, String sessionName, int cols, int rows) { private void run(String baseUrl, String sessionName, int cols, int rows) {
try { try {
URI uri = buildWsUri(baseUrl); URI uri = buildWsUri(baseUrl);
@ -78,7 +82,6 @@ final class TerminalSocketClient {
input = new BufferedInputStream(socket.getInputStream()); input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream()); output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri); handshake(uri);
listener.onConnected();
sendMessage( sendMessage(
"attach", "attach",
"tabId", "android-" + System.currentTimeMillis(), "tabId", "android-" + System.currentTimeMillis(),
@ -86,6 +89,7 @@ final class TerminalSocketClient {
"cols", cols, "cols", cols,
"rows", rows "rows", rows
); );
listener.onConnected();
readLoop(); readLoop();
} catch (Exception error) { } catch (Exception error) {
if (!closed) { if (!closed) {

View File

@ -20,23 +20,29 @@ import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
final class UpdateManager { final class UpdateManager {
private static final String PREF_PENDING_INSTALL_APK = "pending_install_apk";
interface Callback { interface Callback {
void onChecking(boolean checking); void onChecking(boolean checking);
void onMessage(String message); void onMessage(String message);
} }
private interface ReleaseUrlPicker {
String pick(ReleaseInfo info);
}
private final Activity activity; private final Activity activity;
private final SharedPreferences prefs; private final SharedPreferences prefs;
private final Callback callback; private final Callback callback;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private volatile boolean checkInProgress;
private volatile boolean downloadInProgress;
private File pendingInstallApk;
UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) { UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) {
this.activity = activity; this.activity = activity;
@ -45,12 +51,17 @@ final class UpdateManager {
} }
void check(boolean userInitiated) { void check(boolean userInitiated) {
List<String> manifestUrls = getUpdateManifestUrls(); if (checkInProgress) {
postMessage("Update check already running");
return;
}
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Checking update..."); postMessage("Checking update from " + hostLabel(manifestUrl) + "...");
executor.execute(() -> { executor.execute(() -> {
try { try {
ReleaseInfo info = fetchFirstReleaseInfo(manifestUrls); ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (info.versionCode <= BuildConfig.VERSION_CODE) { if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage("Already up to date: " + BuildConfig.VERSION_NAME); postMessage("Already up to date: " + BuildConfig.VERSION_NAME);
return; return;
@ -60,37 +71,52 @@ final class UpdateManager {
} catch (Exception error) { } catch (Exception error) {
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null); postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
} finally { } finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false)); activity.runOnUiThread(() -> callback.onChecking(false));
} }
}); });
} }
private List<String> getUpdateManifestUrls() { void openApkDownload() {
LinkedHashSet<String> urls = new LinkedHashSet<>(); openSelectedReleaseUrl("APK", info -> info.apkUrl);
addUrl(urls, prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL));
addUrl(urls, BuildConfig.DEFAULT_UPDATE_URL);
addUrl(urls, BuildConfig.DEFAULT_GITEA_UPDATE_URL);
return new ArrayList<>(urls);
} }
private void addUrl(LinkedHashSet<String> urls, String url) { void openReleasePage() {
if (url != null && !url.trim().isEmpty()) { openSelectedReleaseUrl("Release page", info -> info.releasePageUrl);
urls.add(url.trim()); }
private void openSelectedReleaseUrl(String label, ReleaseUrlPicker picker) {
if (checkInProgress) {
postMessage("Update check already running");
return;
} }
} String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
private ReleaseInfo fetchFirstReleaseInfo(List<String> manifestUrls) throws Exception { callback.onChecking(true);
Exception lastError = null; postMessage("Resolving " + label + " from " + hostLabel(manifestUrl) + "...");
for (String manifestUrl : manifestUrls) { executor.execute(() -> {
try { try {
postMessage("Checking " + hostLabel(manifestUrl) + "..."); ReleaseInfo info = fetchReleaseInfo(manifestUrl);
return fetchReleaseInfo(manifestUrl); String url = picker.pick(info);
if (url == null || url.trim().isEmpty()) {
throw new IllegalStateException(label + " URL is missing");
}
activity.runOnUiThread(() -> openExternalUrl(url.trim(), label));
} catch (Exception error) { } catch (Exception error) {
lastError = error; postMessage(label + " failed: " + error.getMessage());
postMessage(hostLabel(manifestUrl) + " failed"); } finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
} }
});
}
private String getUpdateManifestUrl() {
String url = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
if (url == null || url.trim().isEmpty()) {
return BuildConfig.DEFAULT_UPDATE_URL;
} }
throw lastError == null ? new IllegalStateException("No update source configured") : lastError; return url.trim();
} }
private String hostLabel(String url) { private String hostLabel(String url) {
@ -173,8 +199,13 @@ final class UpdateManager {
} }
private void downloadAndInstall(ReleaseInfo info) { private void downloadAndInstall(ReleaseInfo info) {
if (downloadInProgress) {
postMessage("Update download already running");
return;
}
downloadInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Downloading " + info.versionName + "..."); postMessage("Preparing " + info.versionName + "...");
executor.execute(() -> { executor.execute(() -> {
try { try {
File apk = downloadApk(info); File apk = downloadApk(info);
@ -189,6 +220,7 @@ final class UpdateManager {
} catch (Exception error) { } catch (Exception error) {
postMessage("Update download failed: " + error.getMessage()); postMessage("Update download failed: " + error.getMessage());
} finally { } finally {
downloadInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false)); activity.runOnUiThread(() -> callback.onChecking(false));
} }
}); });
@ -200,7 +232,12 @@ final class UpdateManager {
throw new IllegalStateException("Cannot create update cache"); throw new IllegalStateException("Cannot create update cache");
} }
File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk"); File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk");
if (isCachedApkValid(apk, info)) {
postMessage("Using downloaded APK");
return apk;
}
postMessage("Downloading " + info.versionName + "...");
HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection(); HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection();
connection.setConnectTimeout(12000); connection.setConnectTimeout(12000);
connection.setReadTimeout(60000); connection.setReadTimeout(60000);
@ -217,18 +254,32 @@ final class UpdateManager {
return apk; return apk;
} }
private boolean isCachedApkValid(File apk, ReleaseInfo info) throws Exception {
if (!apk.exists() || apk.length() <= 0) {
return false;
}
if (info.sha256.isEmpty()) {
return true;
}
return sha256(apk).equalsIgnoreCase(info.sha256);
}
private void installApk(File apk) { private void installApk(File apk) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !activity.getPackageManager().canRequestPackageInstalls()) { && !activity.getPackageManager().canRequestPackageInstalls()) {
pendingInstallApk = apk;
prefs.edit().putString(PREF_PENDING_INSTALL_APK, apk.getAbsolutePath()).apply();
Intent settingsIntent = new Intent( Intent settingsIntent = new Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + activity.getPackageName()) Uri.parse("package:" + activity.getPackageName())
); );
activity.startActivity(settingsIntent); activity.startActivity(settingsIntent);
Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show(); Toast.makeText(activity, "Allow installs, then return to continue", Toast.LENGTH_LONG).show();
postMessage("Install permission required"); postMessage("Install permission required");
return; return;
} }
pendingInstallApk = null;
prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply();
Uri apkUri = UpdateFileProvider.getUriForFile( Uri apkUri = UpdateFileProvider.getUriForFile(
activity, activity,
@ -247,6 +298,41 @@ final class UpdateManager {
} }
} }
private void openExternalUrl(String url, String label) {
try {
activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
postMessage("Opened " + label);
} catch (ActivityNotFoundException error) {
postMessage("No app can open " + label);
}
}
void resumePendingInstall() {
File apk = pendingInstallApk;
if (apk == null) {
String path = prefs.getString(PREF_PENDING_INSTALL_APK, "");
if (path != null && !path.isEmpty()) {
apk = new File(path);
pendingInstallApk = apk;
}
}
if (apk == null) {
return;
}
if (!apk.exists() || apk.length() <= 0) {
pendingInstallApk = null;
prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply();
postMessage("Downloaded APK is no longer available");
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !activity.getPackageManager().canRequestPackageInstalls()) {
return;
}
postMessage("Continuing APK install...");
installApk(apk);
}
private static byte[] readAllBytes(InputStream input) throws Exception { private static byte[] readAllBytes(InputStream input) throws Exception {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024]; byte[] buffer = new byte[16 * 1024];

View File

@ -68,27 +68,62 @@ not kill a tmux session.
Implemented now: Implemented now:
- configurable base URL, defaulting to `http://127.0.0.1:3000` - configurable base URL, defaulting to `http://100.89.0.116:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000` - support for Tailscale URLs such as `http://100.x.y.z:3000`
- native multi-page shell with `Sessions`, `Projects`, `Tools`, `Update`, and
`About`
- native session list from `GET /api/sessions` - native session list from `GET /api/sessions`
- create, rename, command send, split, pane select, pane kill, pin, mute, and - create, rename, command send, split, pane select, pane kill, pin, mute, and
kill session through documented session/preference endpoints kill session through documented session/preference endpoints
- basic live terminal through `/ws/terminal` - basic live terminal through `/ws/terminal`
- native event stream through `/ws/events` - native event stream through `/ws/events`
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, arrows, page keys, and paste - bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste
- shortcut delivery through the terminal WebSocket `input` message - shortcut delivery through the terminal WebSocket `input` message
- native action center for health, server status, timeline, preferences, kanban - native Projects page for kanban project grouping, project agents, project
projects, group messages, hook events, image file/URL upload, image preview messages, add/remove session, create, and delete actions
metadata, and native image preview display - native Tools page for health, server status, timeline, preferences, hook
events, image file/URL upload, image preview metadata, and native image
preview display
- GitHub Actions APK build - GitHub Actions APK build
- release manifest `latest.json` - release manifest `latest.json`
- APK download, SHA-256 verification, and installer handoff - selected-source update checks; GitHub and Gitea are not probed in the same
update check
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- permission/about surfaces for unknown-app install status, notification status,
app settings, app version/build type, package name, selected update source, and
HTTP/WebSocket API/protocol summary
## Update And Release Policy
The Android app cannot silently replace itself. It may download a newer APK and
open Android's package installer, but the user must approve the install. On
Android 8+, the user may also need to allow this app to install unknown apps.
The app checks exactly one update source per run: the selected manifest/API URL.
GitHub is the default public source. Gitea is available as a public mirror, but
the app does not fall back across both providers during a normal check. This
keeps update behavior predictable on mobile networks and avoids duplicate
provider checks.
Downloaded APKs are cached by `versionCode`. If a cached APK exists and its
SHA-256 matches the manifest, the app reuses it instead of downloading the same
version again. This matters when Android redirects the user to unknown-app
install settings before the installer can run. After the user grants that
permission and returns to the app, the app resumes installation of the pending
APK instead of asking the user to run update again.
Only `v*` tags publish GitHub Releases. Main branch builds and manual workflow
runs are for CI artifacts and should be used to validate grouped changes. Do not
publish a new tag for every small UI copy or layout change; publish when there
is a useful feature or test batch for phone-side validation.
## Native Roadmap ## Native Roadmap
To converge with the upstream mobile design, the next implementation should add To converge with the upstream mobile design, the next implementation should add
native Android modules in this order: native Android modules in this order:
1. richer native layouts for kanban, group messages, timeline, and preferences 1. richer native layouts for group messages, timeline, and preferences
2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows 2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows
3. configurable shortcut bar backed directly by WebSocket `input` 3. configurable shortcut bar backed directly by WebSocket `input`