commit 6107faf72649baaf1105db460b6c02afa7f542f1 Author: Codex Date: Fri Jul 3 13:49:13 2026 +0000 Initial native Android tmux client diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..e6306c5 --- /dev/null +++ b/.github/workflows/android.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e05c780 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +app/build/ +local.properties +signing.properties +*.jks +*.keystore +release/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d64946 --- /dev/null +++ b/README.md @@ -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///releases/latest/download/latest.json +``` + +The workflow uploads both the APK and `latest.json` to each release. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ab131cf --- /dev/null +++ b/app/build.gradle.kts @@ -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 + } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c17f127 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java b/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java new file mode 100644 index 0000000..8da6c61 --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java new file mode 100644 index 0000000..6008903 --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -0,0 +1,1307 @@ +package com.neatstudio.tmuxandroid; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private static final int IMAGE_PICK_REQUEST = 2001; + private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L; + private static final int TERMINAL_COLS = 96; + private static final int TERMINAL_ROWS = 32; + private static final int MAX_TERMINAL_CHARS = 120_000; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private SharedPreferences prefs; + private UpdateManager updateManager; + private SessionApiClient api; + private TerminalSocketClient terminalSocket; + private AppEventSocketClient eventSocket; + private LinearLayout root; + private EditText urlField; + private ProgressBar progressBar; + private TextView statusText; + private TextView terminalText; + private ScrollView terminalScroll; + private EditText inputField; + private String activeSessionName; + private String pendingImageUploadSession; + private final StringBuilder terminalBuffer = new StringBuilder(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + prefs = getSharedPreferences("tmux_android", MODE_PRIVATE); + if (!prefs.contains("server_url")) { + prefs.edit() + .putString("server_url", BuildConfig.DEFAULT_SERVER_URL) + .putString("update_url", BuildConfig.DEFAULT_UPDATE_URL) + .apply(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getWindow().setStatusBarColor(Color.rgb(17, 20, 24)); + getWindow().setNavigationBarColor(Color.rgb(17, 20, 24)); + } + api = new SessionApiClient(getServerUrl()); + setContentView(createRoot()); + updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() { + @Override + public void onChecking(boolean checking) { + progressBar.setVisibility(checking ? View.VISIBLE : View.GONE); + } + + @Override + public void onMessage(String message) { + showMessage(message); + } + }); + renderSessionScreen(); + refreshSessions(); + connectAppEvents(); + maybeCheckForUpdates(); + } + + private View createRoot() { + root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.rgb(17, 20, 24)); + + progressBar = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal); + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.GONE); + + statusText = new TextView(this); + statusText.setTextColor(Color.rgb(210, 215, 224)); + statusText.setTextSize(12); + statusText.setGravity(Gravity.CENTER_VERTICAL); + statusText.setPadding(dp(8), 0, dp(8), 0); + statusText.setBackgroundColor(Color.rgb(29, 34, 41)); + statusText.setSingleLine(true); + setStatus("Ready"); + return root; + } + + private void renderSessionScreen() { + closeTerminalSocket(); + activeSessionName = null; + root.removeAllViews(); + root.addView(createServerBar(), matchWrap()); + root.addView(progressBar, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(3) + )); + + ScrollView scroll = new ScrollView(this); + LinearLayout list = new LinearLayout(this); + list.setOrientation(LinearLayout.VERTICAL); + list.setPadding(dp(8), dp(8), dp(8), dp(8)); + list.setTag("session-list"); + scroll.addView(list); + root.addView(scroll, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1 + )); + root.addView(statusText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(28) + )); + } + + private LinearLayout createServerBar() { + LinearLayout toolbar = new LinearLayout(this); + toolbar.setOrientation(LinearLayout.HORIZONTAL); + toolbar.setGravity(Gravity.CENTER_VERTICAL); + toolbar.setPadding(dp(8), dp(6), dp(8), dp(6)); + toolbar.setBackgroundColor(Color.rgb(17, 20, 24)); + + urlField = new EditText(this); + urlField.setSingleLine(true); + urlField.setTextColor(Color.WHITE); + urlField.setHintTextColor(Color.rgb(150, 158, 168)); + urlField.setTextSize(14); + urlField.setInputType(InputType.TYPE_TEXT_VARIATION_URI); + urlField.setImeOptions(EditorInfo.IME_ACTION_GO); + urlField.setText(getServerUrl()); + urlField.setSelectAllOnFocus(true); + urlField.setOnEditorActionListener((view, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_GO) { + saveServerAndRefresh(); + return true; + } + return false; + }); + toolbar.addView(urlField, new LinearLayout.LayoutParams(0, dp(42), 1)); + toolbar.addView(toolbarButton("Go", view -> saveServerAndRefresh())); + toolbar.addView(toolbarButton("New", view -> promptCreateSession())); + toolbar.addView(toolbarButton("Update", view -> updateManager.check(true))); + toolbar.addView(toolbarButton("More", view -> showMainActions())); + return toolbar; + } + + private void refreshSessions() { + setStatus("Loading sessions from " + api.getBaseUrl()); + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + try { + List sessions = api.getSessions(); + runOnUiThread(() -> renderSessionList(sessions)); + } catch (Exception error) { + runOnUiThread(() -> showMessage("Session load failed: " + error.getMessage())); + } finally { + runOnUiThread(() -> progressBar.setVisibility(View.GONE)); + } + }); + } + + private void renderSessionList(List sessions) { + LinearLayout list = root.findViewWithTag("session-list"); + if (list == null) { + return; + } + list.removeAllViews(); + if (sessions.isEmpty()) { + TextView empty = bodyText("No tmux sessions"); + empty.setPadding(dp(8), dp(24), dp(8), dp(24)); + list.addView(empty); + } + for (SessionSummary session : sessions) { + list.addView(sessionRow(session), matchWrap()); + } + setStatus("Loaded " + sessions.size() + " sessions"); + } + + private View sessionRow(SessionSummary session) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.VERTICAL); + row.setPadding(dp(10), dp(10), dp(10), dp(10)); + row.setBackgroundColor(Color.rgb(29, 34, 41)); + + TextView title = new TextView(this); + title.setText(session.name); + title.setTextColor(Color.WHITE); + title.setTextSize(17); + title.setTypeface(Typeface.DEFAULT_BOLD); + + String detail = session.status + + " windows:" + session.windows + + " panes:" + session.paneCount + + textPart(session.currentCommand) + + textPart(session.currentPath); + TextView meta = bodyText(detail); + meta.setPadding(0, dp(4), 0, dp(8)); + + LinearLayout actions = new LinearLayout(this); + actions.setOrientation(LinearLayout.HORIZONTAL); + actions.addView(toolbarButton("Open", view -> openTerminal(session.name))); + actions.addView(toolbarButton("Kill", view -> confirmKill(session.name))); + actions.addView(toolbarButton("More", view -> showSessionActions(session.name))); + + row.addView(title); + row.addView(meta); + row.addView(actions); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + params.bottomMargin = dp(8); + row.setLayoutParams(params); + return row; + } + + private void promptCreateSession() { + EditText input = new EditText(this); + input.setSingleLine(true); + input.setHint("session-name"); + input.setInputType(InputType.TYPE_CLASS_TEXT); + new AlertDialog.Builder(this) + .setTitle("Create tmux session") + .setView(input) + .setNegativeButton("Cancel", null) + .setPositiveButton("Create", (dialog, which) -> { + String name = input.getText().toString().trim(); + if (!name.matches("[A-Za-z0-9._-]+")) { + showMessage("Invalid session name"); + return; + } + executor.execute(() -> { + try { + api.createSession(name); + runOnUiThread(() -> { + showMessage("Created " + name); + refreshSessions(); + }); + } catch (Exception error) { + runOnUiThread(() -> showMessage("Create failed: " + error.getMessage())); + } + }); + }) + .show(); + } + + private void confirmKill(String sessionName) { + new AlertDialog.Builder(this) + .setTitle("Kill session") + .setMessage("Kill tmux session \"" + sessionName + "\"?") + .setNegativeButton("Cancel", null) + .setPositiveButton("Kill", (dialog, which) -> executor.execute(() -> { + try { + api.killSession(sessionName); + runOnUiThread(() -> { + showMessage("Killed " + sessionName); + refreshSessions(); + }); + } catch (Exception error) { + runOnUiThread(() -> showMessage("Kill failed: " + error.getMessage())); + } + })) + .show(); + } + + private void openTerminal(String sessionName) { + activeSessionName = sessionName; + terminalBuffer.setLength(0); + root.removeAllViews(); + root.addView(createTerminalTopBar(sessionName), matchWrap()); + + terminalScroll = new ScrollView(this); + terminalText = new TextView(this); + terminalText.setTextColor(Color.rgb(230, 235, 242)); + terminalText.setTextSize(12); + terminalText.setTypeface(Typeface.MONOSPACE); + terminalText.setTextIsSelectable(true); + terminalText.setPadding(dp(8), dp(8), dp(8), dp(8)); + terminalText.setBackgroundColor(Color.BLACK); + terminalScroll.addView(terminalText, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + root.addView(terminalScroll, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1 + )); + root.addView(createInputBar(), matchWrap()); + root.addView(createSoftKeyBar(), new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(46) + )); + root.addView(statusText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(28) + )); + connectTerminal(sessionName); + } + + private LinearLayout createTerminalTopBar(String sessionName) { + LinearLayout bar = new LinearLayout(this); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + bar.setPadding(dp(8), dp(6), dp(8), dp(6)); + bar.setBackgroundColor(Color.rgb(17, 20, 24)); + bar.addView(toolbarButton("Back", view -> { + renderSessionScreen(); + refreshSessions(); + })); + TextView title = new TextView(this); + title.setText(sessionName); + title.setTextColor(Color.WHITE); + title.setTextSize(16); + title.setTypeface(Typeface.DEFAULT_BOLD); + title.setPadding(dp(10), 0, dp(10), 0); + bar.addView(title, new LinearLayout.LayoutParams(0, dp(42), 1)); + bar.addView(toolbarButton("Reconnect", view -> connectTerminal(sessionName))); + bar.addView(toolbarButton("More", view -> showTerminalActions(sessionName))); + return bar; + } + + private void showMainActions() { + String[] items = { + "Health", + "Server status", + "Timeline", + "Preferences", + "All session details", + "Pane details", + "Kanban projects", + "Create kanban project", + "Delete kanban project", + "Remove kanban session", + "Group messages", + "Send group message", + "Scan group message", + "Post hook event", + "Upload image file", + "Upload image URL", + "Image preview info", + "Open image preview" + }; + new AlertDialog.Builder(this) + .setTitle("Native API actions") + .setItems(items, (dialog, which) -> { + switch (which) { + case 0: + showRaw("Health", () -> api.health()); + break; + case 1: + showRaw("Server status", () -> api.serverStatus()); + break; + case 2: + showRaw("Timeline", () -> api.timeline(50)); + break; + case 3: + showRaw("Preferences", () -> api.preferences()); + break; + case 4: + showRaw("All session details", () -> api.sessionsAll()); + break; + case 5: + showRaw("Pane details", () -> api.sessionsPanes()); + break; + case 6: + showRaw("Kanban projects", () -> api.kanbanProjects()); + break; + case 7: + promptCreateKanbanProject(); + break; + case 8: + promptDeleteKanbanProject(); + break; + case 9: + promptRemoveKanbanSession(); + break; + case 10: + promptGroupMessages(); + break; + case 11: + promptSendGroupMessage(); + break; + case 12: + promptScanGroupMessage(); + break; + case 13: + promptPostHookEvent(); + break; + case 14: + promptUploadImageFile(); + break; + case 15: + promptUploadImageUrl(); + break; + case 16: + promptImagePreviewInfo(); + break; + case 17: + promptOpenImagePreview(); + break; + default: + break; + } + }) + .show(); + } + + private void showSessionActions(String sessionName) { + String[] items = { + "Status", + "Rename", + "Send command", + "Split horizontal", + "Split vertical", + "Select pane", + "Kill pane", + "Pin session", + "Unpin session", + "Mute session", + "Unmute session", + "Session settings", + "Add to kanban project", + "Post hook event" + }; + new AlertDialog.Builder(this) + .setTitle(sessionName) + .setItems(items, (dialog, which) -> { + switch (which) { + case 0: + showRaw("Session status", () -> api.sessionStatus(sessionName)); + break; + case 1: + promptRenameSession(sessionName); + break; + case 2: + promptSendCommand(sessionName); + break; + case 3: + runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal")); + break; + case 4: + runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical")); + break; + case 5: + promptPaneId("Select pane", paneId -> api.selectPane(sessionName, paneId)); + break; + case 6: + promptPaneId("Kill pane", paneId -> api.killPane(sessionName, paneId)); + break; + case 7: + runApiAction("Pin session", () -> api.setPinned(sessionName, true)); + break; + case 8: + runApiAction("Unpin session", () -> api.setPinned(sessionName, false)); + break; + case 9: + runApiAction("Mute session", () -> api.setMuted(sessionName, true)); + break; + case 10: + runApiAction("Unmute session", () -> api.setMuted(sessionName, false)); + break; + case 11: + promptSessionSettings(sessionName); + break; + case 12: + promptAddKanbanSession(sessionName); + break; + case 13: + promptPostHookEvent(sessionName); + break; + default: + break; + } + }) + .show(); + } + + private void showTerminalActions(String sessionName) { + String[] items = { + "Clear local view and tmux history", + "Split horizontal", + "Split vertical", + "Page up", + "Page down", + "Session status", + "Send command" + }; + new AlertDialog.Builder(this) + .setTitle(sessionName) + .setItems(items, (dialog, which) -> { + switch (which) { + case 0: + terminalBuffer.setLength(0); + terminalText.setText(""); + if (terminalSocket != null) { + terminalSocket.clearHistory(); + } + break; + case 1: + runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal")); + break; + case 2: + runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical")); + break; + case 3: + if (terminalSocket != null) { + terminalSocket.scroll(-TERMINAL_ROWS); + } + break; + case 4: + if (terminalSocket != null) { + terminalSocket.scroll(TERMINAL_ROWS); + } + break; + case 5: + showRaw("Session status", () -> api.sessionStatus(sessionName)); + break; + case 6: + promptSendCommand(sessionName); + break; + default: + break; + } + }) + .show(); + } + + private LinearLayout createInputBar() { + LinearLayout bar = new LinearLayout(this); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + bar.setPadding(dp(8), dp(5), dp(8), dp(5)); + bar.setBackgroundColor(Color.rgb(17, 20, 24)); + + inputField = new EditText(this); + inputField.setSingleLine(true); + inputField.setTextColor(Color.WHITE); + inputField.setHintTextColor(Color.rgb(150, 158, 168)); + inputField.setHint("type command or text"); + inputField.setImeOptions(EditorInfo.IME_ACTION_SEND); + inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + inputField.setOnEditorActionListener((view, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEND) { + sendLine(); + return true; + } + return false; + }); + bar.addView(inputField, new LinearLayout.LayoutParams(0, dp(42), 1)); + bar.addView(toolbarButton("Send", view -> sendLine())); + return bar; + } + + private void promptRenameSession(String sessionName) { + promptText("Rename session", sessionName, sessionName, nextName -> { + if (!nextName.matches("[A-Za-z0-9._-]+")) { + throw new IllegalArgumentException("Invalid session name"); + } + api.renameSession(sessionName, nextName); + }); + } + + private void promptSendCommand(String sessionName) { + promptText("Send command", "command", "", command -> api.sendCommand(sessionName, command)); + } + + private void promptPaneId(String title, TextApiAction action) { + promptText(title, "%1", "", action); + } + + private void promptCreateKanbanProject() { + LinearLayout form = formRoot(); + EditText name = formField(form, "Project name", "project"); + EditText path = formField(form, "Path", "~"); + EditText server = formField(form, "SSH server optional", ""); + new AlertDialog.Builder(this) + .setTitle("Create kanban project") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Create", (dialog, which) -> runApiAction("Create kanban project", () -> + api.createKanbanProject( + name.getText().toString().trim(), + defaultValue(path.getText().toString().trim(), "~"), + server.getText().toString().trim() + ) + )) + .show(); + } + + private void promptDeleteKanbanProject() { + promptText("Delete kanban project", "project", "", projectName -> api.deleteKanbanProject(projectName)); + } + + private void promptAddKanbanSession(String sessionName) { + promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName)); + } + + private void promptRemoveKanbanSession() { + LinearLayout form = formRoot(); + EditText project = formField(form, "Project", ""); + EditText agent = formField(form, "Agent/session name", ""); + EditText kill = formField(form, "Kill too: true or false", "false"); + new AlertDialog.Builder(this) + .setTitle("Remove kanban session") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Remove", (dialog, which) -> runApiAction("Remove kanban session", () -> + api.removeKanbanSession( + project.getText().toString().trim(), + agent.getText().toString().trim(), + Boolean.parseBoolean(kill.getText().toString().trim()) + ) + )) + .show(); + } + + private void promptSessionSettings(String sessionName) { + LinearLayout form = formRoot(); + EditText fontSize = formField(form, "Font size", "14"); + EditText fontFamily = formField(form, "Font family", "monospace"); + EditText lineHeight = formField(form, "Line height", "1.25"); + EditText themeId = formField(form, "Theme ID", "dark"); + new AlertDialog.Builder(this) + .setTitle("Session settings") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Save", (dialog, which) -> runApiAction("Session settings", () -> + api.updateSessionSettings( + sessionName, + Integer.parseInt(defaultValue(fontSize.getText().toString().trim(), "14")), + defaultValue(fontFamily.getText().toString().trim(), "monospace"), + Double.parseDouble(defaultValue(lineHeight.getText().toString().trim(), "1.25")), + defaultValue(themeId.getText().toString().trim(), "dark") + ) + )) + .show(); + } + + private void promptGroupMessages() { + promptText("Group messages", "project", "", projectName -> + runOnUiThread(() -> showRaw("Group messages", () -> api.groupMessages(projectName))) + ); + } + + private void promptSendGroupMessage() { + LinearLayout form = formRoot(); + EditText project = formField(form, "Project", ""); + EditText from = formField(form, "From session", ""); + EditText kind = formField(form, "Kind: task or report", "task"); + EditText targetType = formField(form, "Target: session, others, role", "others"); + EditText targetValue = formField(form, "Target value", ""); + EditText body = formField(form, "Message", ""); + body.setMinLines(3); + body.setSingleLine(false); + new AlertDialog.Builder(this) + .setTitle("Send group message") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Send", (dialog, which) -> runApiAction("Send group message", () -> + api.sendGroupMessage( + project.getText().toString().trim(), + from.getText().toString().trim(), + defaultValue(kind.getText().toString().trim(), "task"), + defaultValue(targetType.getText().toString().trim(), "others"), + targetValue.getText().toString().trim(), + body.getText().toString() + ) + )) + .show(); + } + + private void promptScanGroupMessage() { + LinearLayout form = formRoot(); + EditText project = formField(form, "Project", ""); + EditText message = formField(form, "Message ID", ""); + new AlertDialog.Builder(this) + .setTitle("Scan group message") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Scan", (dialog, which) -> runApiAction("Scan group message", () -> + api.scanGroupMessage( + project.getText().toString().trim(), + message.getText().toString().trim() + ) + )) + .show(); + } + + private void promptPostHookEvent() { + promptPostHookEvent(""); + } + + private void promptPostHookEvent(String presetSessionName) { + LinearLayout form = formRoot(); + EditText session = formField(form, "Session", presetSessionName); + EditText title = formField(form, "Title", "Android event"); + EditText status = formField(form, "Status", "info"); + EditText body = formField(form, "Body", ""); + body.setMinLines(2); + body.setSingleLine(false); + new AlertDialog.Builder(this) + .setTitle("Post hook event") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Post", (dialog, which) -> runApiAction("Post hook event", () -> + api.postHookEvent( + session.getText().toString().trim(), + title.getText().toString().trim(), + defaultValue(status.getText().toString().trim(), "info"), + body.getText().toString() + ) + )) + .show(); + } + + private void promptUploadImageUrl() { + LinearLayout form = formRoot(); + EditText session = formField(form, "Session optional", ""); + EditText url = formField(form, "Image URL", ""); + new AlertDialog.Builder(this) + .setTitle("Upload image URL") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Upload", (dialog, which) -> showRaw("Image upload", () -> + api.uploadImageUrl( + session.getText().toString().trim(), + url.getText().toString().trim() + ) + )) + .show(); + } + + private void promptUploadImageFile() { + EditText session = new EditText(this); + session.setSingleLine(true); + session.setHint("session optional"); + new AlertDialog.Builder(this) + .setTitle("Upload image file") + .setView(session) + .setNegativeButton("Cancel", null) + .setPositiveButton("Choose", (dialog, which) -> { + pendingImageUploadSession = session.getText().toString().trim(); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + startActivityForResult(Intent.createChooser(intent, "Choose image"), IMAGE_PICK_REQUEST); + }) + .show(); + } + + private void promptImagePreviewInfo() { + LinearLayout form = formRoot(); + EditText path = formField(form, "Path", ""); + EditText basePath = formField(form, "Base path optional", ""); + new AlertDialog.Builder(this) + .setTitle("Image preview info") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Load", (dialog, which) -> showRaw("Image preview info", () -> + api.imagePreviewInfo( + path.getText().toString().trim(), + basePath.getText().toString().trim() + ) + )) + .show(); + } + + private void promptOpenImagePreview() { + LinearLayout form = formRoot(); + EditText path = formField(form, "Path", ""); + EditText basePath = formField(form, "Base path optional", ""); + new AlertDialog.Builder(this) + .setTitle("Open image preview") + .setView(form) + .setNegativeButton("Cancel", null) + .setPositiveButton("Open", (dialog, which) -> showImagePreview( + path.getText().toString().trim(), + basePath.getText().toString().trim() + )) + .show(); + } + + private void showImagePreview(String path, String basePath) { + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + try { + byte[] bytes = api.imagePreview(path, basePath); + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) { + throw new IllegalStateException("Preview is not a supported bitmap"); + } + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + ImageView image = new ImageView(this); + image.setImageBitmap(bitmap); + image.setAdjustViewBounds(true); + image.setPadding(dp(8), dp(8), dp(8), dp(8)); + new AlertDialog.Builder(this) + .setTitle("Image preview") + .setView(image) + .setPositiveButton("Close", null) + .show(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showMessage("Image preview failed: " + error.getMessage()); + }); + } + }); + } + + private void promptText(String title, String hint, String value, TextApiAction action) { + EditText input = new EditText(this); + input.setSingleLine(true); + input.setHint(hint); + input.setText(value); + input.setSelectAllOnFocus(true); + new AlertDialog.Builder(this) + .setTitle(title) + .setView(input) + .setNegativeButton("Cancel", null) + .setPositiveButton("OK", (dialog, which) -> { + String text = input.getText().toString().trim(); + runApiAction(title, () -> action.run(text)); + }) + .show(); + } + + private void runApiAction(String label, ApiAction action) { + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + try { + action.run(); + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showMessage(label + " done"); + if (activeSessionName == null) { + refreshSessions(); + } + }); + } catch (Exception error) { + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showMessage(label + " failed: " + error.getMessage()); + }); + } + }); + } + + private void showRaw(String title, Callable loader) { + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + try { + String text = loader.call(); + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showTextDialog(title, text); + }); + } catch (Exception error) { + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showMessage(title + " failed: " + error.getMessage()); + }); + } + }); + } + + private void showTextDialog(String title, String text) { + ScrollView scroll = new ScrollView(this); + TextView content = new TextView(this); + content.setText(text == null || text.isEmpty() ? "(empty)" : text); + content.setTextColor(Color.rgb(20, 24, 30)); + content.setTextSize(12); + content.setTypeface(Typeface.MONOSPACE); + content.setTextIsSelectable(true); + content.setPadding(dp(12), dp(12), dp(12), dp(12)); + scroll.addView(content, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + new AlertDialog.Builder(this) + .setTitle(title) + .setView(scroll) + .setPositiveButton("Close", null) + .show(); + } + + private LinearLayout formRoot() { + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.setPadding(dp(18), dp(8), dp(18), 0); + return form; + } + + private EditText formField(LinearLayout form, String label, String value) { + TextView title = new TextView(this); + title.setText(label); + title.setTextColor(Color.DKGRAY); + title.setTextSize(12); + EditText input = new EditText(this); + input.setSingleLine(true); + input.setText(value); + input.setSelectAllOnFocus(true); + form.addView(title); + form.addView(input, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + return input; + } + + private HorizontalScrollView createSoftKeyBar() { + HorizontalScrollView scroller = new HorizontalScrollView(this); + scroller.setHorizontalScrollBarEnabled(false); + scroller.setBackgroundColor(Color.rgb(22, 27, 34)); + + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(dp(6), dp(4), dp(6), dp(4)); + addSoftKey(row, "Enter", "\r"); + addSoftKey(row, "Esc", "\u001b"); + addSoftKey(row, "Tab", "\t"); + addSoftKey(row, "^C", "\u0003"); + addSoftKey(row, "^D", "\u0004"); + addSoftKey(row, "^L", "\u000c"); + addSoftKey(row, "^R", "\u0012"); + addSoftKey(row, "^A", "\u0001"); + addSoftKey(row, "^E", "\u0005"); + addSoftKey(row, "^V", "\u0016"); + addSoftKey(row, "Left", "\u001b[D"); + addSoftKey(row, "Down", "\u001b[B"); + addSoftKey(row, "Up", "\u001b[A"); + addSoftKey(row, "Right", "\u001b[C"); + addSoftKey(row, "PgUp", "\u001b[5~"); + addSoftKey(row, "PgDn", "\u001b[6~"); + addSoftKey(row, "Home", "\u001b[H"); + addSoftKey(row, "End", "\u001b[F"); + addSoftButton(row, "Paste", view -> pasteClipboard()); + scroller.addView(row, new HorizontalScrollView.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + return scroller; + } + + private void connectTerminal(String sessionName) { + closeTerminalSocket(); + setStatus("Connecting " + sessionName); + appendTerminal("[connecting]\r\n"); + terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() { + @Override + public void onConnected() { + runOnUiThread(() -> setStatus("Connected " + sessionName)); + } + + @Override + public void onOutput(String data) { + runOnUiThread(() -> appendTerminal(data)); + } + + @Override + public void onError(String message) { + runOnUiThread(() -> { + appendTerminal("\r\n[error] " + message + "\r\n"); + setStatus("Terminal error: " + message); + }); + } + + @Override + public void onClosed() { + runOnUiThread(() -> setStatus("Disconnected " + sessionName)); + } + }); + terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS); + } + + private void sendLine() { + if (inputField == null) { + return; + } + String text = inputField.getText().toString(); + if (text.isEmpty()) { + sendTerminalInput("\r"); + } else { + sendTerminalInput(text + "\r"); + inputField.setText(""); + } + } + + private void sendTerminalInput(String data) { + if (activeSessionName == null) { + return; + } + TerminalSocketClient socket = terminalSocket; + if (socket != null) { + socket.sendInput(data); + return; + } + executor.execute(() -> { + try { + for (int i = 0; i < data.length(); i += 200) { + api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length()))); + } + } catch (Exception error) { + runOnUiThread(() -> showMessage("Input failed: " + error.getMessage())); + } + }); + } + + private void pasteClipboard() { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null || !clipboard.hasPrimaryClip()) { + return; + } + ClipData clip = clipboard.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return; + } + CharSequence text = clip.getItemAt(0).coerceToText(this); + if (text != null && text.length() > 0) { + sendTerminalInput(text.toString()); + } + } + + private void appendTerminal(String data) { + terminalBuffer.append(data); + if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { + terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS); + } + terminalText.setText(stripAnsiForBasicView(terminalBuffer.toString())); + terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); + } + + private String stripAnsiForBasicView(String text) { + return text + .replaceAll("\u001B\\[[0-?]*[ -/]*[@-~]", "") + .replace("\r", ""); + } + + private void saveServerAndRefresh() { + String url = normalizeServerUrl(urlField.getText().toString()); + prefs.edit().putString("server_url", url).apply(); + api = new SessionApiClient(url); + urlField.setText(url); + connectAppEvents(); + refreshSessions(); + } + + private String getServerUrl() { + return prefs.getString("server_url", BuildConfig.DEFAULT_SERVER_URL); + } + + private String normalizeServerUrl(String raw) { + String value = raw.trim(); + if (value.isEmpty()) { + value = BuildConfig.DEFAULT_SERVER_URL; + } + if (!value.startsWith("http://") && !value.startsWith("https://")) { + value = "http://" + value; + } + Uri parsed = Uri.parse(value); + if (parsed.getPort() == -1 && parsed.getHost() != null) { + Uri.Builder builder = parsed.buildUpon().encodedAuthority(parsed.getHost() + ":3000"); + value = builder.build().toString(); + } + while (value.endsWith("/") && value.length() > "http://x".length()) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + private void maybeCheckForUpdates() { + long now = System.currentTimeMillis(); + long last = prefs.getLong("last_update_check_ms", 0L); + if (now - last < AUTO_UPDATE_INTERVAL_MS) { + return; + } + prefs.edit().putLong("last_update_check_ms", now).apply(); + root.postDelayed(() -> updateManager.check(false), 2000L); + } + + private Button toolbarButton(String label, View.OnClickListener listener) { + Button button = new Button(this); + button.setText(label); + button.setTextSize(12); + button.setAllCaps(false); + button.setMinWidth(0); + button.setMinimumWidth(0); + button.setPadding(dp(8), 0, dp(8), 0); + button.setOnClickListener(listener); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(42) + ); + params.leftMargin = dp(5); + button.setLayoutParams(params); + return button; + } + + private void addSoftKey(LinearLayout row, String label, String sequence) { + addSoftButton(row, label, view -> sendTerminalInput(sequence)); + } + + private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) { + Button button = toolbarButton(label, listener); + button.setMinWidth(dp(50)); + button.setMinimumWidth(dp(50)); + row.addView(button, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + } + + private TextView bodyText(String text) { + TextView view = new TextView(this); + view.setText(text); + view.setTextColor(Color.rgb(205, 213, 224)); + view.setTextSize(13); + return view; + } + + private String textPart(String value) { + return value == null || value.isEmpty() ? "" : " " + value; + } + + private String defaultValue(String value, String fallback) { + return value == null || value.isEmpty() ? fallback : value; + } + + private LinearLayout.LayoutParams matchWrap() { + return new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + } + + private void showMessage(String message) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + setStatus(message); + } + + private void setStatus(String message) { + if (statusText != null) { + statusText.setText(message); + } + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + + private void closeTerminalSocket() { + if (terminalSocket != null) { + terminalSocket.close(); + terminalSocket = null; + } + } + + private void connectAppEvents() { + if (eventSocket != null) { + eventSocket.close(); + } + eventSocket = new AppEventSocketClient(new AppEventSocketClient.Listener() { + @Override + public void onMessage(String text) { + runOnUiThread(() -> handleAppEvent(text)); + } + + @Override + public void onClosed() { + runOnUiThread(() -> setStatus("Event stream disconnected")); + } + }); + eventSocket.connect(api.getBaseUrl()); + } + + private void handleAppEvent(String text) { + try { + JSONObject event = new JSONObject(text); + String type = event.optString("type", ""); + if ("hello".equals(type)) { + setStatus("Event stream connected"); + return; + } + if ("sessions-invalidated".equals(type)) { + setStatus("Sessions changed: " + event.optString("reason", "update")); + if (activeSessionName == null) { + refreshSessions(); + } + return; + } + if ("hook-event".equals(type)) { + showMessage(event.optString("title", "Hook event")); + return; + } + setStatus(type.isEmpty() ? "Event received" : type); + } catch (Exception error) { + setStatus("Event received"); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != IMAGE_PICK_REQUEST) { + return; + } + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + pendingImageUploadSession = null; + return; + } + Uri uri = data.getData(); + String sessionName = pendingImageUploadSession == null ? "" : pendingImageUploadSession; + pendingImageUploadSession = null; + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + try (InputStream input = getContentResolver().openInputStream(uri)) { + if (input == null) { + throw new IllegalStateException("Cannot open selected image"); + } + String response = api.uploadImage(sessionName, readAllBytes(input)); + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showTextDialog("Image upload", response); + }); + } catch (Exception error) { + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showMessage("Image upload failed: " + error.getMessage()); + }); + } + }); + } + + private byte[] readAllBytes(InputStream input) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + BufferedInputStream buffered = new BufferedInputStream(input); + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = buffered.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + + @Override + public void onBackPressed() { + if (activeSessionName != null) { + renderSessionScreen(); + refreshSessions(); + return; + } + super.onBackPressed(); + } + + @Override + protected void onDestroy() { + closeTerminalSocket(); + if (eventSocket != null) { + eventSocket.close(); + eventSocket = null; + } + executor.shutdownNow(); + super.onDestroy(); + } + + private interface ApiAction { + void run() throws Exception; + } + + private interface TextApiAction { + void run(String text) throws Exception; + } +} diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/ReleaseInfo.java b/app/src/main/java/com/neatstudio/tmuxandroid/ReleaseInfo.java new file mode 100644 index 0000000..f45f360 --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/ReleaseInfo.java @@ -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; + } +} + diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/SessionApiClient.java b/app/src/main/java/com/neatstudio/tmuxandroid/SessionApiClient.java new file mode 100644 index 0000000..f414004 --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/SessionApiClient.java @@ -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 getSessions() throws Exception { + String text = request("GET", "/api/sessions", null); + JSONArray array = new JSONArray(text); + List 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; + } +} diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/SessionSummary.java b/app/src/main/java/com/neatstudio/tmuxandroid/SessionSummary.java new file mode 100644 index 0000000..f729257 --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/SessionSummary.java @@ -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; + } +} + diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java new file mode 100644 index 0000000..cc2b05b --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/UpdateFileProvider.java b/app/src/main/java/com/neatstudio/tmuxandroid/UpdateFileProvider.java new file mode 100644 index 0000000..4d8ac6a --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/UpdateFileProvider.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java b/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java new file mode 100644 index 0000000..7788eda --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java @@ -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)); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..15b0f64 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + tmux Android + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0ca8840 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..be5bdbc --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a0dd15f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "8.7.3" apply false +} diff --git a/docs/android-client-notes.md b/docs/android-client-notes.md new file mode 100644 index 0000000..4d0cde1 --- /dev/null +++ b/docs/android-client-notes.md @@ -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` diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..afa71c4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=false +android.nonTransitiveRClass=true + diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d25c202 --- /dev/null +++ b/settings.gradle.kts @@ -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") +