Compare commits

...
10 Commits
Author SHA1 Message Date
Codex 16086a593f Show build type and update host
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
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
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
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
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
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
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
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
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
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 12:27:45 +00:00
5 changed files with 613 additions and 212 deletions
+1 -19
View File
@@ -8,15 +8,6 @@ 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
@@ -25,7 +16,7 @@ jobs:
build:
runs-on: ubuntu-latest
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:
- name: Checkout
@@ -139,15 +130,7 @@ 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
@@ -157,4 +140,3 @@ jobs:
--title "tmux Android ${VERSION_NAME}" \
--notes "Android APK for tmux-ui remote testing."
fi
+35 -14
View File
@@ -12,18 +12,22 @@ APIs directly:
- 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
- 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
- GitHub and Gitea update manifest mirrors with fallback
- native API action center for health, server status, timeline, preferences,
- selectable GitHub or Gitea update manifest source
- native Tools page 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, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- automatic update checks against a GitHub Release manifest
- APK download, SHA-256 verification, and installer handoff
- 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
## Server URL
@@ -75,7 +79,12 @@ Create the base64 value from your release keystore:
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
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
@@ -143,8 +152,11 @@ The public manual APK download is:
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
manifest and the Gitea release API. The Gitea API endpoint is:
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 built-in Gitea API endpoint is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
@@ -159,10 +171,19 @@ https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux
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.
- 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.
@@ -3,7 +3,6 @@ package com.neatstudio.tmuxandroid;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
@@ -60,6 +59,10 @@ public final class MainActivity extends Activity {
private static final int MAX_TERMINAL_CHARS = 120_000;
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
private static final String PAGE_SESSIONS = "Sessions";
private static final String PAGE_TOOLS = "Tools";
private static final String PAGE_UPDATE = "Update";
private static final String PAGE_ABOUT = "About";
private static final String[] SERVER_PROFILES = {
"http://100.89.0.116:3000",
"http://100.89.0.2:3000",
@@ -78,10 +81,12 @@ public final class MainActivity extends Activity {
private EditText urlField;
private ProgressBar progressBar;
private TextView statusText;
private TextView sessionSummaryText;
private TextView terminalText;
private ScrollView terminalScroll;
private EditText inputField;
private String activeSessionName;
private String activeMainPage = PAGE_SESSIONS;
private String pendingImageUploadSession;
private final StringBuilder terminalBuffer = new StringBuilder();
@@ -169,23 +174,31 @@ public final class MainActivity extends Activity {
private void renderSessionScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_SESSIONS;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createServerProfileBar(), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
));
root.addView(createMainTabs(PAGE_SESSIONS), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(sessionSummaryBlock());
content.addView(sectionTitle("Tmux sessions"));
LinearLayout list = new LinearLayout(this);
list.setOrientation(LinearLayout.VERTICAL);
list.setPadding(dp(8), dp(8), dp(8), dp(8));
list.setTag("session-list");
scroll.addView(list);
content.addView(list);
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
@@ -197,6 +210,181 @@ public final class MainActivity extends Activity {
));
}
private View sessionSummaryBlock() {
View block = infoBlock(
"Active API",
"Server: " + getServerUrl() + "\nSessions: loading"
);
sessionSummaryText = findBodyText(block);
return block;
}
private void renderToolsScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_TOOLS;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_TOOLS), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(sectionTitle("Server"));
content.addView(actionPanel(
actionButton("Health", view -> showRaw("Health", () -> api.health())),
actionButton("Probe Tailscale", view -> probeServerProfiles()),
actionButton("Status", view -> showRaw("Server status", () -> api.serverStatus())),
actionButton("Preferences", view -> showRaw("Preferences", () -> api.preferences()))
));
content.addView(sectionTitle("Sessions"));
content.addView(actionPanel(
actionButton("Timeline", view -> showRaw("Timeline", () -> api.timeline(50))),
actionButton("All details", view -> showRaw("All session details", () -> api.sessionsAll())),
actionButton("Pane details", view -> showRaw("Pane details", () -> api.sessionsPanes()))
));
content.addView(sectionTitle("Kanban / Messages"));
content.addView(actionPanel(
actionButton("Projects", view -> showRaw("Kanban projects", () -> api.kanbanProjects())),
actionButton("New project", view -> promptCreateKanbanProject()),
actionButton("Delete project", view -> promptDeleteKanbanProject()),
actionButton("Remove session", view -> promptRemoveKanbanSession())
));
content.addView(actionPanel(
actionButton("Messages", view -> promptGroupMessages()),
actionButton("Send message", view -> promptSendGroupMessage()),
actionButton("Scan message", view -> promptScanGroupMessage()),
actionButton("Post hook", view -> promptPostHookEvent())
));
content.addView(sectionTitle("Images"));
content.addView(actionPanel(
actionButton("Upload file", view -> promptUploadImageFile()),
actionButton("Upload URL", view -> promptUploadImageUrl()),
actionButton("Preview info", view -> promptImagePreviewInfo()),
actionButton("Preview", view -> promptOpenImagePreview())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("Tools");
}
private void renderUpdateScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_UPDATE;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_UPDATE), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(infoBlock(
"Installed",
appIdentityText() + "\n"
+ "Update source: " + updateSourceHost() + "\n"
+ prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)
));
content.addView(sectionTitle("Update"));
content.addView(actionPanel(
actionButton("Check now", view -> updateManager.check(true)),
actionButton("Source", view -> showUpdateSourcePicker()),
actionButton("APK", view -> updateManager.openApkDownload())
));
content.addView(sectionTitle("Permissions"));
content.addView(infoBlock("Android", permissionSummary()));
content.addView(actionPanel(
actionButton("Install permission", view -> openInstallPermissionSettings()),
actionButton("Notifications", view -> requestNotificationPermission()),
actionButton("App settings", view -> openAppSettings()),
actionButton("Details", view -> showPermissionsAndUpdateStatus())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("Update and permissions");
}
private void renderAboutScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_ABOUT;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_ABOUT), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(48)
));
root.addView(progressBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(3)
));
ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent();
content.addView(infoBlock(
"tmux-browser Android",
"Native Android client for the remote tmux-browser API.\n"
+ appIdentityText() + "\n"
+ "Package " + getPackageName()
));
content.addView(infoBlock(
"Protocol",
"API base: " + getServerUrl() + "\n"
+ "HTTP on port 3000: /api/health, /api/sessions, session actions, kanban, messages, images.\n"
+ "WebSocket: /ws/terminal uses attach/input/resize/scroll/clear-history; /ws/events streams session and hook events."
));
content.addView(infoBlock(
"Update policy",
"Selected source: " + updateSourceHost() + "\n"
+ prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL) + "\n"
+ "The app checks only this source. APK downloads are cached by version and reused after Android install permission is granted."
));
content.addView(actionPanel(
actionButton("Release page", view -> updateManager.openReleasePage()),
actionButton("Update source", view -> showUpdateSourcePicker()),
actionButton("Permissions", view -> showPermissionsAndUpdateStatus())
));
scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1
));
root.addView(statusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(28)
));
setStatus("About");
}
private LinearLayout createServerBar() {
LinearLayout toolbar = new LinearLayout(this);
toolbar.setOrientation(LinearLayout.VERTICAL);
@@ -231,16 +419,38 @@ public final class MainActivity extends Activity {
actionRow.setOrientation(LinearLayout.HORIZONTAL);
actionRow.setGravity(Gravity.CENTER_VERTICAL);
actionRow.setPadding(0, dp(6), 0, 0);
actionRow.addView(toolbarButton("New", view -> promptCreateSession()));
actionRow.addView(toolbarButton("Refresh", view -> refreshSessions()));
actionRow.addView(toolbarButton("Update", view -> updateManager.check(true)));
actionRow.addView(toolbarButton("More", view -> showMainActions()));
addContextActions(actionRow);
toolbar.addView(urlRow, matchWrap());
if (actionRow.getChildCount() > 0) {
toolbar.addView(actionRow, matchWrap());
}
return toolbar;
}
private void addContextActions(LinearLayout actionRow) {
if (PAGE_SESSIONS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("New", view -> promptCreateSession()));
actionRow.addView(toolbarButton("Refresh", view -> refreshSessions()));
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
return;
}
if (PAGE_TOOLS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health())));
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
return;
}
if (PAGE_UPDATE.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Check now", view -> updateManager.check(true)));
actionRow.addView(toolbarButton("Source", view -> showUpdateSourcePicker()));
return;
}
if (PAGE_ABOUT.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Release", view -> updateManager.openReleasePage()));
actionRow.addView(toolbarButton("Permissions", view -> showPermissionsAndUpdateStatus()));
}
}
private HorizontalScrollView createServerProfileBar() {
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
@@ -273,14 +483,142 @@ public final class MainActivity extends Activity {
return scroller;
}
private HorizontalScrollView createMainTabs(String selected) {
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
scroller.setBackgroundColor(Color.rgb(17, 20, 24));
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(8), dp(4), dp(8), dp(4));
row.addView(navButton(PAGE_SESSIONS, selected, view -> {
renderSessionScreen();
refreshSessions();
}));
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen()));
scroller.addView(row, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return scroller;
}
private Button navButton(String label, String selected, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
boolean active = label.equals(selected);
button.setTextColor(active ? Color.rgb(8, 12, 18) : Color.rgb(235, 241, 248));
button.setBackground(active
? rounded(Color.rgb(86, 211, 219), 8, Color.rgb(86, 211, 219), 1)
: buttonBackground());
button.setMinWidth(dp(82));
button.setMinimumWidth(dp(82));
return button;
}
private LinearLayout pageContent() {
LinearLayout content = new LinearLayout(this);
content.setOrientation(LinearLayout.VERTICAL);
content.setPadding(dp(10), dp(10), dp(10), dp(14));
return content;
}
private TextView sectionTitle(String text) {
TextView title = new TextView(this);
title.setText(text);
title.setTextColor(Color.rgb(139, 148, 158));
title.setTextSize(12);
title.setTypeface(Typeface.DEFAULT_BOLD);
title.setPadding(dp(2), dp(12), dp(2), dp(6));
return title;
}
private View infoBlock(String title, String body) {
LinearLayout block = new LinearLayout(this);
block.setOrientation(LinearLayout.VERTICAL);
block.setPadding(dp(12), dp(10), dp(12), dp(10));
block.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
TextView heading = new TextView(this);
heading.setText(title);
heading.setTextColor(Color.WHITE);
heading.setTextSize(16);
heading.setTypeface(Typeface.DEFAULT_BOLD);
TextView text = bodyText(body);
text.setPadding(0, dp(6), 0, 0);
block.addView(heading);
block.addView(text);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
block.setLayoutParams(params);
return block;
}
private TextView findBodyText(View block) {
if (!(block instanceof LinearLayout)) {
return null;
}
LinearLayout layout = (LinearLayout) block;
if (layout.getChildCount() < 2 || !(layout.getChildAt(1) instanceof TextView)) {
return null;
}
return (TextView) layout.getChildAt(1);
}
private LinearLayout actionPanel(Button... buttons) {
LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setPadding(dp(7), dp(7), dp(7), dp(2));
panel.setBackground(rounded(Color.rgb(24, 30, 37), 8, Color.rgb(42, 51, 61), 1));
LinearLayout row = null;
for (int index = 0; index < buttons.length; index++) {
if (index % 2 == 0) {
row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
panel.addView(row, matchWrap());
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(42), 1);
params.leftMargin = dp(3);
params.rightMargin = dp(3);
params.bottomMargin = dp(6);
row.addView(buttons[index], params);
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
panel.setLayoutParams(params);
return panel;
}
private Button actionButton(String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
return button;
}
private void selectServer(String url) {
prefs.edit().putString("server_url", url).apply();
api = new SessionApiClient(url);
urlField.setText(url);
setStatus("Selected " + url);
connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions();
}
}
private void refreshSessions() {
setStatus("Loading sessions from " + api.getBaseUrl());
@@ -290,7 +628,12 @@ public final class MainActivity extends Activity {
List<SessionSummary> sessions = api.getSessions();
runOnUiThread(() -> renderSessionList(sessions));
} catch (Exception error) {
runOnUiThread(() -> showMessage("Session load failed: " + error.getMessage()));
runOnUiThread(() -> {
if (sessionSummaryText != null) {
sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: failed - " + error.getMessage());
}
showMessage("Session load failed: " + error.getMessage());
});
} finally {
runOnUiThread(() -> progressBar.setVisibility(View.GONE));
}
@@ -311,6 +654,9 @@ public final class MainActivity extends Activity {
for (SessionSummary session : sessions) {
list.addView(sessionRow(session), matchWrap());
}
if (sessionSummaryText != null) {
sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: " + sessions.size());
}
setStatus("Loaded " + sessions.size() + " sessions");
}
@@ -466,108 +812,6 @@ public final class MainActivity extends Activity {
return bar;
}
private void showMainActions() {
String[] items = {
"Health",
"Probe Tailscale APIs",
"Server status",
"Timeline",
"Preferences",
"All session details",
"Pane details",
"Kanban projects",
"Create kanban project",
"Delete kanban project",
"Remove kanban session",
"Group messages",
"Send group message",
"Scan group message",
"Post hook event",
"Upload image file",
"Upload image URL",
"Image preview info",
"Open image preview",
"Open APK download",
"Update source",
"Permissions / update status"
};
new AlertDialog.Builder(this)
.setTitle("Native API actions")
.setItems(items, (dialog, which) -> {
switch (which) {
case 0:
showRaw("Health", () -> api.health());
break;
case 1:
probeServerProfiles();
break;
case 2:
showRaw("Server status", () -> api.serverStatus());
break;
case 3:
showRaw("Timeline", () -> api.timeline(50));
break;
case 4:
showRaw("Preferences", () -> api.preferences());
break;
case 5:
showRaw("All session details", () -> api.sessionsAll());
break;
case 6:
showRaw("Pane details", () -> api.sessionsPanes());
break;
case 7:
showRaw("Kanban projects", () -> api.kanbanProjects());
break;
case 8:
promptCreateKanbanProject();
break;
case 9:
promptDeleteKanbanProject();
break;
case 10:
promptRemoveKanbanSession();
break;
case 11:
promptGroupMessages();
break;
case 12:
promptSendGroupMessage();
break;
case 13:
promptScanGroupMessage();
break;
case 14:
promptPostHookEvent();
break;
case 15:
promptUploadImageFile();
break;
case 16:
promptUploadImageUrl();
break;
case 17:
promptImagePreviewInfo();
break;
case 18:
promptOpenImagePreview();
break;
case 19:
openExternalUrl(BuildConfig.DEFAULT_APK_URL);
break;
case 20:
showUpdateSourcePicker();
break;
case 21:
showPermissionsAndUpdateStatus();
break;
default:
break;
}
})
.show();
}
private void showSessionActions(String sessionName) {
String[] items = {
"Status",
@@ -966,44 +1210,26 @@ public final class MainActivity extends Activity {
private void showPermissionsAndUpdateStatus() {
StringBuilder text = new StringBuilder();
text.append("Installed app: ")
.append(BuildConfig.VERSION_NAME)
.append(" (")
.append(BuildConfig.VERSION_CODE)
.append(")\n");
text.append("Installed app: ").append(appIdentityText()).append('\n');
text.append("Server: ").append(getServerUrl()).append('\n');
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("Manual APK download:\n");
text.append("Tap APK on the Update page. The app resolves the APK from the selected update source.\n");
text.append("Release page:\n");
text.append("Tap Release on the About page. The app resolves the page from the selected update source.\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")
text.append("Tap Check now on the Update page. The app checks only the selected source, downloads one APK per version, verifies SHA-256, then opens Android's installer.\n");
text.append("Selected manifest: ")
.append(updateSourceHost())
.append('\n')
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n');
text.append("GitHub primary manifest:\n").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea public mirror API:\n").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append("Available sources:\n");
text.append("GitHub: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append('\n');
text.append("Network: manifest permission, no runtime grant required\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
text.append("Install unknown apps: ")
.append(getPackageManager().canRequestPackageInstalls() ? "allowed" : "not allowed")
.append('\n');
} else {
text.append("Install unknown apps: allowed by Android version\n");
}
if (Build.VERSION.SDK_INT >= 33) {
text.append("Notifications: ")
.append(checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ? "allowed" : "not allowed")
.append('\n');
} else {
text.append("Notifications: no runtime permission required\n");
}
text.append("SMS: not requested; this tmux client does not need SMS permission.\n");
text.append(permissionSummary());
new AlertDialog.Builder(this)
.setTitle("Permissions / update")
@@ -1014,12 +1240,26 @@ public final class MainActivity extends Activity {
.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 String permissionSummary() {
StringBuilder text = new StringBuilder();
text.append("Network: declared in manifest; Android does not show a runtime grant.\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
text.append("Install unknown apps: ")
.append(getPackageManager().canRequestPackageInstalls() ? "allowed" : "not allowed")
.append(". Android requires you to allow this per app before in-app APK updates can install.\n");
} else {
text.append("Install unknown apps: allowed by this Android version.\n");
}
if (Build.VERSION.SDK_INT >= 33) {
text.append("Notifications: ")
.append(checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ? "allowed" : "not allowed")
.append(". This only affects optional status notifications, not tmux control.\n");
} else {
text.append("Notifications: no runtime permission required.\n");
}
text.append("SMS: not requested. This tmux client does not need SMS permission.");
text.append("\nOpen App settings for Android's full per-app permission and storage/network controls.");
return text.toString();
}
private void showUpdateSourcePicker() {
@@ -1045,6 +1285,11 @@ public final class MainActivity extends Activity {
private void setUpdateUrl(String url) {
prefs.edit().putString("update_url", url).apply();
showMessage("Update source: " + url);
if (PAGE_UPDATE.equals(activeMainPage)) {
renderUpdateScreen();
} else if (PAGE_ABOUT.equals(activeMainPage)) {
renderAboutScreen();
}
}
private void probeServerProfiles() {
@@ -1097,6 +1342,14 @@ public final class MainActivity extends Activity {
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 3001);
}
private void openAppSettings() {
Intent intent = new Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:" + getPackageName())
);
startActivity(intent);
}
private void showImagePreview(String path, String basePath) {
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
@@ -1474,13 +1727,33 @@ public final class MainActivity extends Activity {
api = new SessionApiClient(url);
urlField.setText(url);
connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions();
} else {
setStatus("Server saved: " + url);
}
}
private String getServerUrl() {
return prefs.getString("server_url", BuildConfig.DEFAULT_SERVER_URL);
}
private String appIdentityText() {
return "Version " + BuildConfig.VERSION_NAME
+ " (" + BuildConfig.VERSION_CODE + ", "
+ (BuildConfig.DEBUG ? "debug" : "release")
+ ")";
}
private String updateSourceHost() {
String url = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
if (url == null || url.trim().isEmpty()) {
url = BuildConfig.DEFAULT_UPDATE_URL;
}
String host = Uri.parse(url).getHost();
return host == null || host.isEmpty() ? "custom" : host;
}
private String normalizeServerUrl(String raw) {
String value = raw.trim();
if (value.isEmpty()) {
@@ -1715,6 +1988,14 @@ public final class MainActivity extends Activity {
super.onBackPressed();
}
@Override
protected void onResume() {
super.onResume();
if (updateManager != null) {
updateManager.resumePendingInstall();
}
}
@Override
protected void onDestroy() {
closeTerminalSocket();
@@ -20,23 +20,29 @@ 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;
@@ -45,12 +51,17 @@ final class UpdateManager {
}
void check(boolean userInitiated) {
List<String> manifestUrls = getUpdateManifestUrls();
if (checkInProgress) {
postMessage("Update check already running");
return;
}
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true;
callback.onChecking(true);
postMessage("Checking update...");
postMessage("Checking update from " + hostLabel(manifestUrl) + "...");
executor.execute(() -> {
try {
ReleaseInfo info = fetchFirstReleaseInfo(manifestUrls);
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage("Already up to date: " + BuildConfig.VERSION_NAME);
return;
@@ -60,37 +71,52 @@ final class UpdateManager {
} catch (Exception error) {
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
} finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
}
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 openApkDownload() {
openSelectedReleaseUrl("APK", info -> info.apkUrl);
}
private void addUrl(LinkedHashSet<String> urls, String url) {
if (url != null && !url.trim().isEmpty()) {
urls.add(url.trim());
}
void openReleasePage() {
openSelectedReleaseUrl("Release page", info -> info.releasePageUrl);
}
private ReleaseInfo fetchFirstReleaseInfo(List<String> manifestUrls) throws Exception {
Exception lastError = null;
for (String manifestUrl : manifestUrls) {
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(() -> {
try {
postMessage("Checking " + hostLabel(manifestUrl) + "...");
return fetchReleaseInfo(manifestUrl);
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));
} catch (Exception error) {
lastError = error;
postMessage(hostLabel(manifestUrl) + " failed");
postMessage(label + " failed: " + error.getMessage());
} finally {
checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
}
throw lastError == null ? new IllegalStateException("No update source configured") : lastError;
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();
}
private String hostLabel(String url) {
@@ -173,8 +199,13 @@ final class UpdateManager {
}
private void downloadAndInstall(ReleaseInfo info) {
if (downloadInProgress) {
postMessage("Update download already running");
return;
}
downloadInProgress = true;
callback.onChecking(true);
postMessage("Downloading " + info.versionName + "...");
postMessage("Preparing " + info.versionName + "...");
executor.execute(() -> {
try {
File apk = downloadApk(info);
@@ -189,6 +220,7 @@ final class UpdateManager {
} catch (Exception error) {
postMessage("Update download failed: " + error.getMessage());
} finally {
downloadInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false));
}
});
@@ -200,7 +232,12 @@ 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);
@@ -217,18 +254,32 @@ 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 run update again", Toast.LENGTH_LONG).show();
Toast.makeText(activity, "Allow installs, then return to continue", Toast.LENGTH_LONG).show();
postMessage("Install permission required");
return;
}
pendingInstallApk = null;
prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply();
Uri apkUri = UpdateFileProvider.getUriForFile(
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 {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];
+33 -2
View File
@@ -70,6 +70,7 @@ Implemented now:
- configurable base URL, defaulting to `http://100.89.0.116:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000`
- native multi-page shell with `Sessions`, `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
@@ -78,12 +79,42 @@ Implemented now:
- 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
- native action center for health, server status, timeline, preferences, kanban
- native Tools page for health, server status, timeline, preferences, kanban
projects, group messages, hook events, image file/URL upload, image preview
metadata, and native image preview display
- GitHub Actions APK build
- release manifest `latest.json`
- APK download, SHA-256 verification, and installer handoff
- 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