Compare commits

...
20 Commits
Author SHA1 Message Date
Codex db306679d7 Polish native controls
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 11m43s
2026-07-06 09:34:39 +00:00
Codex 19837d05bc Improve terminal rendering and keyboard insets
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 08:48:42 +00:00
Codex 73cc8c8181 Document public Gitea update mirror
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m44s
2026-07-06 08:16:13 +00:00
Codex b64ef21831 Add Tailscale API probe action
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m40s
2026-07-06 07:14:01 +00:00
Codex e077fa5d14 Add tmux terminal shortcut controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m0s
2026-07-06 03:47:56 +00:00
Codex d655fd7e09 Clarify app download and update paths
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m42s
2026-07-05 19:06:31 +00:00
Codex 4c5cd385b7 Use headless JDK in Gitea Android workflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m51s
2026-07-05 17:42:04 +00:00
Codex 3174b77923 Use Android SDK container for Gitea build
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:38:21 +00:00
Codex 226f2e781b Use clean Gitea Android build workflow
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:37:12 +00:00
Codex f35595706e Install JDK in Gitea build bootstrap
Gitea Smoke / smoke (push) Waiting to run
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / tools (push) Failing after 0s
Gitea Android APK / lsremote (push) Successful in 3s
Gitea Android APK / secret (push) Successful in 1s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:31:51 +00:00
Codex 5d821de7f7 Add Gitea git access diagnostics
Gitea Android APK / probe (push) Successful in 2s
Gitea Android APK / tools (push) Failing after 0s
Gitea Android APK / lsremote (push) Successful in 0s
Gitea Android APK / secret (push) Successful in 0s
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / checkout (push) Successful in 4s
2026-07-05 17:30:10 +00:00
Codex eb646cc0d9 Diagnose Gitea clone secret and auth format
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / secret (push) Successful in 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / probe (push) Successful in 2s
2026-07-05 17:29:11 +00:00
Codex 46b94ebdad Use Gitea clone token secret in workflow
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:27:45 +00:00
Codex 72780eb98f Add Gitea workflow diagnostic jobs
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:25:56 +00:00
Codex e9615cb8fe Avoid unsupported Gitea workflow token syntax
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 4s
2026-07-05 17:24:53 +00:00
Codex 9e3e6d99cc Install Gitea workflow tools before checkout
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:23:57 +00:00
Codex fc673a0df9 Use Gitea job token for workflow clone
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:20:51 +00:00
Codex e144c1d3fb Fix Gitea workflow checkout directory
Gitea Android APK / build (push) Failing after 2s
Gitea Smoke / smoke (push) Successful in 1s
2026-07-05 17:18:45 +00:00
Codex 90db1dc04c Make Gitea Android workflow self contained
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 3s
2026-07-05 17:17:33 +00:00
Codex e47c8c15d4 Simplify Gitea Android workflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:10:54 +00:00
5 changed files with 390 additions and 197 deletions
+53 -146
View File
@@ -1,160 +1,67 @@
name: Android APK name: Gitea Android APK
on: on: [push]
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: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || inputs.publish_release == 'true' }}
steps: 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 - name: Build APK
shell: bash env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: | run: |
if [[ "${{ steps.signing.outputs.signed }}" == "true" ]]; then set -eu
BUILD_TASK=":app:assembleRelease"
else if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
BUILD_TASK=":app:assembleDebug" apt-get update
apt-get install -y git curl unzip
fi fi
gradle "${BUILD_TASK}" \ if ! command -v java >/dev/null 2>&1; then
apt-get update
apt-get install -y openjdk-17-jdk-headless
fi
java -version
WORKDIR="${PWD}/source"
rm -rf "${WORKDIR}"
SERVER_URL="${GITHUB_SERVER_URL:-https://gitea.neatcn.com}"
REPOSITORY="${GITHUB_REPOSITORY:-tmux/tmux-browser-android}"
AUTH_SERVER_URL="$(printf '%s' "${SERVER_URL}" | sed "s#https://#https://gouki:${CLONE_TOKEN}@#")"
git clone "${AUTH_SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
cd "${WORKDIR}"
git checkout "${GITHUB_SHA:-main}"
export GRADLE_HOME="${PWD}/.ci/gradle-8.10.2"
mkdir -p .ci
if [ ! -x "${GRADLE_HOME}/bin/gradle" ]; then
curl -fsSL -o .ci/gradle.zip https://services.gradle.org/distributions/gradle-8.10.2-bin.zip
unzip -q .ci/gradle.zip -d .ci
fi
export PATH="${GRADLE_HOME}/bin:${PATH}"
export ANDROID_HOME="${PWD}/.ci/android-sdk"
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
if [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
mkdir -p "${ANDROID_HOME}/cmdline-tools"
curl -fsSL -o .ci/android-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q .ci/android-tools.zip -d "${ANDROID_HOME}/cmdline-tools"
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${CMDLINE_TOOLS}"
fi
export PATH="${CMDLINE_TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER:-0}"
VERSION_CODE=$((2000 + ${GITHUB_RUN_NUMBER:-0}))
gradle :app:assembleDebug \
-PversionCode="${VERSION_CODE}" \ -PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \ -PversionName="${VERSION_NAME}" \
-PrepoSlug="${GITHUB_REPOSITORY}" -PrepoSlug="neatstudio/tmux-browser-android"
- name: Prepare artifacts
shell: bash
run: |
mkdir -p release mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)" APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" "release/tmux-android.apk" cp "${APK_PATH}" release/tmux-android-gitea.apk
cp "${APK_PATH}" "release/tmux-android-${VERSION_NAME}.apk" ls -lh release/tmux-android-gitea.apk
SHA256="$(sha256sum release/tmux-android.apk | awk '{print $1}')" sha256sum release/tmux-android-gitea.apk
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
+41 -11
View File
@@ -9,6 +9,8 @@ APIs directly:
- server URL management for Tailscale `100.x.y.z:3000` APIs - server URL management for Tailscale `100.x.y.z:3000` APIs
- quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`, - quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`,
`100.89.0.11`, and `100.89.0.116` `100.89.0.11`, and `100.89.0.116`
- one-tap probe for the preset Tailscale APIs, showing health/version and
session counts
- native tmux session list - native tmux session list
- create, rename, send command, split pane, select pane, kill pane, pin, mute, - create, rename, send command, split pane, select pane, kill pane, pin, mute,
and kill session through HTTP API and kill session through HTTP API
@@ -18,7 +20,8 @@ APIs directly:
- native API action center for health, server status, timeline, preferences, - native API action center for health, server status, timeline, preferences,
kanban projects, group messages, hook events, image file/URL upload, image kanban projects, group messages, hook events, image file/URL upload, image
preview info, and native image preview display preview info, and native image preview display
- mobile soft-key row for tmux-oriented input - mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- automatic update checks against a GitHub Release manifest - automatic update checks against a GitHub Release manifest
- APK download, SHA-256 verification, and installer handoff - APK download, SHA-256 verification, and installer handoff
@@ -77,10 +80,13 @@ Publish a test build by pushing a `v*` tag. That creates a GitHub Release with:
```text ```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/tmux-android.apk
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json
``` ```
Those GitHub links are the primary public install/update channel. Gitea releases
are mirrored as a second public source. This Gitea instance does not support the
GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
Release API as the stable Gitea update entrypoint.
Plain branch builds only create Actions artifacts; they are useful for CI Plain branch builds only create Actions artifacts; they are useful for CI
verification, but releases are the stable download/update channel. verification, but releases are the stable download/update channel.
@@ -93,10 +99,15 @@ incompatible package.
The terminal screen connects to `/ws/terminal` and sends the upstream protocol The terminal screen connects to `/ws/terminal` and sends the upstream protocol
messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`. messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
The first Android UI renders terminal output as basic monospace text with ANSI The first Android UI renders terminal output as monospace text with basic ANSI
escape filtering. It is enough for shell-oriented remote testing, but it is not SGR color support. The terminal view stays bottom-aligned when output is short,
yet a complete xterm-compatible renderer for full-screen TUIs such as `vim` or auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
`top`. opens. It is enough for shell-oriented remote testing, but it is not yet a
complete xterm-compatible renderer for full-screen TUIs such as `vim` or `top`.
The terminal toolbar and shortcut row include tmux prefix helpers. The app sends
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`,
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`.
All app features are native Android controls. Complex server objects such as All app features are native Android controls. Complex server objects such as
kanban projects, preferences, timeline events, group messages, and image metadata kanban projects, preferences, timeline events, group messages, and image metadata
@@ -126,13 +137,32 @@ The default update manifest is:
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
``` ```
The Gitea mirror is: The public manual APK download is:
```text ```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
``` ```
The app tries the selected update source first, then falls back to the GitHub The app tries the selected update source first, then falls back to the GitHub
and Gitea mirrors. manifest and the Gitea release API. The Gitea API endpoint is:
The workflow uploads both the APK and `latest.json` to each release. ```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
Gitea tag-specific assets are also public, for example:
```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/latest.json
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux-android.apk
```
In the app:
- Tap `Update` on the main screen to check `latest.json`, download the APK,
verify SHA-256, and open Android's installer.
- Open `More` -> `Open APK download` to download the current public APK in a
browser.
- Open `More` -> `Permissions / update status` to see the installed version,
update manifest, APK URL, install permission state, and a `Check update`
button.
+6
View File
@@ -12,6 +12,10 @@ val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json") .orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl") val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest") .orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultApkUrl = providers.gradleProperty("defaultApkUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/tmux-android.apk")
val defaultReleasePageUrl = providers.gradleProperty("defaultReleasePageUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest")
val signingProps = Properties() val signingProps = Properties()
val signingFile = rootProject.file("signing.properties") val signingFile = rootProject.file("signing.properties")
@@ -33,6 +37,8 @@ android {
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"") buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"") buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"") buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
} }
buildFeatures { buildFeatures {
@@ -3,6 +3,7 @@ package com.neatstudio.tmuxandroid;
import android.Manifest; import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.ClipData; import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
@@ -13,15 +14,23 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.provider.Settings; import android.provider.Settings;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.InputType; import android.text.InputType;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.Gravity; import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
@@ -98,6 +107,7 @@ public final class MainActivity extends Activity {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24)); getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24)); getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
} }
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
api = new SessionApiClient(getServerUrl()); api = new SessionApiClient(getServerUrl());
setContentView(createRoot()); setContentView(createRoot());
updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() { updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() {
@@ -130,8 +140,8 @@ public final class MainActivity extends Activity {
statusText.setTextColor(Color.rgb(210, 215, 224)); statusText.setTextColor(Color.rgb(210, 215, 224));
statusText.setTextSize(12); statusText.setTextSize(12);
statusText.setGravity(Gravity.CENTER_VERTICAL); statusText.setGravity(Gravity.CENTER_VERTICAL);
statusText.setPadding(dp(8), 0, dp(8), 0); statusText.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackgroundColor(Color.rgb(29, 34, 41)); statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true); statusText.setSingleLine(true);
setStatus("Ready"); setStatus("Ready");
applySystemBarInsets(root); applySystemBarInsets(root);
@@ -140,11 +150,15 @@ public final class MainActivity extends Activity {
private void applySystemBarInsets(View view) { private void applySystemBarInsets(View view) {
view.setOnApplyWindowInsetsListener((target, insets) -> { view.setOnApplyWindowInsetsListener((target, insets) -> {
int bottom = insets.getSystemWindowInsetBottom();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
bottom = Math.max(bottom, insets.getInsets(WindowInsets.Type.ime()).bottom);
}
target.setPadding( target.setPadding(
0, 0,
insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetTop(),
0, 0,
insets.getSystemWindowInsetBottom() bottom
); );
return insets; return insets;
}); });
@@ -202,6 +216,7 @@ public final class MainActivity extends Activity {
urlField.setImeOptions(EditorInfo.IME_ACTION_GO); urlField.setImeOptions(EditorInfo.IME_ACTION_GO);
urlField.setText(getServerUrl()); urlField.setText(getServerUrl());
urlField.setSelectAllOnFocus(true); urlField.setSelectAllOnFocus(true);
styleInput(urlField);
urlField.setOnEditorActionListener((view, actionId, event) -> { urlField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_GO) { if (actionId == EditorInfo.IME_ACTION_GO) {
saveServerAndRefresh(); saveServerAndRefresh();
@@ -302,8 +317,11 @@ public final class MainActivity extends Activity {
private View sessionRow(SessionSummary session) { private View sessionRow(SessionSummary session) {
LinearLayout row = new LinearLayout(this); LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.VERTICAL); row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(dp(10), dp(10), dp(10), dp(10)); row.setPadding(dp(12), dp(11), dp(12), dp(11));
row.setBackgroundColor(Color.rgb(29, 34, 41)); row.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
row.setElevation(dp(1));
}
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText(session.name); title.setText(session.name);
@@ -393,13 +411,18 @@ public final class MainActivity extends Activity {
root.addView(createTerminalTopBar(sessionName), matchWrap()); root.addView(createTerminalTopBar(sessionName), matchWrap());
terminalScroll = new ScrollView(this); terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true);
terminalScroll.setBackgroundColor(Color.rgb(4, 7, 10));
terminalText = new TextView(this); terminalText = new TextView(this);
terminalText.setTextColor(Color.rgb(230, 235, 242)); terminalText.setTextColor(Color.rgb(230, 235, 242));
terminalText.setTextSize(12); terminalText.setTextSize(13);
terminalText.setTypeface(Typeface.MONOSPACE); terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setTextIsSelectable(true); terminalText.setIncludeFontPadding(false);
terminalText.setPadding(dp(8), dp(8), dp(8), dp(8)); terminalText.setLineSpacing(0, 1.05f);
terminalText.setBackgroundColor(Color.BLACK); terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(false);
terminalText.setPadding(dp(10), dp(10), dp(10), dp(10));
terminalText.setBackgroundColor(Color.rgb(4, 7, 10));
terminalScroll.addView(terminalText, new ScrollView.LayoutParams( terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
@@ -446,6 +469,7 @@ public final class MainActivity extends Activity {
private void showMainActions() { private void showMainActions() {
String[] items = { String[] items = {
"Health", "Health",
"Probe Tailscale APIs",
"Server status", "Server status",
"Timeline", "Timeline",
"Preferences", "Preferences",
@@ -463,6 +487,7 @@ public final class MainActivity extends Activity {
"Upload image URL", "Upload image URL",
"Image preview info", "Image preview info",
"Open image preview", "Open image preview",
"Open APK download",
"Update source", "Update source",
"Permissions / update status" "Permissions / update status"
}; };
@@ -474,60 +499,66 @@ public final class MainActivity extends Activity {
showRaw("Health", () -> api.health()); showRaw("Health", () -> api.health());
break; break;
case 1: case 1:
showRaw("Server status", () -> api.serverStatus()); probeServerProfiles();
break; break;
case 2: case 2:
showRaw("Timeline", () -> api.timeline(50)); showRaw("Server status", () -> api.serverStatus());
break; break;
case 3: case 3:
showRaw("Preferences", () -> api.preferences()); showRaw("Timeline", () -> api.timeline(50));
break; break;
case 4: case 4:
showRaw("All session details", () -> api.sessionsAll()); showRaw("Preferences", () -> api.preferences());
break; break;
case 5: case 5:
showRaw("Pane details", () -> api.sessionsPanes()); showRaw("All session details", () -> api.sessionsAll());
break; break;
case 6: case 6:
showRaw("Kanban projects", () -> api.kanbanProjects()); showRaw("Pane details", () -> api.sessionsPanes());
break; break;
case 7: case 7:
promptCreateKanbanProject(); showRaw("Kanban projects", () -> api.kanbanProjects());
break; break;
case 8: case 8:
promptDeleteKanbanProject(); promptCreateKanbanProject();
break; break;
case 9: case 9:
promptRemoveKanbanSession(); promptDeleteKanbanProject();
break; break;
case 10: case 10:
promptGroupMessages(); promptRemoveKanbanSession();
break; break;
case 11: case 11:
promptSendGroupMessage(); promptGroupMessages();
break; break;
case 12: case 12:
promptScanGroupMessage(); promptSendGroupMessage();
break; break;
case 13: case 13:
promptPostHookEvent(); promptScanGroupMessage();
break; break;
case 14: case 14:
promptUploadImageFile(); promptPostHookEvent();
break; break;
case 15: case 15:
promptUploadImageUrl(); promptUploadImageFile();
break; break;
case 16: case 16:
promptImagePreviewInfo(); promptUploadImageUrl();
break; break;
case 17: case 17:
promptOpenImagePreview(); promptImagePreviewInfo();
break; break;
case 18: case 18:
showUpdateSourcePicker(); promptOpenImagePreview();
break; break;
case 19: case 19:
openExternalUrl(BuildConfig.DEFAULT_APK_URL);
break;
case 20:
showUpdateSourcePicker();
break;
case 21:
showPermissionsAndUpdateStatus(); showPermissionsAndUpdateStatus();
break; break;
default: default:
@@ -615,7 +646,12 @@ public final class MainActivity extends Activity {
"Page up", "Page up",
"Page down", "Page down",
"Session status", "Session status",
"Send command" "Send command",
"Tmux prefix",
"Detach tmux client",
"New tmux window",
"Next tmux window",
"Previous tmux window"
}; };
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle(sessionName) .setTitle(sessionName)
@@ -650,6 +686,21 @@ public final class MainActivity extends Activity {
case 6: case 6:
promptSendCommand(sessionName); promptSendCommand(sessionName);
break; break;
case 7:
sendTerminalInput("\u0002");
break;
case 8:
sendTerminalInput("\u0002d");
break;
case 9:
sendTerminalInput("\u0002c");
break;
case 10:
sendTerminalInput("\u0002n");
break;
case 11:
sendTerminalInput("\u0002p");
break;
default: default:
break; break;
} }
@@ -671,6 +722,7 @@ public final class MainActivity extends Activity {
inputField.setHint("type command or text"); inputField.setHint("type command or text");
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND); inputField.setImeOptions(EditorInfo.IME_ACTION_SEND);
inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
styleInput(inputField);
inputField.setOnEditorActionListener((view, actionId, event) -> { inputField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) { if (actionId == EditorInfo.IME_ACTION_SEND) {
sendLine(); sendLine();
@@ -914,12 +966,28 @@ public final class MainActivity extends Activity {
private void showPermissionsAndUpdateStatus() { private void showPermissionsAndUpdateStatus() {
StringBuilder text = new StringBuilder(); StringBuilder text = new StringBuilder();
text.append("Installed app: ")
.append(BuildConfig.VERSION_NAME)
.append(" (")
.append(BuildConfig.VERSION_CODE)
.append(")\n");
text.append("Server: ").append(getServerUrl()).append('\n'); text.append("Server: ").append(getServerUrl()).append('\n');
text.append("Selected update manifest: ") text.append('\n');
text.append("Manual APK download:\n")
.append(BuildConfig.DEFAULT_APK_URL)
.append('\n');
text.append("Release page:\n")
.append(BuildConfig.DEFAULT_RELEASE_PAGE_URL)
.append('\n');
text.append('\n');
text.append("In-app update:\n");
text.append("Tap Update. The app reads latest.json, downloads the APK, verifies SHA-256, then opens Android's installer.\n");
text.append("Selected manifest:\n")
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)) .append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n'); .append('\n');
text.append("GitHub mirror: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n'); text.append("GitHub primary manifest:\n").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea mirror: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n'); text.append("Gitea public mirror API:\n").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append('\n');
text.append("Network: manifest permission, no runtime grant required\n"); text.append("Network: manifest permission, no runtime grant required\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
text.append("Install unknown apps: ") text.append("Install unknown apps: ")
@@ -942,10 +1010,18 @@ public final class MainActivity extends Activity {
.setMessage(text.toString()) .setMessage(text.toString())
.setNegativeButton("Close", null) .setNegativeButton("Close", null)
.setNeutralButton("Install permission", (dialog, which) -> openInstallPermissionSettings()) .setNeutralButton("Install permission", (dialog, which) -> openInstallPermissionSettings())
.setPositiveButton("Notify permission", (dialog, which) -> requestNotificationPermission()) .setPositiveButton("Check update", (dialog, which) -> updateManager.check(true))
.show(); .show();
} }
private void openExternalUrl(String url) {
try {
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
} catch (ActivityNotFoundException error) {
showMessage("No app can open URL");
}
}
private void showUpdateSourcePicker() { private void showUpdateSourcePicker() {
String[] items = { String[] items = {
"GitHub: " + BuildConfig.DEFAULT_UPDATE_URL, "GitHub: " + BuildConfig.DEFAULT_UPDATE_URL,
@@ -971,6 +1047,36 @@ public final class MainActivity extends Activity {
showMessage("Update source: " + url); showMessage("Update source: " + url);
} }
private void probeServerProfiles() {
progressBar.setVisibility(View.VISIBLE);
setStatus("Probing Tailscale APIs...");
executor.execute(() -> {
StringBuilder text = new StringBuilder();
for (String url : SERVER_PROFILES) {
text.append(url).append('\n');
try {
SessionApiClient client = new SessionApiClient(url);
JSONObject health = new JSONObject(client.health());
List<SessionSummary> sessions = client.getSessions();
text.append(" ok: true\n");
text.append(" version: ")
.append(health.optString("version", "unknown"))
.append(" ")
.append(health.optString("commit", ""))
.append('\n');
text.append(" sessions: ").append(sessions.size()).append('\n');
} catch (Exception error) {
text.append(" failed: ").append(error.getMessage()).append('\n');
}
text.append('\n');
}
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
showTextDialog("Tailscale APIs", text.toString());
});
});
}
private void openInstallPermissionSettings() { private void openInstallPermissionSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
showMessage("Install permission is allowed on this Android version"); showMessage("Install permission is allowed on this Android version");
@@ -1113,6 +1219,7 @@ public final class MainActivity extends Activity {
input.setSingleLine(true); input.setSingleLine(true);
input.setText(value); input.setText(value);
input.setSelectAllOnFocus(true); input.setSelectAllOnFocus(true);
styleInput(input);
form.addView(title); form.addView(title);
form.addView(input, new LinearLayout.LayoutParams( form.addView(input, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -1140,6 +1247,13 @@ public final class MainActivity extends Activity {
addSoftKey(row, "^A", "\u0001"); addSoftKey(row, "^A", "\u0001");
addSoftKey(row, "^E", "\u0005"); addSoftKey(row, "^E", "\u0005");
addSoftKey(row, "^V", "\u0016"); addSoftKey(row, "^V", "\u0016");
addSoftKey(row, "^Z", "\u001a");
addSoftKey(row, "^\\", "\u001c");
addSoftKey(row, "Tmux", "\u0002");
addSoftKey(row, "Detach", "\u0002d");
addSoftKey(row, "NewWin", "\u0002c");
addSoftKey(row, "NextWin", "\u0002n");
addSoftKey(row, "PrevWin", "\u0002p");
addSoftKey(row, "Left", "\u001b[D"); addSoftKey(row, "Left", "\u001b[D");
addSoftKey(row, "Down", "\u001b[B"); addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Up", "\u001b[A"); addSoftKey(row, "Up", "\u001b[A");
@@ -1240,14 +1354,118 @@ public final class MainActivity extends Activity {
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS); terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
} }
terminalText.setText(stripAnsiForBasicView(terminalBuffer.toString())); terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
private String stripAnsiForBasicView(String text) { private CharSequence renderAnsiForTerminal(String text) {
return text SpannableStringBuilder output = new SpannableStringBuilder();
.replaceAll("\u001B\\[[0-?]*[ -/]*[@-~]", "") int fg = Color.rgb(230, 235, 242);
.replace("\r", ""); int bg = Color.TRANSPARENT;
boolean bold = false;
int index = 0;
while (index < text.length()) {
char item = text.charAt(index);
if (item == '\r') {
index++;
continue;
}
if (item == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[') {
int end = findAnsiEnd(text, index + 2);
if (end == -1) {
break;
}
char command = text.charAt(end);
if (command == 'm') {
int[] state = applySgr(text.substring(index + 2, end), fg, bg, bold);
fg = state[0];
bg = state[1];
bold = state[2] == 1;
}
index = end + 1;
continue;
}
int start = output.length();
output.append(item);
int finish = output.length();
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bg != Color.TRANSPARENT) {
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (bold) {
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
index++;
}
return output;
}
private int findAnsiEnd(String text, int start) {
for (int index = start; index < text.length(); index++) {
char item = text.charAt(index);
if (item >= '@' && item <= '~') {
return index;
}
}
return -1;
}
private int[] applySgr(String params, int fg, int bg, boolean bold) {
if (params.isEmpty()) {
params = "0";
}
String[] parts = params.split(";");
for (String part : parts) {
int value;
try {
value = part.isEmpty() ? 0 : Integer.parseInt(part);
} catch (NumberFormatException ignored) {
continue;
}
if (value == 0) {
fg = Color.rgb(230, 235, 242);
bg = Color.TRANSPARENT;
bold = false;
} else if (value == 1) {
bold = true;
} else if (value == 22) {
bold = false;
} else if (value == 39) {
fg = Color.rgb(230, 235, 242);
} else if (value == 49) {
bg = Color.TRANSPARENT;
} else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) {
fg = ansiColor(value, false);
} else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) {
bg = ansiColor(value, true);
}
}
return new int[]{fg, bg, bold ? 1 : 0};
}
private int ansiColor(int code, boolean background) {
int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30);
boolean bright = code >= 90;
switch (base) {
case 0:
return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45);
case 1:
return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73);
case 2:
return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80);
case 3:
return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34);
case 4:
return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255);
case 5:
return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255);
case 6:
return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187);
case 7:
default:
return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217);
}
} }
private void saveServerAndRefresh() { private void saveServerAndRefresh() {
@@ -1295,11 +1513,15 @@ public final class MainActivity extends Activity {
private Button toolbarButton(String label, View.OnClickListener listener) { private Button toolbarButton(String label, View.OnClickListener listener) {
Button button = new Button(this); Button button = new Button(this);
button.setText(label); button.setText(label);
button.setTextColor(Color.rgb(235, 241, 248));
button.setTextSize(12); button.setTextSize(12);
button.setAllCaps(false); button.setAllCaps(false);
button.setTypeface(Typeface.DEFAULT_BOLD);
button.setMinWidth(0); button.setMinWidth(0);
button.setMinimumWidth(0); button.setMinimumWidth(0);
button.setPadding(dp(8), 0, dp(8), 0); button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground());
button.setGravity(Gravity.CENTER);
button.setOnClickListener(listener); button.setOnClickListener(listener);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
@@ -1310,6 +1532,33 @@ public final class MainActivity extends Activity {
return button; return button;
} }
private void styleInput(EditText input) {
input.setTextColor(Color.rgb(240, 246, 252));
input.setHintTextColor(Color.rgb(139, 148, 158));
input.setTextSize(14);
input.setSingleLine(true);
input.setPadding(dp(10), 0, dp(10), 0);
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
}
private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable();
states.addState(new int[]{android.R.attr.state_pressed}, rounded(Color.rgb(64, 78, 94), 8, Color.rgb(91, 108, 128), 1));
states.addState(new int[]{android.R.attr.state_focused}, rounded(Color.rgb(48, 61, 76), 8, Color.rgb(98, 128, 164), 1));
states.addState(new int[]{}, rounded(Color.rgb(34, 43, 53), 8, Color.rgb(55, 66, 80), 1));
return states;
}
private GradientDrawable rounded(int color, int radiusDp, int strokeColor, int strokeDp) {
GradientDrawable drawable = new GradientDrawable();
drawable.setColor(color);
drawable.setCornerRadius(dp(radiusDp));
if (strokeDp > 0) {
drawable.setStroke(dp(strokeDp), strokeColor);
}
return drawable;
}
private void addSoftKey(LinearLayout row, String label, String sequence) { private void addSoftKey(LinearLayout row, String label, String sequence) {
addSoftButton(row, label, view -> sendTerminalInput(sequence)); addSoftButton(row, label, view -> sendTerminalInput(sequence));
} }
+3 -2
View File
@@ -68,14 +68,15 @@ not kill a tmux session.
Implemented now: Implemented now:
- configurable base URL, defaulting to `http://127.0.0.1:3000` - configurable base URL, defaulting to `http://100.89.0.116:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000` - support for Tailscale URLs such as `http://100.x.y.z:3000`
- native session list from `GET /api/sessions` - native session list from `GET /api/sessions`
- create, rename, command send, split, pane select, pane kill, pin, mute, and - create, rename, command send, split, pane select, pane kill, pin, mute, and
kill session through documented session/preference endpoints kill session through documented session/preference endpoints
- basic live terminal through `/ws/terminal` - basic live terminal through `/ws/terminal`
- native event stream through `/ws/events` - native event stream through `/ws/events`
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, arrows, page keys, and paste - bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste
- shortcut delivery through the terminal WebSocket `input` message - shortcut delivery through the terminal WebSocket `input` message
- native action center for health, server status, timeline, preferences, kanban - native action center for health, server status, timeline, preferences, kanban
projects, group messages, hook events, image file/URL upload, image preview projects, group messages, hook events, image file/URL upload, image preview