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
+64
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
}
}
}
}
+31
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>
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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));
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">tmux Android</string>
</resources>
+11
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>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>