Compare commits

...

13 Commits
v0.1.8 ... main

Author SHA1 Message Date
Codex
dd056c8027 Add projects page and smooth terminal input
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m19s
2026-07-06 18:49:28 +00:00
Codex
16086a593f Show build type and update host
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m56s
2026-07-06 13:17:34 +00:00
Codex
c683f135e3 Add sessions page status panel
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 13:12:01 +00:00
Codex
1585d9ee07 Add app settings permission shortcut
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 1s
2026-07-06 13:06:56 +00:00
Codex
149d675910 Expand about protocol details
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 13:01:55 +00:00
Codex
0609c73d35 Resolve manual update links from selected source
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-06 12:56:08 +00:00
Codex
79fd148511 Make main toolbar page contextual
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-06 12:50:09 +00:00
Codex
147cec7c6b Restrict releases to version tags
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:43:11 +00:00
Codex
67968654cb Resume pending APK install
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:38:44 +00:00
Codex
fc1cf589a7 Document update release policy
Some checks failed
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-06 12:33:04 +00:00
Codex
e2d9810b34 Add update and about pages
Some checks failed
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:27:45 +00:00
Codex
db306679d7 Polish native controls
All checks were successful
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
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 08:48:42 +00:00
6 changed files with 1094 additions and 245 deletions

View File

@ -8,15 +8,6 @@ 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
@ -25,7 +16,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') || inputs.publish_release == 'true' }} PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps: steps:
- name: Checkout - name: Checkout
@ -139,15 +130,7 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then TAG="${GITHUB_REF_NAME}"
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
@ -157,4 +140,3 @@ 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

View File

@ -12,18 +12,24 @@ 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
- GitHub and Gitea update manifest mirrors with fallback - selectable GitHub or Gitea update manifest source
- 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 a GitHub Release manifest - automatic update checks against the selected release manifest
- APK download, SHA-256 verification, and installer handoff - 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
## Server URL ## Server URL
@ -75,7 +81,12 @@ Create the base64 value from your release keystore:
base64 -w 0 tmux-android-release.jks base64 -w 0 tmux-android-release.jks
``` ```
Publish a test build by pushing a `v*` tag. That creates a GitHub Release with: 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:
```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
@ -99,19 +110,24 @@ 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. 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 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
kanban projects, preferences, timeline events, group messages, and image metadata preferences, timeline events, group messages, and image metadata currently use
currently use native forms plus native JSON detail dialogs; image preview uses a native forms plus native JSON detail dialogs; kanban projects use a native
native `ImageView`. The app does not load the browser UI. project/agent list; image preview uses a native `ImageView`. The app does not
load the browser UI.
## Permissions ## Permissions
@ -142,8 +158,11 @@ The public manual APK download is:
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
``` ```
The app tries the selected update source first, then falls back to the GitHub The app checks only the selected update source. It does not probe GitHub and
manifest and the Gitea release API. The Gitea API endpoint is: Gitea during the same update check. Choose the source in the app's `Update`
page, or use a custom manifest/API URL.
The built-in Gitea API endpoint is:
```text ```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
@ -158,10 +177,19 @@ https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux
In the app: In the app:
- Tap `Update` on the main screen to check `latest.json`, download the APK, - Open the `Update` page to check `latest.json`, download the APK, verify
verify SHA-256, and open Android's installer. SHA-256, and open Android's installer.
- Open `More` -> `Open APK download` to download the current public APK in a - If the same version APK was already downloaded and its SHA-256 still matches,
browser. the app reuses that file instead of downloading it again.
- Open `More` -> `Permissions / update status` to see the installed version, - If Android sends you to the unknown-app install permission screen, return to
update manifest, APK URL, install permission state, and a `Check update` the app after allowing it; the app continues installing the already downloaded
button. 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.

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,10 @@ 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);
@ -78,7 +82,6 @@ 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(),
@ -86,6 +89,7 @@ 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,23 +20,29 @@ 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;
@ -45,12 +51,17 @@ final class UpdateManager {
} }
void check(boolean userInitiated) { void check(boolean userInitiated) {
List<String> manifestUrls = getUpdateManifestUrls(); if (checkInProgress) {
postMessage("Update check already running");
return;
}
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Checking update..."); postMessage("Checking update from " + hostLabel(manifestUrl) + "...");
executor.execute(() -> { executor.execute(() -> {
try { try {
ReleaseInfo info = fetchFirstReleaseInfo(manifestUrls); ReleaseInfo info = fetchReleaseInfo(manifestUrl);
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;
@ -60,37 +71,52 @@ 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));
} }
}); });
} }
private List<String> getUpdateManifestUrls() { void openApkDownload() {
LinkedHashSet<String> urls = new LinkedHashSet<>(); openSelectedReleaseUrl("APK", info -> info.apkUrl);
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);
} }
private void addUrl(LinkedHashSet<String> urls, String url) { void openReleasePage() {
if (url != null && !url.trim().isEmpty()) { openSelectedReleaseUrl("Release page", info -> info.releasePageUrl);
urls.add(url.trim()); }
private void openSelectedReleaseUrl(String label, ReleaseUrlPicker picker) {
if (checkInProgress) {
postMessage("Update check already running");
return;
} }
} String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
private ReleaseInfo fetchFirstReleaseInfo(List<String> manifestUrls) throws Exception { callback.onChecking(true);
Exception lastError = null; postMessage("Resolving " + label + " from " + hostLabel(manifestUrl) + "...");
for (String manifestUrl : manifestUrls) { executor.execute(() -> {
try { try {
postMessage("Checking " + hostLabel(manifestUrl) + "..."); ReleaseInfo info = fetchReleaseInfo(manifestUrl);
return 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));
} catch (Exception error) { } catch (Exception error) {
lastError = error; postMessage(label + " failed: " + error.getMessage());
postMessage(hostLabel(manifestUrl) + " failed"); } finally {
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;
} }
throw lastError == null ? new IllegalStateException("No update source configured") : lastError; return url.trim();
} }
private String hostLabel(String url) { private String hostLabel(String url) {
@ -173,8 +199,13 @@ 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("Downloading " + info.versionName + "..."); postMessage("Preparing " + info.versionName + "...");
executor.execute(() -> { executor.execute(() -> {
try { try {
File apk = downloadApk(info); File apk = downloadApk(info);
@ -189,6 +220,7 @@ 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));
} }
}); });
@ -200,7 +232,12 @@ 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);
@ -217,18 +254,32 @@ 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 run update again", Toast.LENGTH_LONG).show(); Toast.makeText(activity, "Allow installs, then return to continue", 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,
@ -247,6 +298,41 @@ 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,6 +70,8 @@ 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
@ -78,18 +80,50 @@ 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 action center for health, server status, timeline, preferences, kanban - native Projects page for kanban project grouping, project agents, project
projects, group messages, hook events, image file/URL upload, image preview messages, add/remove session, create, and delete actions
metadata, and native image preview display - native Tools page for health, server status, timeline, preferences, hook
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`
- APK download, SHA-256 verification, and installer handoff - 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.
## 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 kanban, group messages, timeline, and preferences 1. richer native layouts for 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`