Initial native Android tmux client
This commit is contained in:
commit
6107faf726
160
.github/workflows/android.yml
vendored
Normal file
160
.github/workflows/android.yml
vendored
Normal 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
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
signing.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
release/
|
||||||
|
|
||||||
103
README.md
Normal file
103
README.md
Normal 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
64
app/build.gradle.kts
Normal 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
app/src/main/AndroidManifest.xml
Normal file
31
app/src/main/AndroidManifest.xml
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1307
app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java
Normal file
1307
app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java
Normal file
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java
Normal file
200
app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/src/main/res/values/strings.xml
Normal file
5
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">tmux Android</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
11
app/src/main/res/values/styles.xml
Normal file
11
app/src/main/res/values/styles.xml
Normal 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>
|
||||||
|
|
||||||
5
app/src/main/res/xml/network_security_config.xml
Normal file
5
app/src/main/res/xml/network_security_config.xml
Normal 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
3
build.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
}
|
||||||
94
docs/android-client-notes.md
Normal file
94
docs/android-client-notes.md
Normal 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
4
gradle.properties
Normal 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
19
settings.gradle.kts
Normal 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")
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user