Initial native Android tmux client

This commit is contained in:
Codex 2026-07-03 13:49:13 +00:00
commit 6107faf726
20 changed files with 3027 additions and 0 deletions

160
.github/workflows/android.yml vendored Normal file
View File

@ -0,0 +1,160 @@
name: Android APK
on:
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:
build:
runs-on: ubuntu-latest
env:
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || inputs.publish_release == 'true' }}
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
shell: bash
run: |
if [[ "${{ steps.signing.outputs.signed }}" == "true" ]]; then
BUILD_TASK=":app:assembleRelease"
else
BUILD_TASK=":app:assembleDebug"
fi
gradle "${BUILD_TASK}" \
-PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \
-PrepoSlug="${GITHUB_REPOSITORY}"
- name: Prepare artifacts
shell: bash
run: |
mkdir -p release
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-${VERSION_NAME}.apk"
SHA256="$(sha256sum release/tmux-android.apk | awk '{print $1}')"
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

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.gradle/
build/
app/build/
local.properties
signing.properties
*.jks
*.keystore
release/

103
README.md Normal file
View File

@ -0,0 +1,103 @@
# tmux Android
Android API client for `tmux-ui` / `tmux-browser`.
The server project is expected to already be running on port 3000. This app does
not host or load the existing web UI. It calls the existing HTTP and WebSocket
APIs directly:
- server URL management for `http://127.0.0.1:3000` and Tailscale `100.x.y.z:3000`
- native tmux session list
- create, rename, send command, split pane, select pane, kill pane, pin, mute,
and kill session through HTTP API
- open one live terminal viewer through `/ws/terminal`
- native `/ws/events` listener for session invalidation and hook notifications
- native API action center for health, server status, timeline, preferences,
kanban projects, group messages, hook events, image file/URL upload, image
preview info, and native image preview display
- mobile soft-key row for tmux-oriented input
- automatic update checks against a GitHub Release manifest
- APK download, SHA-256 verification, and installer handoff
## Server URL
Default:
```text
http://127.0.0.1:3000
```
On a physical Android device, `127.0.0.1` means the phone itself. For remote
testing, install Tailscale on the phone and use:
```text
http://100.x.y.z:3000
```
The upstream server should stay bound to localhost or a private Tailscale IP.
Do not expose terminal control on `0.0.0.0` to the public internet.
## GitHub Actions Build
The workflow in `.github/workflows/android.yml` builds APKs online.
Required environment on Actions:
- `ubuntu-latest`
- JDK 17
- Android SDK platform 35 installed by `android-actions/setup-android`
- Gradle 8.10.2 installed by `gradle/actions/setup-gradle`
- Android Gradle Plugin 8.7.3 from Google Maven
For release builds that can update an already installed app, configure these
repository secrets:
```text
ANDROID_KEYSTORE_BASE64
ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_ALIAS
ANDROID_KEY_PASSWORD
```
Create the base64 value from your release keystore:
```bash
base64 -w 0 tmux-android-release.jks
```
Publish a test build by running the `Android APK` workflow manually with
`publish_release=true`, or by pushing a `v*` tag.
Unsigned/debug workflow artifacts are useful only for smoke testing install and
launch. Automatic in-place updates require release APKs signed with the same
keystore every time; otherwise Android treats the APK as a different or
incompatible package.
## Current Terminal Scope
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 basic monospace text with ANSI
escape filtering. 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`.
All app features are native Android controls. Complex server objects such as
kanban projects, preferences, timeline events, group messages, and image metadata
currently use native forms plus native JSON detail dialogs; image preview uses a
native `ImageView`. The app does not load the browser UI.
## Auto Update
Normal Android apps cannot silently replace themselves. This app checks the
release manifest automatically, downloads the newer APK, verifies its SHA-256,
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.
The default update manifest is:
```text
https://github.com/<owner>/<repo>/releases/latest/download/latest.json
```
The workflow uploads both the APK and `latest.json` to each release.

64
app/build.gradle.kts Normal file
View File

@ -0,0 +1,64 @@
import java.util.Properties
plugins {
id("com.android.application")
}
val repoSlug = providers.gradleProperty("repoSlug")
.orElse("neatstudio/tmux-android")
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
.orElse("http://127.0.0.1:3000")
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val signingProps = Properties()
val signingFile = rootProject.file("signing.properties")
if (signingFile.exists()) {
signingFile.inputStream().use(signingProps::load)
}
android {
namespace = "com.neatstudio.tmuxandroid"
compileSdk = 35
defaultConfig {
applicationId = "com.neatstudio.tmuxandroid"
minSdk = 26
targetSdk = 35
versionCode = providers.gradleProperty("versionCode").orElse("1").get().toInt()
versionName = providers.gradleProperty("versionName").orElse("0.1.0").get()
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
}
buildFeatures {
buildConfig = true
}
signingConfigs {
create("release") {
val storeFilePath = signingProps.getProperty("storeFile")
if (!storeFilePath.isNullOrBlank()) {
storeFile = rootProject.file(storeFilePath)
storePassword = signingProps.getProperty("storePassword")
keyAlias = signingProps.getProperty("keyAlias")
keyPassword = signingProps.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = false
val releaseSigning = signingConfigs.getByName("release")
if (releaseSigning.storeFile != null) {
signingConfig = releaseSigning
}
}
}
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme"
android:label="@string/app_name"
android:supportsRtl="true">
<provider
android:name=".UpdateFileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,221 @@
package com.neatstudio.tmuxandroid;
import android.util.Base64;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.net.ssl.SSLSocketFactory;
final class AppEventSocketClient {
interface Listener {
void onMessage(String text);
void onClosed();
}
private final Listener listener;
private Socket socket;
private BufferedInputStream input;
private BufferedOutputStream output;
private volatile boolean closed;
AppEventSocketClient(Listener listener) {
this.listener = listener;
}
void connect(String baseUrl) {
closed = false;
new Thread(() -> run(baseUrl), "app-events-ws").start();
}
void close() {
closed = true;
try {
sendFrame(8, new byte[0]);
} catch (Exception ignored) {
}
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
}
private void run(String baseUrl) {
try {
URI uri = buildWsUri(baseUrl);
socket = openSocket(uri);
input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri);
while (!closed) {
Frame frame = readFrame();
if (frame.opcode == 1) {
listener.onMessage(new String(frame.payload, StandardCharsets.UTF_8));
} else if (frame.opcode == 8) {
return;
} else if (frame.opcode == 9) {
sendFrame(10, frame.payload);
}
}
} catch (Exception ignored) {
} finally {
closed = true;
listener.onClosed();
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
}
}
private URI buildWsUri(String baseUrl) throws Exception {
URI base = new URI(baseUrl);
String scheme = "https".equalsIgnoreCase(base.getScheme()) ? "wss" : "ws";
int port = base.getPort();
String authority = port == -1 ? base.getHost() : base.getHost() + ":" + port;
return new URI(scheme + "://" + authority + "/ws/events");
}
private Socket openSocket(URI uri) throws Exception {
int port = uri.getPort();
if (port == -1) {
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
if ("wss".equalsIgnoreCase(uri.getScheme())) {
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
}
return new Socket(uri.getHost(), port);
}
private void handshake(URI uri) throws Exception {
byte[] nonce = new byte[16];
new SecureRandom().nextBytes(nonce);
String key = Base64.encodeToString(nonce, Base64.NO_WRAP);
String host = uri.getPort() == -1 ? uri.getHost() : uri.getHost() + ":" + uri.getPort();
String request = "GET " + uri.getRawPath() + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: " + key + "\r\n"
+ "Sec-WebSocket-Version: 13\r\n"
+ "\r\n";
output.write(request.getBytes(StandardCharsets.US_ASCII));
output.flush();
String response = readHttpHeaders();
if (!response.startsWith("HTTP/1.1 101") && !response.startsWith("HTTP/1.0 101")) {
throw new IllegalStateException("WebSocket handshake failed");
}
String expected = websocketAccept(key).toLowerCase(java.util.Locale.US);
if (!response.toLowerCase(java.util.Locale.US).contains("sec-websocket-accept: " + expected)) {
throw new IllegalStateException("WebSocket accept header mismatch");
}
}
private String readHttpHeaders() throws Exception {
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
int a = -1;
int b = -1;
int c = -1;
int d;
while ((d = input.read()) != -1) {
buffer.write(d);
if (a == '\r' && b == '\n' && c == '\r' && d == '\n') {
break;
}
a = b;
b = c;
c = d;
}
return buffer.toString("US-ASCII");
}
private static String websocketAccept(String key) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
return Base64.encodeToString(
digest.digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII)),
Base64.NO_WRAP
);
}
private Frame readFrame() throws Exception {
int first = input.read();
int second = input.read();
if (first == -1 || second == -1) {
throw new IllegalStateException("WebSocket closed");
}
int opcode = first & 0x0f;
long length = second & 0x7f;
if (length == 126) {
length = ((long) input.read() << 8) | input.read();
} else if (length == 127) {
length = 0;
for (int i = 0; i < 8; i++) {
length = (length << 8) | input.read();
}
}
byte[] payload = readExactly((int) length);
return new Frame(opcode, payload);
}
private byte[] readExactly(int length) throws Exception {
byte[] data = new byte[length];
int offset = 0;
while (offset < length) {
int read = input.read(data, offset, length - offset);
if (read == -1) {
throw new IllegalStateException("WebSocket closed");
}
offset += read;
}
return data;
}
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);
}
}
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();
}
private static final class Frame {
final int opcode;
final byte[] payload;
Frame(int opcode, byte[] payload) {
this.opcode = opcode;
this.payload = payload;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
package com.neatstudio.tmuxandroid;
final class ReleaseInfo {
final int versionCode;
final String versionName;
final String apkUrl;
final String sha256;
final String releasePageUrl;
ReleaseInfo(int versionCode, String versionName, String apkUrl, String sha256, String releasePageUrl) {
this.versionCode = versionCode;
this.versionName = versionName;
this.apkUrl = apkUrl;
this.sha256 = sha256;
this.releasePageUrl = releasePageUrl;
}
}

View File

@ -0,0 +1,330 @@
package com.neatstudio.tmuxandroid;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
final class SessionApiClient {
private final String baseUrl;
SessionApiClient(String baseUrl) {
this.baseUrl = trimTrailingSlash(baseUrl);
}
List<SessionSummary> getSessions() throws Exception {
String text = request("GET", "/api/sessions", null);
JSONArray array = new JSONArray(text);
List<SessionSummary> sessions = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject item = array.getJSONObject(i);
sessions.add(new SessionSummary(
item.getString("name"),
item.optString("status", ""),
nullableString(item, "currentCommand"),
nullableString(item, "currentPath"),
item.optInt("windows", 0),
item.optInt("paneCount", 0)
));
}
return sessions;
}
void createSession(String name) throws Exception {
JSONObject body = new JSONObject().put("name", name);
request("POST", "/api/sessions", body.toString());
}
void killSession(String name) throws Exception {
request("DELETE", "/api/sessions/" + encodePath(name), null);
}
void renameSession(String fromName, String toName) throws Exception {
JSONObject body = new JSONObject().put("name", toName);
request("PATCH", "/api/sessions/" + encodePath(fromName), body.toString());
}
void sendCommand(String sessionName, String command) throws Exception {
JSONObject body = new JSONObject().put("command", command);
request("POST", "/api/sessions/" + encodePath(sessionName) + "/send", body.toString());
}
void sendInput(String sessionName, String input) throws Exception {
JSONObject body = new JSONObject().put("input", input);
request("POST", "/api/sessions/" + encodePath(sessionName) + "/input", body.toString());
}
void splitPane(String sessionName, String direction) throws Exception {
JSONObject body = new JSONObject().put("direction", direction);
request("POST", "/api/sessions/" + encodePath(sessionName) + "/split", body.toString());
}
void selectPane(String sessionName, String paneId) throws Exception {
JSONObject body = new JSONObject().put("paneId", paneId);
request("POST", "/api/sessions/" + encodePath(sessionName) + "/select-pane", body.toString());
}
void killPane(String sessionName, String paneId) throws Exception {
request("DELETE", "/api/sessions/" + encodePath(sessionName) + "/panes/" + encodePath(paneId), null);
}
void setPinned(String sessionName, boolean pinned) throws Exception {
JSONObject body = new JSONObject().put("pinned", pinned);
request("PATCH", "/api/preferences/pinned-sessions/" + encodePath(sessionName), body.toString());
}
void setMuted(String sessionName, boolean muted) throws Exception {
JSONObject body = new JSONObject().put("muted", muted);
request("PATCH", "/api/preferences/muted-sessions/" + encodePath(sessionName), body.toString());
}
void updateSessionSettings(String sessionName, int fontSize, String fontFamily, double lineHeight, String themeId) throws Exception {
JSONObject settings = new JSONObject()
.put("fontSize", fontSize)
.put("fontFamily", fontFamily)
.put("lineHeight", lineHeight)
.put("themeId", themeId);
JSONObject body = new JSONObject().put("settings", settings);
request("PATCH", "/api/preferences/session-settings/" + encodePath(sessionName), body.toString());
}
void createKanbanProject(String name, String path, String server) throws Exception {
JSONObject body = new JSONObject()
.put("name", name)
.put("path", path)
.put("server", server == null || server.isEmpty() ? JSONObject.NULL : server);
request("POST", "/api/kanban/projects", body.toString());
}
void deleteKanbanProject(String name) throws Exception {
request("DELETE", "/api/kanban/projects/" + encodePath(name), null);
}
void addKanbanSession(String projectName, String sessionName) throws Exception {
JSONObject body = new JSONObject().put("sessionName", sessionName);
request("POST", "/api/kanban/projects/" + encodePath(projectName) + "/sessions", body.toString());
}
void removeKanbanSession(String projectName, String agentName, boolean kill) throws Exception {
request(
"DELETE",
"/api/kanban/projects/" + encodePath(projectName) + "/sessions/" + encodePath(agentName) + "?kill=" + kill,
null
);
}
void sendGroupMessage(String projectName, String fromSession, String kind, String targetType, String targetValue, String bodyText) throws Exception {
JSONObject target = new JSONObject().put("type", targetType);
if ("session".equals(targetType)) {
target.put("sessionName", targetValue);
} else if ("role".equals(targetType)) {
target.put("role", targetValue);
}
JSONObject body = new JSONObject()
.put("fromSession", fromSession)
.put("kind", kind)
.put("target", target)
.put("body", bodyText);
request("POST", "/api/kanban/projects/" + encodePath(projectName) + "/messages", body.toString());
}
void scanGroupMessage(String projectName, String messageId) throws Exception {
request("POST", "/api/kanban/projects/" + encodePath(projectName) + "/messages/" + encodePath(messageId) + "/scan", "{}");
}
void postHookEvent(String sessionName, String title, String status, String bodyText) throws Exception {
JSONObject body = new JSONObject()
.put("source", "android")
.put("sessionName", sessionName)
.put("eventType", "mobile-event")
.put("status", status)
.put("title", title)
.put("body", bodyText);
request("POST", "/api/hooks/events", body.toString());
}
String uploadImageUrl(String sessionName, String imageUrl) throws Exception {
JSONObject body = new JSONObject().put("url", imageUrl);
return request("POST", "/api/uploads/image-url", body.toString(), sessionName);
}
String uploadImage(String sessionName, byte[] bytes) throws Exception {
return prettyJson(requestBytes("POST", "/api/uploads/image", bytes, sessionName));
}
byte[] imagePreview(String path, String basePath) throws Exception {
String query = "?path=" + encodePath(path);
if (basePath != null && !basePath.isEmpty()) {
query += "&basePath=" + encodePath(basePath);
}
return requestBinary("GET", "/api/image-preview" + query, null, null);
}
String imagePreviewInfo(String path, String basePath) throws Exception {
String query = "?path=" + encodePath(path);
if (basePath != null && !basePath.isEmpty()) {
query += "&basePath=" + encodePath(basePath);
}
return prettyJson(request("GET", "/api/image-preview-info" + query, null));
}
String health() throws Exception {
return prettyJson(request("GET", "/api/health", null));
}
String serverStatus() throws Exception {
return prettyJson(request("GET", "/api/server-status", null));
}
String timeline(int limit) throws Exception {
return prettyJson(request("GET", "/api/timeline?limit=" + limit, null));
}
String preferences() throws Exception {
return prettyJson(request("GET", "/api/preferences", null));
}
String sessionsAll() throws Exception {
return prettyJson(request("GET", "/api/sessions-all", null));
}
String sessionsPanes() throws Exception {
return prettyJson(request("GET", "/api/sessions-panes", null));
}
String sessionStatus(String sessionName) throws Exception {
return prettyJson(request("GET", "/api/sessions/" + encodePath(sessionName) + "/status", null));
}
String kanbanProjects() throws Exception {
return prettyJson(request("GET", "/api/kanban/projects", null));
}
String groupMessages(String projectName) throws Exception {
return prettyJson(request("GET", "/api/kanban/projects/" + encodePath(projectName) + "/messages", null));
}
String getBaseUrl() {
return baseUrl;
}
private String request(String method, String path, String body) throws Exception {
return request(method, path, body, null);
}
private String request(String method, String path, String body, String sessionNameHeader) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(12000);
connection.setReadTimeout(20000);
connection.setRequestProperty("Accept", "application/json");
if (sessionNameHeader != null && !sessionNameHeader.isEmpty()) {
connection.setRequestProperty("X-Tmux-Session", sessionNameHeader);
}
if (body != null) {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Content-Length", String.valueOf(bytes.length));
try (OutputStream output = connection.getOutputStream()) {
output.write(bytes);
}
}
int code = connection.getResponseCode();
InputStream rawInput = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream();
String text = rawInput == null ? "" : new String(readAllBytes(new BufferedInputStream(rawInput)), StandardCharsets.UTF_8);
connection.disconnect();
if (code < 200 || code >= 300) {
String message = text;
try {
message = new JSONObject(text).optString("error", text);
} catch (Exception ignored) {
}
throw new IllegalStateException(method + " " + path + " failed: " + message);
}
return text;
}
private String requestBytes(String method, String path, byte[] body, String sessionNameHeader) throws Exception {
return new String(requestBinary(method, path, body, sessionNameHeader), StandardCharsets.UTF_8);
}
private byte[] requestBinary(String method, String path, byte[] body, String sessionNameHeader) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(12000);
connection.setReadTimeout(30000);
if (sessionNameHeader != null && !sessionNameHeader.isEmpty()) {
connection.setRequestProperty("X-Tmux-Session", sessionNameHeader);
}
if (body != null) {
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/octet-stream");
connection.setRequestProperty("Content-Length", String.valueOf(body.length));
try (OutputStream output = connection.getOutputStream()) {
output.write(body);
}
}
int code = connection.getResponseCode();
InputStream rawInput = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream();
byte[] bytes = rawInput == null ? new byte[0] : readAllBytes(new BufferedInputStream(rawInput));
connection.disconnect();
if (code < 200 || code >= 300) {
String message = new String(bytes, StandardCharsets.UTF_8);
try {
message = new JSONObject(message).optString("error", message);
} catch (Exception ignored) {
}
throw new IllegalStateException(method + " " + path + " failed: " + message);
}
return bytes;
}
private static String prettyJson(String text) {
try {
String trimmed = text.trim();
if (trimmed.startsWith("[")) {
return new JSONArray(trimmed).toString(2);
}
if (trimmed.startsWith("{")) {
return new JSONObject(trimmed).toString(2);
}
} catch (Exception ignored) {
}
return text;
}
private static byte[] readAllBytes(BufferedInputStream input) throws Exception {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private static String nullableString(JSONObject item, String key) {
return item.isNull(key) ? null : item.optString(key, null);
}
private static String encodePath(String value) throws Exception {
return java.net.URLEncoder.encode(value, "UTF-8").replace("+", "%20");
}
private static String trimTrailingSlash(String value) {
String result = value;
while (result.endsWith("/") && result.length() > "http://x".length()) {
result = result.substring(0, result.length() - 1);
}
return result;
}
}

View File

@ -0,0 +1,20 @@
package com.neatstudio.tmuxandroid;
final class SessionSummary {
final String name;
final String status;
final String currentCommand;
final String currentPath;
final int windows;
final int paneCount;
SessionSummary(String name, String status, String currentCommand, String currentPath, int windows, int paneCount) {
this.name = name;
this.status = status;
this.currentCommand = currentCommand;
this.currentPath = currentPath;
this.windows = windows;
this.paneCount = paneCount;
}
}

View File

@ -0,0 +1,307 @@
package com.neatstudio.tmuxandroid;
import android.util.Base64;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.net.ssl.SSLSocketFactory;
final class TerminalSocketClient {
interface Listener {
void onConnected();
void onOutput(String data);
void onError(String message);
void onClosed();
}
private final Object writeLock = new Object();
private final Listener listener;
private Socket socket;
private BufferedInputStream input;
private BufferedOutputStream output;
private volatile boolean closed;
private Thread thread;
TerminalSocketClient(Listener listener) {
this.listener = listener;
}
void connect(String baseUrl, String sessionName, int cols, int rows) {
closed = false;
thread = new Thread(() -> run(baseUrl, sessionName, cols, rows), "terminal-ws");
thread.start();
}
void sendInput(String data) {
sendMessage("input", "data", data);
}
void resize(int cols, int rows) {
sendMessage("resize", "cols", cols, "rows", rows);
}
void scroll(int lines) {
sendMessage("scroll", "lines", lines);
}
void clearHistory() {
sendMessage("clear-history");
}
void close() {
closed = true;
try {
sendFrame(8, new byte[0]);
} catch (Exception ignored) {
}
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
}
private void run(String baseUrl, String sessionName, int cols, int rows) {
try {
URI uri = buildWsUri(baseUrl);
socket = openSocket(uri);
input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri);
listener.onConnected();
sendMessage(
"attach",
"tabId", "android-" + System.currentTimeMillis(),
"sessionName", sessionName,
"cols", cols,
"rows", rows
);
readLoop();
} catch (Exception error) {
if (!closed) {
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
}
} finally {
closed = true;
listener.onClosed();
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
}
}
private URI buildWsUri(String baseUrl) throws Exception {
URI base = new URI(baseUrl);
String scheme = "https".equalsIgnoreCase(base.getScheme()) ? "wss" : "ws";
int port = base.getPort();
String authority = port == -1 ? base.getHost() : base.getHost() + ":" + port;
return new URI(scheme + "://" + authority + "/ws/terminal");
}
private Socket openSocket(URI uri) throws Exception {
int port = uri.getPort();
if (port == -1) {
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
if ("wss".equalsIgnoreCase(uri.getScheme())) {
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
}
return new Socket(uri.getHost(), port);
}
private void handshake(URI uri) throws Exception {
byte[] nonce = new byte[16];
new SecureRandom().nextBytes(nonce);
String key = Base64.encodeToString(nonce, Base64.NO_WRAP);
String host = uri.getPort() == -1 ? uri.getHost() : uri.getHost() + ":" + uri.getPort();
String request = "GET " + uri.getRawPath() + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: " + key + "\r\n"
+ "Sec-WebSocket-Version: 13\r\n"
+ "\r\n";
output.write(request.getBytes(StandardCharsets.US_ASCII));
output.flush();
String response = readHttpHeaders();
if (!response.startsWith("HTTP/1.1 101") && !response.startsWith("HTTP/1.0 101")) {
throw new IllegalStateException("WebSocket handshake failed: " + response.split("\r\n")[0]);
}
String expected = websocketAccept(key);
if (!response.toLowerCase(java.util.Locale.US).contains("sec-websocket-accept: " + expected.toLowerCase(java.util.Locale.US))) {
throw new IllegalStateException("WebSocket accept header mismatch");
}
}
private String readHttpHeaders() throws Exception {
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
int previous3 = -1;
int previous2 = -1;
int previous1 = -1;
int current;
while ((current = input.read()) != -1) {
buffer.write(current);
if (previous3 == '\r' && previous2 == '\n' && previous1 == '\r' && current == '\n') {
break;
}
previous3 = previous2;
previous2 = previous1;
previous1 = current;
}
return buffer.toString("US-ASCII");
}
private static String websocketAccept(String key) throws Exception {
String source = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
MessageDigest digest = MessageDigest.getInstance("SHA-1");
return Base64.encodeToString(digest.digest(source.getBytes(StandardCharsets.US_ASCII)), Base64.NO_WRAP);
}
private void readLoop() throws Exception {
while (!closed) {
Frame frame = readFrame();
if (frame.opcode == 1) {
handleText(new String(frame.payload, StandardCharsets.UTF_8));
} else if (frame.opcode == 8) {
return;
} else if (frame.opcode == 9) {
sendFrame(10, frame.payload);
}
}
}
private Frame readFrame() throws Exception {
int first = input.read();
int second = input.read();
if (first == -1 || second == -1) {
throw new IllegalStateException("WebSocket closed");
}
int opcode = first & 0x0f;
boolean masked = (second & 0x80) != 0;
long length = second & 0x7f;
if (length == 126) {
length = ((long) input.read() << 8) | input.read();
} else if (length == 127) {
length = 0;
for (int i = 0; i < 8; i++) {
length = (length << 8) | input.read();
}
}
byte[] mask = null;
if (masked) {
mask = readExactly(4);
}
byte[] payload = readExactly((int) length);
if (masked && mask != null) {
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (payload[i] ^ mask[i % 4]);
}
}
return new Frame(opcode, payload);
}
private byte[] readExactly(int length) throws Exception {
byte[] data = new byte[length];
int offset = 0;
while (offset < length) {
int read = input.read(data, offset, length - offset);
if (read == -1) {
throw new IllegalStateException("WebSocket closed");
}
offset += read;
}
return data;
}
private void handleText(String text) throws Exception {
JSONObject message = new JSONObject(text);
String type = message.optString("type", "");
if ("output".equals(type)) {
listener.onOutput(message.optString("data", ""));
} else if ("error".equals(type)) {
listener.onError(message.optString("message", "Terminal error"));
} else if ("session-exit".equals(type)) {
close();
}
}
private void sendJson(JSONObject object) {
try {
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
} catch (Exception error) {
if (!closed) {
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
}
}
}
private void sendMessage(String type, Object... keyValues) {
try {
JSONObject object = new JSONObject();
object.put("type", type);
for (int i = 0; i + 1 < keyValues.length; i += 2) {
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
}
sendJson(object);
} catch (Exception error) {
if (!closed) {
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
}
}
}
private void sendFrame(int opcode, byte[] payload) throws Exception {
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();
}
}
private static final class Frame {
final int opcode;
final byte[] payload;
Frame(int opcode, byte[] payload) {
this.opcode = opcode;
this.payload = payload;
}
}
}

View File

@ -0,0 +1,116 @@
package com.neatstudio.tmuxandroid;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
public final class UpdateFileProvider extends ContentProvider {
static Uri getUriForFile(Context context, String authority, File file) {
try {
File root = new File(context.getCacheDir(), "updates").getCanonicalFile();
File target = file.getCanonicalFile();
if (!target.getPath().startsWith(root.getPath() + File.separator)) {
throw new IllegalArgumentException("File is outside update cache");
}
return new Uri.Builder()
.scheme("content")
.authority(authority)
.appendPath(target.getName())
.build();
} catch (IOException error) {
throw new IllegalArgumentException("Invalid update file", error);
}
}
@Override
public boolean onCreate() {
return true;
}
@Override
public String getType(Uri uri) {
return "application/vnd.android.package-archive";
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
if (!"r".equals(mode)) {
throw new FileNotFoundException("Read-only provider");
}
File file = resolveFile(uri);
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
File file;
try {
file = resolveFile(uri);
} catch (FileNotFoundException error) {
return null;
}
String[] columns = projection == null
? new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}
: projection;
MatrixCursor cursor = new MatrixCursor(columns, 1);
MatrixCursor.RowBuilder row = cursor.newRow();
for (String column : columns) {
if (OpenableColumns.DISPLAY_NAME.equals(column)) {
row.add(file.getName());
} else if (OpenableColumns.SIZE.equals(column)) {
row.add(file.length());
} else {
row.add(null);
}
}
return cursor;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("insert");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("delete");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("update");
}
private File resolveFile(Uri uri) throws FileNotFoundException {
Context context = getContext();
if (context == null) {
throw new FileNotFoundException("No context");
}
String name = uri.getLastPathSegment();
if (name == null || name.contains("/") || name.contains("..")) {
throw new FileNotFoundException("Invalid file name");
}
try {
File root = new File(context.getCacheDir(), "updates").getCanonicalFile();
File file = new File(root, name).getCanonicalFile();
if (!file.getPath().startsWith(root.getPath() + File.separator) || !file.isFile()) {
throw new FileNotFoundException("File not found");
}
return file;
} catch (IOException error) {
FileNotFoundException wrapped = new FileNotFoundException("Invalid file");
wrapped.initCause(error);
throw wrapped;
}
}
}

View File

@ -0,0 +1,200 @@
package com.neatstudio.tmuxandroid;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.widget.Toast;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
final class UpdateManager {
interface Callback {
void onChecking(boolean checking);
void onMessage(String message);
}
private final Activity activity;
private final SharedPreferences prefs;
private final Callback callback;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) {
this.activity = activity;
this.prefs = prefs;
this.callback = callback;
}
void check(boolean userInitiated) {
String manifestUrl = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
callback.onChecking(true);
executor.execute(() -> {
try {
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage(userInitiated ? "Already up to date" : null);
return;
}
activity.runOnUiThread(() -> showUpdateDialog(info));
} catch (Exception error) {
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
} finally {
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
}
private ReleaseInfo fetchReleaseInfo(String manifestUrl) throws Exception {
String json = readText(manifestUrl);
JSONObject root = new JSONObject(json);
return new ReleaseInfo(
root.getInt("versionCode"),
root.optString("versionName", ""),
root.getString("apkUrl"),
root.optString("sha256", ""),
root.optString("releasePageUrl", "")
);
}
private String readText(String url) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(12000);
connection.setReadTimeout(12000);
connection.setRequestProperty("Accept", "application/json");
try (InputStream input = new BufferedInputStream(connection.getInputStream())) {
byte[] bytes = readAllBytes(input);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
} finally {
connection.disconnect();
}
}
private void showUpdateDialog(ReleaseInfo info) {
new AlertDialog.Builder(activity)
.setTitle("Update available")
.setMessage("Install " + info.versionName + " now?")
.setNegativeButton("Later", null)
.setPositiveButton("Install", (dialog, which) -> downloadAndInstall(info))
.show();
}
private void downloadAndInstall(ReleaseInfo info) {
callback.onChecking(true);
executor.execute(() -> {
try {
File apk = downloadApk(info);
if (!info.sha256.isEmpty()) {
String actual = sha256(apk);
if (!actual.equalsIgnoreCase(info.sha256)) {
throw new IllegalStateException("APK SHA-256 mismatch");
}
}
activity.runOnUiThread(() -> installApk(apk));
} catch (Exception error) {
postMessage("Update download failed: " + error.getMessage());
} finally {
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
}
private File downloadApk(ReleaseInfo info) throws Exception {
File dir = new File(activity.getCacheDir(), "updates");
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("Cannot create update cache");
}
File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk");
HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection();
connection.setConnectTimeout(12000);
connection.setReadTimeout(60000);
try (InputStream input = new BufferedInputStream(connection.getInputStream());
FileOutputStream output = new FileOutputStream(apk)) {
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
} finally {
connection.disconnect();
}
return apk;
}
private void installApk(File apk) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !activity.getPackageManager().canRequestPackageInstalls()) {
Intent settingsIntent = new Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + activity.getPackageName())
);
activity.startActivity(settingsIntent);
Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show();
return;
}
Uri apkUri = UpdateFileProvider.getUriForFile(
activity,
activity.getPackageName() + ".fileprovider",
apk
);
Intent intent = new Intent(Intent.ACTION_VIEW)
.setDataAndType(apkUri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
activity.startActivity(intent);
} catch (ActivityNotFoundException error) {
postMessage("No package installer found");
}
}
private static byte[] readAllBytes(InputStream input) throws Exception {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private static String sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = new BufferedInputStream(new java.io.FileInputStream(file))) {
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = input.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
byte[] bytes = digest.digest();
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte item : bytes) {
builder.append(String.format(Locale.US, "%02x", item));
}
return builder.toString();
}
private void postMessage(String message) {
if (message == null) {
return;
}
activity.runOnUiThread(() -> callback.onMessage(message));
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">tmux Android</string>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.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:windowActionModeOverlay">true</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

3
build.gradle.kts Normal file
View File

@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "8.7.3" apply false
}

View File

@ -0,0 +1,94 @@
# Android Client Notes
This project is based on the upstream `tmux-browser` documents under
`docs/`, especially:
- `docs/api.md`
- `docs/superpowers/specs/2026-04-17-browser-tmux-dashboard-design.md`
- `docs/superpowers/specs/2026-04-21-pty-streaming-terminal-design.md`
- `docs/superpowers/specs/2026-04-30-harmonyos-mobile-tmux-manager-design.md`
## What The Upstream Docs Say
The mobile design document is HarmonyOS-first and recommends a native ArkTS
client with these modules:
- connection profile store
- session API client
- terminal WebSocket client
- terminal core
- terminal view
- shortcut bar
- session list and terminal screens
It also says Android is second priority and WebView terminal rendering is out of
scope for that first HarmonyOS version.
## Android Request Reconciliation
The current request is different from that document in two important ways:
- target platform is Android first
- remote testing requires online APK builds and app-side update checks
For that reason, this repository starts with an Android API-client MVP. The
existing server is expected to already be running on port 3000. The app calls
the server HTTP API and terminal WebSocket directly instead of loading the
existing browser UI.
This is not yet the full native-terminal architecture described by the HarmonyOS
design document, but every exposed Android feature is native and calls the
server API directly. There is no WebView fallback.
## Backend Contract
The Android client must not change the server protocol.
HTTP:
- `GET /api/sessions`
- `POST /api/sessions`
- `DELETE /api/sessions/:name`
- `POST /api/sessions/:name/input`
WebSocket:
- `/ws/terminal`
- `/ws/events`
- `attach`
- `input`
- `resize`
- `scroll`
- `clear-history`
The server remains the source of truth. Closing the app or terminal viewer must
not kill a tmux session.
## Current MVP
Implemented now:
- configurable base URL, defaulting to `http://127.0.0.1:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000`
- native session list from `GET /api/sessions`
- create, rename, command send, split, pane select, pane kill, pin, mute, and
kill session through documented session/preference endpoints
- basic live terminal through `/ws/terminal`
- native event stream through `/ws/events`
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, arrows, page keys, and paste
- shortcut delivery through the terminal WebSocket `input` message
- native action center for health, server status, timeline, preferences, kanban
projects, group messages, hook events, image file/URL upload, image preview
metadata, and native image preview display
- GitHub Actions APK build
- release manifest `latest.json`
- APK download, SHA-256 verification, and installer handoff
## Native Roadmap
To converge with the upstream mobile design, the next implementation should add
native Android modules in this order:
1. richer native layouts for kanban, group messages, timeline, and preferences
2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows
3. configurable shortcut bar backed directly by WebSocket `input`

4
gradle.properties Normal file
View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=false
android.nonTransitiveRClass=true

19
settings.gradle.kts Normal file
View File

@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "tmux-android"
include(":app")