Compare commits

..

No commits in common. "main" and "v0.1.3" have entirely different histories.
main ... v0.1.3

8 changed files with 368 additions and 1288 deletions

View File

@ -1,67 +1,160 @@
name: Gitea Android APK
name: Android APK
on: [push]
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
steps:
- name: Build APK
env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
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: |
set -eu
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
apt-get update
apt-get install -y git curl unzip
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION_NAME="${GITHUB_REF_NAME#v}"
else
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER}"
fi
if ! command -v java >/dev/null 2>&1; then
apt-get update
apt-get install -y openjdk-17-jdk-headless
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
java -version
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}"
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
- name: Build APK
shell: bash
run: |
if [[ "${{ steps.signing.outputs.signed }}" == "true" ]]; then
BUILD_TASK=":app:assembleRelease"
else
BUILD_TASK=":app:assembleDebug"
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 \
gradle "${BUILD_TASK}" \
-PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \
-PrepoSlug="neatstudio/tmux-browser-android"
-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-gitea.apk
ls -lh release/tmux-android-gitea.apk
sha256sum release/tmux-android-gitea.apk
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

View File

@ -8,6 +8,15 @@ on:
- "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
@ -16,7 +25,7 @@ jobs:
build:
runs-on: ubuntu-latest
env:
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || inputs.publish_release == 'true' }}
steps:
- name: Checkout
@ -130,7 +139,15 @@ jobs:
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
@ -140,3 +157,4 @@ jobs:
--title "tmux Android ${VERSION_NAME}" \
--notes "Android APK for tmux-ui remote testing."
fi

101
README.md
View File

@ -9,27 +9,18 @@ APIs directly:
- 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`,
`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
- Sessions page with current API/server and loaded session count
- native Projects page for kanban project grouping, project agents, project
messages, add/remove session, create, and delete actions
- 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
- selectable GitHub or Gitea update manifest source
- native Tools page for health, server status, timeline, preferences, hook
events, image file/URL upload, image preview info, and native image preview
display
- 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 the selected release manifest
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- native Update and About pages for version/build type, protocol, permission,
selected update source, and release information
- GitHub and Gitea update manifest mirrors with fallback
- 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
@ -81,23 +72,15 @@ Create the base64 value from your release keystore:
base64 -w 0 tmux-android-release.jks
```
Branch builds and manual workflow runs create Actions artifacts only. Use them
to verify grouped changes before publishing.
Publish a release build by pushing a `v*` tag. A tag should be reserved for a
coherent feature/test batch, not every small UI or text change. Tag publishing
creates a GitHub Release with:
Publish a test build by pushing a `v*` tag. That creates a GitHub Release with:
```text
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://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
verification, but releases are the stable download/update channel.
@ -110,24 +93,15 @@ incompatible package.
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 monospace text with basic ANSI
SGR color support. The terminal view stays bottom-aligned when output is short,
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
opens. Rendering is throttled and the local terminal buffer is capped so opening
busy sessions does not block the UI thread. Input typed before the terminal
attach message is sent is queued and flushed after the WebSocket client is ready.
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`.
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
preferences, timeline events, group messages, and image metadata currently use
native forms plus native JSON detail dialogs; kanban projects use a native
project/agent list; image preview uses a native `ImageView`. The app does not
load the browser UI.
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.
## Permissions
@ -152,44 +126,13 @@ The default update manifest is:
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
```
The public manual APK download is:
The Gitea mirror is:
```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json
```
The app checks only the selected update source. It does not probe GitHub and
Gitea during the same update check. Choose the source in the app's `Update`
page, or use a custom manifest/API URL.
The app tries the selected update source first, then falls back to the GitHub
and Gitea mirrors.
The built-in Gitea API endpoint is:
```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:
- Open the `Update` page to check `latest.json`, download the APK, verify
SHA-256, and open Android's installer.
- If the same version APK was already downloaded and its SHA-256 still matches,
the app reuses that file instead of downloading it again.
- If Android sends you to the unknown-app install permission screen, return to
the app after allowing it; the app continues installing the already downloaded
APK without another update check or download.
- Open the `Update` page and tap `APK` to resolve the APK from the selected
update source and open it in a browser.
- Open the `Update` page and tap `Details` to see the installed version,
selected update source, APK/Release resolver behavior, install permission
state, and a `Check update` button.
- Open the `Update` page and tap `App settings` for Android's full per-app
permission/settings screen.
- Open the `About` page to see the app version/build type, package name,
API/protocol summary, selected update source, and update policy.
The workflow uploads both the APK and `latest.json` to each release.

View File

@ -5,17 +5,13 @@ plugins {
}
val repoSlug = providers.gradleProperty("repoSlug")
.orElse("neatstudio/tmux-browser-android")
.orElse("neatstudio/tmux-android")
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
.orElse("http://100.89.0.116:3000")
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.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")
.orElse("https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json")
val signingProps = Properties()
val signingFile = rootProject.file("signing.properties")
@ -37,8 +33,6 @@ android {
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.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 {

File diff suppressed because it is too large Load Diff

View File

@ -71,10 +71,6 @@ final class TerminalSocketClient {
}
}
boolean isClosed() {
return closed;
}
private void run(String baseUrl, String sessionName, int cols, int rows) {
try {
URI uri = buildWsUri(baseUrl);
@ -82,6 +78,7 @@ final class TerminalSocketClient {
input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri);
listener.onConnected();
sendMessage(
"attach",
"tabId", "android-" + System.currentTimeMillis(),
@ -89,7 +86,6 @@ final class TerminalSocketClient {
"cols", cols,
"rows", rows
);
listener.onConnected();
readLoop();
} catch (Exception error) {
if (!closed) {

View File

@ -10,7 +10,6 @@ import android.os.Build;
import android.provider.Settings;
import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedInputStream;
@ -20,29 +19,23 @@ import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
final class UpdateManager {
private static final String PREF_PENDING_INSTALL_APK = "pending_install_apk";
interface Callback {
void onChecking(boolean checking);
void onMessage(String message);
}
private interface ReleaseUrlPicker {
String pick(ReleaseInfo info);
}
private final Activity activity;
private final SharedPreferences prefs;
private final Callback callback;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private volatile boolean checkInProgress;
private volatile boolean downloadInProgress;
private File pendingInstallApk;
UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) {
this.activity = activity;
@ -51,17 +44,12 @@ final class UpdateManager {
}
void check(boolean userInitiated) {
if (checkInProgress) {
postMessage("Update check already running");
return;
}
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
List<String> manifestUrls = getUpdateManifestUrls();
callback.onChecking(true);
postMessage("Checking update from " + hostLabel(manifestUrl) + "...");
postMessage("Checking update...");
executor.execute(() -> {
try {
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
ReleaseInfo info = fetchFirstReleaseInfo(manifestUrls);
if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage("Already up to date: " + BuildConfig.VERSION_NAME);
return;
@ -71,52 +59,37 @@ final class UpdateManager {
} catch (Exception error) {
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
} finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
}
void openApkDownload() {
openSelectedReleaseUrl("APK", info -> info.apkUrl);
private List<String> getUpdateManifestUrls() {
LinkedHashSet<String> urls = new LinkedHashSet<>();
addUrl(urls, prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL));
addUrl(urls, BuildConfig.DEFAULT_UPDATE_URL);
addUrl(urls, BuildConfig.DEFAULT_GITEA_UPDATE_URL);
return new ArrayList<>(urls);
}
void openReleasePage() {
openSelectedReleaseUrl("Release page", info -> info.releasePageUrl);
private void addUrl(LinkedHashSet<String> urls, String url) {
if (url != null && !url.trim().isEmpty()) {
urls.add(url.trim());
}
}
private void openSelectedReleaseUrl(String label, ReleaseUrlPicker picker) {
if (checkInProgress) {
postMessage("Update check already running");
return;
}
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
callback.onChecking(true);
postMessage("Resolving " + label + " from " + hostLabel(manifestUrl) + "...");
executor.execute(() -> {
private ReleaseInfo fetchFirstReleaseInfo(List<String> manifestUrls) throws Exception {
Exception lastError = null;
for (String manifestUrl : manifestUrls) {
try {
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
String url = picker.pick(info);
if (url == null || url.trim().isEmpty()) {
throw new IllegalStateException(label + " URL is missing");
}
activity.runOnUiThread(() -> openExternalUrl(url.trim(), label));
postMessage("Checking " + hostLabel(manifestUrl) + "...");
return fetchReleaseInfo(manifestUrl);
} catch (Exception error) {
postMessage(label + " failed: " + error.getMessage());
} finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
lastError = error;
postMessage(hostLabel(manifestUrl) + " failed");
}
});
}
private String getUpdateManifestUrl() {
String url = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
if (url == null || url.trim().isEmpty()) {
return BuildConfig.DEFAULT_UPDATE_URL;
}
return url.trim();
throw lastError == null ? new IllegalStateException("No update source configured") : lastError;
}
private String hostLabel(String url) {
@ -130,9 +103,6 @@ final class UpdateManager {
private ReleaseInfo fetchReleaseInfo(String manifestUrl) throws Exception {
String json = readText(manifestUrl);
JSONObject root = new JSONObject(json);
if (root.has("assets") && root.has("tag_name")) {
return fetchReleaseInfoFromReleaseApi(root);
}
return new ReleaseInfo(
root.getInt("versionCode"),
root.optString("versionName", ""),
@ -142,40 +112,6 @@ final class UpdateManager {
);
}
private ReleaseInfo fetchReleaseInfoFromReleaseApi(JSONObject release) throws Exception {
JSONArray assets = release.getJSONArray("assets");
String manifestUrl = findAssetUrl(assets, "latest.json");
String apkUrl = findAssetUrl(assets, "tmux-android.apk");
if (manifestUrl.isEmpty()) {
throw new IllegalStateException("Release has no latest.json");
}
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (apkUrl.isEmpty()) {
return info;
}
return new ReleaseInfo(
info.versionCode,
info.versionName,
apkUrl,
info.sha256,
release.optString("html_url", info.releasePageUrl)
);
}
private String findAssetUrl(JSONArray assets, String name) {
for (int index = 0; index < assets.length(); index++) {
JSONObject asset = assets.optJSONObject(index);
if (asset == null || !name.equals(asset.optString("name"))) {
continue;
}
String url = asset.optString("browser_download_url", "");
if (!url.isEmpty()) {
return url;
}
}
return "";
}
private String readText(String url) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(12000);
@ -199,13 +135,8 @@ final class UpdateManager {
}
private void downloadAndInstall(ReleaseInfo info) {
if (downloadInProgress) {
postMessage("Update download already running");
return;
}
downloadInProgress = true;
callback.onChecking(true);
postMessage("Preparing " + info.versionName + "...");
postMessage("Downloading " + info.versionName + "...");
executor.execute(() -> {
try {
File apk = downloadApk(info);
@ -220,7 +151,6 @@ final class UpdateManager {
} catch (Exception error) {
postMessage("Update download failed: " + error.getMessage());
} finally {
downloadInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
@ -232,12 +162,7 @@ final class UpdateManager {
throw new IllegalStateException("Cannot create update cache");
}
File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk");
if (isCachedApkValid(apk, info)) {
postMessage("Using downloaded APK");
return apk;
}
postMessage("Downloading " + info.versionName + "...");
HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection();
connection.setConnectTimeout(12000);
connection.setReadTimeout(60000);
@ -254,32 +179,18 @@ final class UpdateManager {
return apk;
}
private boolean isCachedApkValid(File apk, ReleaseInfo info) throws Exception {
if (!apk.exists() || apk.length() <= 0) {
return false;
}
if (info.sha256.isEmpty()) {
return true;
}
return sha256(apk).equalsIgnoreCase(info.sha256);
}
private void installApk(File apk) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !activity.getPackageManager().canRequestPackageInstalls()) {
pendingInstallApk = apk;
prefs.edit().putString(PREF_PENDING_INSTALL_APK, apk.getAbsolutePath()).apply();
Intent settingsIntent = new Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + activity.getPackageName())
);
activity.startActivity(settingsIntent);
Toast.makeText(activity, "Allow installs, then return to continue", Toast.LENGTH_LONG).show();
Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show();
postMessage("Install permission required");
return;
}
pendingInstallApk = null;
prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply();
Uri apkUri = UpdateFileProvider.getUriForFile(
activity,
@ -298,41 +209,6 @@ final class UpdateManager {
}
}
private void openExternalUrl(String url, String label) {
try {
activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
postMessage("Opened " + label);
} catch (ActivityNotFoundException error) {
postMessage("No app can open " + label);
}
}
void resumePendingInstall() {
File apk = pendingInstallApk;
if (apk == null) {
String path = prefs.getString(PREF_PENDING_INSTALL_APK, "");
if (path != null && !path.isEmpty()) {
apk = new File(path);
pendingInstallApk = apk;
}
}
if (apk == null) {
return;
}
if (!apk.exists() || apk.length() <= 0) {
pendingInstallApk = null;
prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply();
postMessage("Downloaded APK is no longer available");
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !activity.getPackageManager().canRequestPackageInstalls()) {
return;
}
postMessage("Continuing APK install...");
installApk(apk);
}
private static byte[] readAllBytes(InputStream input) throws Exception {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];

View File

@ -68,62 +68,27 @@ not kill a tmux session.
Implemented now:
- configurable base URL, defaulting to `http://100.89.0.116:3000`
- configurable base URL, defaulting to `http://127.0.0.1:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000`
- native multi-page shell with `Sessions`, `Projects`, `Tools`, `Update`, and
`About`
- 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`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, arrows, page keys, and paste
- shortcut delivery through the terminal WebSocket `input` message
- native Projects page for kanban project grouping, project agents, project
messages, add/remove session, create, and delete actions
- native Tools page for health, server status, timeline, preferences, hook
events, image file/URL upload, image preview metadata, and native image
preview display
- 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`
- selected-source update checks; GitHub and Gitea are not probed in the same
update check
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- permission/about surfaces for unknown-app install status, notification status,
app settings, app version/build type, package name, selected update source, and
HTTP/WebSocket API/protocol summary
## Update And Release Policy
The Android app cannot silently replace itself. It may download a newer APK and
open Android's package installer, but the user must approve the install. On
Android 8+, the user may also need to allow this app to install unknown apps.
The app checks exactly one update source per run: the selected manifest/API URL.
GitHub is the default public source. Gitea is available as a public mirror, but
the app does not fall back across both providers during a normal check. This
keeps update behavior predictable on mobile networks and avoids duplicate
provider checks.
Downloaded APKs are cached by `versionCode`. If a cached APK exists and its
SHA-256 matches the manifest, the app reuses it instead of downloading the same
version again. This matters when Android redirects the user to unknown-app
install settings before the installer can run. After the user grants that
permission and returns to the app, the app resumes installation of the pending
APK instead of asking the user to run update again.
Only `v*` tags publish GitHub Releases. Main branch builds and manual workflow
runs are for CI artifacts and should be used to validate grouped changes. Do not
publish a new tag for every small UI copy or layout change; publish when there
is a useful feature or test batch for phone-side validation.
- 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 group messages, timeline, and preferences
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`