Compare commits

...
21 Commits
Author SHA1 Message Date
Codex c4713dccbd Handle terminal control sequences
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 12m18s
2026-07-07 10:55:06 +00:00
Codex 981e94efc3 Resize terminal to phone viewport
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 11m58s
2026-07-07 04:39:08 +00:00
Codex 434cb67372 Add interactive UI feedback
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 11m58s
2026-07-07 04:11:56 +00:00
Codex 10e8139a32 Document mirrored release APK policy
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Has been cancelled
2026-07-07 04:00:00 +00:00
Codex f9e465bed5 Add update source fallback checks
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m57s
2026-07-07 03:45:46 +00:00
Codex 7b13d42930 Move terminal writes off main thread
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m50s
2026-07-07 03:12:28 +00:00
Codex 72913c5571 Default updates to Gitea
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m48s
2026-07-07 01:37:18 +00:00
Codex dd056c8027 Add projects page and smooth terminal input
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
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
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
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
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
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
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
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
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
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
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
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
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
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m44s
2026-07-06 08:16:13 +00:00
8 changed files with 1541 additions and 310 deletions
+7 -5
View File
@@ -1,4 +1,4 @@
name: Gitea Android APK name: Gitea Android Compile Check
on: [push] on: [push]
@@ -6,7 +6,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Build APK - name: Compile Android APK
env: env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }} CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: | run: |
@@ -62,6 +62,8 @@ jobs:
mkdir -p release mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)" APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" release/tmux-android-gitea.apk cp "${APK_PATH}" release/tmux-android-gitea-compile-check.apk
ls -lh release/tmux-android-gitea.apk ls -lh release/tmux-android-gitea-compile-check.apk
sha256sum release/tmux-android-gitea.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."
+2 -20
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
+94 -40
View File
@@ -12,18 +12,28 @@ APIs directly:
- one-tap probe for the preset Tailscale APIs, showing health/version and - one-tap probe for the preset Tailscale APIs, showing health/version and
session counts 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 Gitea or GitHub update source, defaulting to Gitea for mobile
- native API action center for health, server status, timeline, preferences, reachability
kanban projects, group messages, hook events, image file/URL upload, image - native Tools page for health, server status, timeline, preferences, hook
preview info, and native image preview display events, image file/URL upload, image preview info, and native image preview
display
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach, - mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- automatic update checks against a GitHub Release manifest - terminal viewport resize based on the phone's visible text area, including
- APK download, SHA-256 verification, and installer handoff keyboard height changes, so tmux output wraps at the same width the user sees
- automatic update checks with Gitea first and GitHub fallback, plus manual
source-specific checks
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- native Update and About pages for version/build type, protocol, permission,
selected update source, and release information
## Server URL ## Server URL
@@ -75,17 +85,31 @@ 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
``` ```
Those GitHub links are the stable public install/update channel. Gitea releases Gitea is the app's default install/update channel because phones may not be able
are mirrored for internal tracking, but the current Gitea org/repo visibility to reach GitHub reliably. GitHub remains an optional public source. This Gitea
keeps anonymous release downloads behind login, so phones should use GitHub for instance does not support the GitHub-style `/releases/latest/download/...` URL,
no-login install and in-app updates. so the app uses the Gitea Release API as the stable Gitea update entrypoint.
Release APKs must be identical on GitHub and Gitea. The canonical APK is the
GitHub Release asset built by `.github/workflows/android.yml`; publish to Gitea
by mirroring that same `tmux-android.apk` byte-for-byte and uploading a
Gitea-specific `latest.json` whose `apkUrl` points at the Gitea asset but whose
`versionCode`, `versionName`, and `sha256` match the GitHub manifest. Do not use
a separately built Gitea APK as a release asset unless it is proven to have the
same SHA-256 as the GitHub APK. This keeps Android signatures and update
compatibility identical no matter which platform the phone can reach.
Plain branch builds only create Actions artifacts; they are useful for CI 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.
@@ -99,19 +123,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 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`, 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`. `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
@@ -130,32 +159,57 @@ release manifest automatically, downloads the newer APK, verifies its SHA-256,
then opens Android's package installer. The user still has to approve the then opens Android's package installer. The user still has to approve the
install, and Android 8+ may require allowing this app to install unknown apps. install, and Android 8+ may require allowing this app to install unknown apps.
The default update manifest is: The default update source is the Gitea Release API:
```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
```
The public manual APK download is:
```text
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
manifest and the Gitea release API. GitHub is the reliable no-login source today.
The Gitea API endpoint is:
```text ```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
``` ```
The default source resolves `latest.json` and `tmux-android.apk` from Gitea
release assets, so APK downloads do not require GitHub. The optional GitHub
manifest is:
```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
```
The app has four update checks on the `Update` page:
- `Auto check` tries Gitea first, then tries GitHub only if Gitea cannot be
reached.
- `Gitea` checks only the public Gitea release API.
- `GitHub` checks only the public GitHub manifest.
- `Selected` checks the source chosen with `Source`, including a custom
manifest/API URL.
Each source retries transient network failures before that source is considered
failed. The `APK` and `Release page` buttons still resolve from the selected
source, so they can be forced to Gitea on phones that cannot reliably reach
GitHub.
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: In the app:
- Tap `Update` on the main screen to check `latest.json`, download the APK, - Open the `Update` page and tap `Auto check` to check `latest.json`, download
verify SHA-256, and open Android's installer. the APK, verify SHA-256, and open Android's installer. Use `Gitea`, `GitHub`,
- Open `More` -> `Open APK download` to download the current public APK in a or `Selected` to force a single update source.
browser. - If the same version APK was already downloaded and its SHA-256 still matches,
- Open `More` -> `Permissions / update status` to see the installed version, the app reuses that file instead of downloading it again.
update manifest, APK URL, install permission state, and a `Check update` - If Android sends you to the unknown-app install permission screen, return to
button. 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.
+3
View File
@@ -9,6 +9,8 @@ val repoSlug = providers.gradleProperty("repoSlug")
val defaultServerUrl = providers.gradleProperty("defaultServerUrl") val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
.orElse("http://100.89.0.116:3000") .orElse("http://100.89.0.116:3000")
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl") val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
.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")
@@ -36,6 +38,7 @@ 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_GITHUB_UPDATE_URL", "\"${defaultGithubUpdateUrl.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_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"") buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,9 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays; import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.SSLSocketFactory;
@@ -25,6 +28,8 @@ final class TerminalSocketClient {
private final Object writeLock = new Object(); private final Object writeLock = new Object();
private final Listener listener; private final Listener listener;
private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor(runnable ->
new Thread(runnable, "terminal-ws-write"));
private Socket socket; private Socket socket;
private BufferedInputStream input; private BufferedInputStream input;
private BufferedOutputStream output; private BufferedOutputStream output;
@@ -42,35 +47,41 @@ final class TerminalSocketClient {
} }
void sendInput(String data) { void sendInput(String data) {
sendMessage("input", "data", data); sendMessageAsync("input", "data", data);
} }
void resize(int cols, int rows) { void resize(int cols, int rows) {
sendMessage("resize", "cols", cols, "rows", rows); sendMessageAsync("resize", "cols", cols, "rows", rows);
} }
void scroll(int lines) { void scroll(int lines) {
sendMessage("scroll", "lines", lines); sendMessageAsync("scroll", "lines", lines);
} }
void clearHistory() { void clearHistory() {
sendMessage("clear-history"); sendMessageAsync("clear-history");
} }
void close() { void close() {
closed = true; closed = true;
try { try {
sendFrame(8, new byte[0]); writeExecutor.execute(() -> {
} catch (Exception ignored) { try {
} sendFrame(8, new byte[0]);
try { } catch (Exception ignored) {
if (socket != null) { }
socket.close(); closeSocketQuietly();
} });
} catch (Exception ignored) { writeExecutor.shutdown();
} catch (RejectedExecutionException ignored) {
closeSocketQuietly();
} }
} }
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,14 +89,14 @@ 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(); sendMessageSync(
sendMessage(
"attach", "attach",
"tabId", "android-" + System.currentTimeMillis(), "tabId", "android-" + System.currentTimeMillis(),
"sessionName", sessionName, "sessionName", sessionName,
"cols", cols, "cols", cols,
"rows", rows "rows", rows
); );
listener.onConnected();
readLoop(); readLoop();
} catch (Exception error) { } catch (Exception error) {
if (!closed) { if (!closed) {
@@ -94,12 +105,8 @@ final class TerminalSocketClient {
} finally { } finally {
closed = true; closed = true;
listener.onClosed(); listener.onClosed();
try { closeSocketQuietly();
if (socket != null) { writeExecutor.shutdownNow();
socket.close();
}
} catch (Exception ignored) {
}
} }
} }
@@ -239,7 +246,7 @@ final class TerminalSocketClient {
} }
} }
private void sendJson(JSONObject object) { private void sendJsonSync(JSONObject object) {
try { try {
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8)); sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
} catch (Exception error) { } catch (Exception error) {
@@ -249,14 +256,21 @@ final class TerminalSocketClient {
} }
} }
private void sendMessage(String type, Object... keyValues) { private void sendMessageAsync(String type, Object... keyValues) {
try {
writeExecutor.execute(() -> sendMessageSync(type, keyValues));
} catch (RejectedExecutionException ignored) {
}
}
private void sendMessageSync(String type, Object... keyValues) {
try { try {
JSONObject object = new JSONObject(); JSONObject object = new JSONObject();
object.put("type", type); object.put("type", type);
for (int i = 0; i + 1 < keyValues.length; i += 2) { for (int i = 0; i + 1 < keyValues.length; i += 2) {
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]); object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
} }
sendJson(object); sendJsonSync(object);
} catch (Exception error) { } catch (Exception error) {
if (!closed) { if (!closed) {
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage()); listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
@@ -264,6 +278,15 @@ final class TerminalSocketClient {
} }
} }
private void closeSocketQuietly() {
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
}
private void sendFrame(int opcode, byte[] payload) throws Exception { private void sendFrame(int opcode, byte[] payload) throws Exception {
synchronized (writeLock) { synchronized (writeLock) {
if (output == null) { if (output == null) {
@@ -20,23 +20,31 @@ 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";
private static final int NETWORK_ATTEMPTS = 3;
private static final long RETRY_DELAY_MS = 1200L;
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,52 +53,124 @@ final class UpdateManager {
} }
void check(boolean userInitiated) { void check(boolean userInitiated) {
List<String> manifestUrls = getUpdateManifestUrls(); checkWithFallback(userInitiated);
}
void checkSelected(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"selected source",
new String[]{getUpdateManifestUrl()}
);
}
void checkGitea(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"Gitea",
new String[]{BuildConfig.DEFAULT_GITEA_UPDATE_URL}
);
}
void checkGithub(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"GitHub",
new String[]{BuildConfig.DEFAULT_GITHUB_UPDATE_URL}
);
}
void checkWithFallback(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"Gitea, then GitHub",
new String[]{BuildConfig.DEFAULT_GITEA_UPDATE_URL, BuildConfig.DEFAULT_GITHUB_UPDATE_URL}
);
}
private void startUpdateCheck(boolean userInitiated, String label, String[] manifestUrls) {
if (checkInProgress) {
postMessage("Update check already running");
return;
}
checkInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Checking update..."); postMessage("Checking update: " + label + "...");
executor.execute(() -> { executor.execute(() -> {
Exception lastError = null;
try { try {
ReleaseInfo info = fetchFirstReleaseInfo(manifestUrls); for (int index = 0; index < manifestUrls.length; index++) {
if (info.versionCode <= BuildConfig.VERSION_CODE) { String manifestUrl = manifestUrls[index];
postMessage("Already up to date: " + BuildConfig.VERSION_NAME); try {
return; postMessage("Checking " + hostLabel(manifestUrl) + "...");
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage("Already up to date: " + BuildConfig.VERSION_NAME + " from " + hostLabel(manifestUrl));
return;
}
postMessage("Update found: " + info.versionName + " from " + hostLabel(manifestUrl));
activity.runOnUiThread(() -> showUpdateDialog(info));
return;
} catch (Exception error) {
lastError = error;
if (index + 1 < manifestUrls.length) {
postMessage(hostLabel(manifestUrl) + " failed; trying " + hostLabel(manifestUrls[index + 1]) + "...");
}
}
} }
postMessage("Update found: " + info.versionName); if (lastError != null) {
activity.runOnUiThread(() -> showUpdateDialog(info)); throw lastError;
} catch (Exception error) { }
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null); throw new IllegalStateException("No update sources configured");
} catch (Exception finalError) {
postMessage(userInitiated ? "Update check failed: " + finalError.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) {
@@ -151,6 +231,22 @@ final class UpdateManager {
} }
private String readText(String url) throws Exception { private String readText(String url) throws Exception {
Exception lastError = null;
for (int attempt = 1; attempt <= NETWORK_ATTEMPTS; attempt++) {
try {
return readTextOnce(url);
} catch (Exception error) {
lastError = error;
if (attempt < NETWORK_ATTEMPTS) {
postMessage("Retrying " + hostLabel(url) + " (" + (attempt + 1) + "/" + NETWORK_ATTEMPTS + ")...");
waitBeforeRetry();
}
}
}
throw lastError == null ? new IllegalStateException("Request failed") : lastError;
}
private String readTextOnce(String url) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(12000); connection.setConnectTimeout(12000);
connection.setReadTimeout(12000); connection.setReadTimeout(12000);
@@ -173,8 +269,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 +290,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,8 +302,42 @@ 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;
}
HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection(); postMessage("Downloading " + info.versionName + "...");
File partial = new File(dir, "tmux-android-" + info.versionCode + ".apk.tmp");
if (partial.exists()) {
partial.delete();
}
if (apk.exists()) {
apk.delete();
}
Exception lastError = null;
for (int attempt = 1; attempt <= NETWORK_ATTEMPTS; attempt++) {
try {
downloadApkOnce(info.apkUrl, partial);
if (!partial.renameTo(apk)) {
throw new IllegalStateException("Cannot finalize APK download");
}
return apk;
} catch (Exception error) {
lastError = error;
partial.delete();
if (attempt < NETWORK_ATTEMPTS) {
postMessage("Retrying APK download (" + (attempt + 1) + "/" + NETWORK_ATTEMPTS + ")...");
waitBeforeRetry();
}
}
}
throw lastError == null ? new IllegalStateException("APK download failed") : lastError;
}
private void downloadApkOnce(String apkUrl, File apk) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(apkUrl).openConnection();
connection.setConnectTimeout(12000); connection.setConnectTimeout(12000);
connection.setReadTimeout(60000); connection.setReadTimeout(60000);
try (InputStream input = new BufferedInputStream(connection.getInputStream()); try (InputStream input = new BufferedInputStream(connection.getInputStream());
@@ -214,21 +350,34 @@ final class UpdateManager {
} finally { } finally {
connection.disconnect(); connection.disconnect();
} }
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 +396,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];
@@ -280,4 +464,8 @@ final class UpdateManager {
} }
activity.runOnUiThread(() -> callback.onMessage(message)); activity.runOnUiThread(() -> callback.onMessage(message));
} }
private void waitBeforeRetry() throws InterruptedException {
Thread.sleep(RETRY_DELAY_MS);
}
} }
+49 -5
View File
@@ -70,6 +70,8 @@ Implemented now:
- configurable base URL, defaulting to `http://100.89.0.116: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
@@ -78,18 +80,60 @@ Implemented now:
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys, - bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste 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 - Auto/Gitea/GitHub/Selected update checks, with Auto falling back from Gitea
to GitHub
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- terminal viewport resize derived from the Android text area, including
keyboard height changes, to avoid fixed-width tmux output wrapping on phones
- permission/about surfaces for unknown-app install status, notification status,
app settings, app version/build type, package name, selected update source, and
HTTP/WebSocket API/protocol summary
## 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 provides explicit update checks for Auto, Gitea, GitHub, and Selected.
Auto checks Gitea first because phones may not reach GitHub reliably, then tries
GitHub only if Gitea cannot be reached. The manual Gitea, GitHub, and Selected
buttons force one source. Transient network failures are retried against the
current source before a source is considered failed.
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.
Release APKs must be identical across the public GitHub and Gitea download
channels. Treat the GitHub Release APK as the canonical build artifact, then
mirror that same APK byte-for-byte to the matching Gitea Release. The Gitea
`latest.json` should point to the Gitea APK URL, but it must keep the same
`versionCode`, `versionName`, and `sha256` as the GitHub manifest. A Gitea-built
APK is only a compile check unless its SHA-256 exactly matches the GitHub
release APK.
## Native Roadmap ## 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`