Compare commits

..

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

7 changed files with 262 additions and 1128 deletions

View File

@ -8,6 +8,15 @@ on:
- "v*" - "v*"
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs:
publish_release:
description: "Create or update a GitHub Release"
required: true
default: "false"
type: choice
options:
- "false"
- "true"
permissions: permissions:
contents: write contents: write
@ -16,7 +25,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: 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: steps:
- name: Checkout - name: Checkout
@ -130,7 +139,15 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
TAG="${GITHUB_REF_NAME}" 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 if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" release/tmux-android.apk release/latest.json --clobber gh release upload "${TAG}" release/tmux-android.apk release/latest.json --clobber
@ -140,3 +157,4 @@ jobs:
--title "tmux Android ${VERSION_NAME}" \ --title "tmux Android ${VERSION_NAME}" \
--notes "Android APK for tmux-ui remote testing." --notes "Android APK for tmux-ui remote testing."
fi fi

103
README.md
View File

@ -12,25 +12,18 @@ APIs directly:
- one-tap probe for the preset Tailscale APIs, showing health/version and - one-tap probe for the preset Tailscale APIs, showing health/version and
session counts session counts
- native tmux session list - 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, - create, rename, send command, split pane, select pane, kill pane, pin, mute,
and kill session through HTTP API and kill session through HTTP API
- open one live terminal viewer through `/ws/terminal` - open one live terminal viewer through `/ws/terminal`
- native `/ws/events` listener for session invalidation and hook notifications - native `/ws/events` listener for session invalidation and hook notifications
- selectable Gitea or GitHub update source, defaulting to Gitea for mobile - GitHub and Gitea update manifest mirrors with fallback
reachability - native API action center for health, server status, timeline, preferences,
- native Tools page for health, server status, timeline, preferences, hook kanban projects, group messages, hook events, image file/URL upload, image
events, image file/URL upload, image preview info, and native image preview preview info, and native image preview display
display
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach, - mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- automatic update checks against the selected release manifest - automatic update checks against a GitHub Release manifest
- one-download-per-version APK cache, SHA-256 verification, and installer - APK download, SHA-256 verification, and installer handoff
handoff
- native Update and About pages for version/build type, protocol, permission,
selected update source, and release information
## Server URL ## Server URL
@ -82,22 +75,17 @@ Create the base64 value from your release keystore:
base64 -w 0 tmux-android-release.jks base64 -w 0 tmux-android-release.jks
``` ```
Branch builds and manual workflow runs create Actions artifacts only. Use them Publish a test build by pushing a `v*` tag. That creates a GitHub Release with:
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:
```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
``` ```
Gitea is the app's default install/update channel because phones may not be able Those GitHub links are the stable public install/update channel. Gitea releases
to reach GitHub reliably. GitHub remains an optional public source. This Gitea are mirrored for internal tracking, but the current Gitea org/repo visibility
instance does not support the GitHub-style `/releases/latest/download/...` URL, keeps anonymous release downloads behind login, so phones should use GitHub for
so the app uses the Gitea Release API as the stable Gitea update entrypoint. no-login install and in-app updates.
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.
@ -111,24 +99,19 @@ 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 monospace text with basic ANSI The first Android UI renders terminal output as basic monospace text with ANSI
SGR color support. The terminal view stays bottom-aligned when output is short, escape filtering. It is enough for shell-oriented remote testing, but it is not
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard yet a complete xterm-compatible renderer for full-screen TUIs such as `vim` or
opens. Rendering is throttled and the local terminal buffer is capped so opening `top`.
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 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`, 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`. `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
preferences, timeline events, group messages, and image metadata currently use kanban projects, preferences, timeline events, group messages, and image metadata
native forms plus native JSON detail dialogs; kanban projects use a native currently use native forms plus native JSON detail dialogs; image preview uses a
project/agent list; image preview uses a native `ImageView`. The app does not native `ImageView`. The app does not load the browser UI.
load the browser UI.
## Permissions ## Permissions
@ -147,46 +130,32 @@ release manifest automatically, downloads the newer APK, verifies its SHA-256,
then opens Android's package installer. The user still has to approve the 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. install, and Android 8+ may require allowing this app to install unknown apps.
The default update source is the Gitea Release API: The default update manifest is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
The default source resolves `latest.json` and `tmux-android.apk` from Gitea
release assets, so APK downloads do not require GitHub. The optional GitHub
manifest is:
```text ```text
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 app checks only the selected update source. It does not probe GitHub and The public manual APK download is:
Gitea during the same update check. Choose the source in the app's `Update`
page, or use a custom manifest/API URL.
Gitea tag-specific assets are also public, for example:
```text ```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux-android.apk ```
The app tries the selected update source first, then falls back to the GitHub
manifest and the Gitea release API. GitHub is the reliable no-login source today.
The Gitea API endpoint is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
``` ```
In the app: In the app:
- Open the `Update` page to check `latest.json`, download the APK, verify - Tap `Update` on the main screen to check `latest.json`, download the APK,
SHA-256, and open Android's installer. verify SHA-256, and open Android's installer.
- If the same version APK was already downloaded and its SHA-256 still matches, - Open `More` -> `Open APK download` to download the current public APK in a
the app reuses that file instead of downloading it again. browser.
- If Android sends you to the unknown-app install permission screen, return to - Open `More` -> `Permissions / update status` to see the installed version,
the app after allowing it; the app continues installing the already downloaded update manifest, APK URL, install permission state, and a `Check update`
APK without another update check or download. button.
- 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.

View File

@ -9,8 +9,6 @@ val repoSlug = providers.gradleProperty("repoSlug")
val defaultServerUrl = providers.gradleProperty("defaultServerUrl") val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
.orElse("http://100.89.0.116:3000") .orElse("http://100.89.0.116:3000")
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl") val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
.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")
@ -38,7 +36,6 @@ 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_GITHUB_UPDATE_URL", "\"${defaultGithubUpdateUrl.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_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"") buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")

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

View File

@ -20,29 +20,23 @@ import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
final class UpdateManager { final class UpdateManager {
private static final String PREF_PENDING_INSTALL_APK = "pending_install_apk";
interface Callback { interface Callback {
void onChecking(boolean checking); void onChecking(boolean checking);
void onMessage(String message); void onMessage(String message);
} }
private interface ReleaseUrlPicker {
String pick(ReleaseInfo info);
}
private final Activity activity; private final Activity activity;
private final SharedPreferences prefs; private final SharedPreferences prefs;
private final Callback callback; private final Callback callback;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private volatile boolean checkInProgress;
private volatile boolean downloadInProgress;
private File pendingInstallApk;
UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) { UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) {
this.activity = activity; this.activity = activity;
@ -51,17 +45,12 @@ final class UpdateManager {
} }
void check(boolean userInitiated) { void check(boolean userInitiated) {
if (checkInProgress) { List<String> manifestUrls = getUpdateManifestUrls();
postMessage("Update check already running");
return;
}
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Checking update from " + hostLabel(manifestUrl) + "..."); postMessage("Checking update...");
executor.execute(() -> { executor.execute(() -> {
try { try {
ReleaseInfo info = fetchReleaseInfo(manifestUrl); ReleaseInfo info = fetchFirstReleaseInfo(manifestUrls);
if (info.versionCode <= BuildConfig.VERSION_CODE) { if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage("Already up to date: " + BuildConfig.VERSION_NAME); postMessage("Already up to date: " + BuildConfig.VERSION_NAME);
return; return;
@ -71,52 +60,37 @@ final class UpdateManager {
} catch (Exception error) { } catch (Exception error) {
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null); postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
} finally { } finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false)); activity.runOnUiThread(() -> callback.onChecking(false));
} }
}); });
} }
void openApkDownload() { private List<String> getUpdateManifestUrls() {
openSelectedReleaseUrl("APK", info -> info.apkUrl); 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() { private void addUrl(LinkedHashSet<String> urls, String url) {
openSelectedReleaseUrl("Release page", info -> info.releasePageUrl); 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); private ReleaseInfo fetchFirstReleaseInfo(List<String> manifestUrls) throws Exception {
postMessage("Resolving " + label + " from " + hostLabel(manifestUrl) + "..."); Exception lastError = null;
executor.execute(() -> { for (String manifestUrl : manifestUrls) {
try { try {
ReleaseInfo info = fetchReleaseInfo(manifestUrl); postMessage("Checking " + hostLabel(manifestUrl) + "...");
String url = picker.pick(info); return fetchReleaseInfo(manifestUrl);
if (url == null || url.trim().isEmpty()) {
throw new IllegalStateException(label + " URL is missing");
}
activity.runOnUiThread(() -> openExternalUrl(url.trim(), label));
} catch (Exception error) { } catch (Exception error) {
postMessage(label + " failed: " + error.getMessage()); lastError = error;
} finally { postMessage(hostLabel(manifestUrl) + " failed");
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
} }
});
}
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) { private String hostLabel(String url) {
@ -199,13 +173,8 @@ final class UpdateManager {
} }
private void downloadAndInstall(ReleaseInfo info) { private void downloadAndInstall(ReleaseInfo info) {
if (downloadInProgress) {
postMessage("Update download already running");
return;
}
downloadInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Preparing " + info.versionName + "..."); postMessage("Downloading " + info.versionName + "...");
executor.execute(() -> { executor.execute(() -> {
try { try {
File apk = downloadApk(info); File apk = downloadApk(info);
@ -220,7 +189,6 @@ final class UpdateManager {
} catch (Exception error) { } catch (Exception error) {
postMessage("Update download failed: " + error.getMessage()); postMessage("Update download failed: " + error.getMessage());
} finally { } finally {
downloadInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false)); activity.runOnUiThread(() -> callback.onChecking(false));
} }
}); });
@ -232,12 +200,7 @@ final class UpdateManager {
throw new IllegalStateException("Cannot create update cache"); throw new IllegalStateException("Cannot create update cache");
} }
File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk"); 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(); HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection();
connection.setConnectTimeout(12000); connection.setConnectTimeout(12000);
connection.setReadTimeout(60000); connection.setReadTimeout(60000);
@ -254,32 +217,18 @@ final class UpdateManager {
return apk; 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) { private void installApk(File apk) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !activity.getPackageManager().canRequestPackageInstalls()) { && !activity.getPackageManager().canRequestPackageInstalls()) {
pendingInstallApk = apk;
prefs.edit().putString(PREF_PENDING_INSTALL_APK, apk.getAbsolutePath()).apply();
Intent settingsIntent = new Intent( Intent settingsIntent = new Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + activity.getPackageName()) Uri.parse("package:" + activity.getPackageName())
); );
activity.startActivity(settingsIntent); 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"); postMessage("Install permission required");
return; return;
} }
pendingInstallApk = null;
prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply();
Uri apkUri = UpdateFileProvider.getUriForFile( Uri apkUri = UpdateFileProvider.getUriForFile(
activity, activity,
@ -298,41 +247,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 { private static byte[] readAllBytes(InputStream input) throws Exception {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024]; byte[] buffer = new byte[16 * 1024];

View File

@ -70,8 +70,6 @@ Implemented now:
- configurable base URL, defaulting to `http://100.89.0.116: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 multi-page shell with `Sessions`, `Projects`, `Tools`, `Update`, and
`About`
- 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
@ -80,50 +78,18 @@ Implemented now:
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys, - bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste tmux prefix actions, and paste
- shortcut delivery through the terminal WebSocket `input` message - shortcut delivery through the terminal WebSocket `input` message
- native Projects page for kanban project grouping, project agents, project - native action center for health, server status, timeline, preferences, kanban
messages, add/remove session, create, and delete actions projects, group messages, hook events, image file/URL upload, image preview
- native Tools page for health, server status, timeline, preferences, hook metadata, and native image preview display
events, image file/URL upload, image preview metadata, and native image
preview display
- GitHub Actions APK build - GitHub Actions APK build
- release manifest `latest.json` - release manifest `latest.json`
- selected-source update checks; Gitea and GitHub are not probed in the same - APK download, SHA-256 verification, and installer handoff
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.
Gitea is the default public source because phones may not reach GitHub reliably.
GitHub is available as an optional public source, 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.
## Native Roadmap ## Native Roadmap
To converge with the upstream mobile design, the next implementation should add To converge with the upstream mobile design, the next implementation should add
native Android modules in this order: 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 2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows
3. configurable shortcut bar backed directly by WebSocket `input` 3. configurable shortcut bar backed directly by WebSocket `input`