Compare commits

...
8 Commits
Author SHA1 Message Date
Codex c4713dccbd Handle terminal control sequences
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 12m18s
2026-07-07 10:55:06 +00:00
Codex 981e94efc3 Resize terminal to phone viewport
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 11m58s
2026-07-07 04:39:08 +00:00
Codex 434cb67372 Add interactive UI feedback
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Successful in 11m58s
2026-07-07 04:11:56 +00:00
Codex 10e8139a32 Document mirrored release APK policy
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android Compile Check / build (push) Has been cancelled
2026-07-07 04:00:00 +00:00
Codex f9e465bed5 Add update source fallback checks
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m57s
2026-07-07 03:45:46 +00:00
Codex 7b13d42930 Move terminal writes off main thread
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m50s
2026-07-07 03:12:28 +00:00
Codex 72913c5571 Default updates to Gitea
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m48s
2026-07-07 01:37:18 +00:00
Codex dd056c8027 Add projects page and smooth terminal input
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m19s
2026-07-06 18:49:28 +00:00
7 changed files with 779 additions and 115 deletions
+7 -5
View File
@@ -1,4 +1,4 @@
name: Gitea Android APK name: Gitea Android Compile Check
on: [push] on: [push]
@@ -6,7 +6,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Build APK - name: Compile Android APK
env: env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }} CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: | run: |
@@ -62,6 +62,8 @@ jobs:
mkdir -p release mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)" APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" release/tmux-android-gitea.apk cp "${APK_PATH}" release/tmux-android-gitea-compile-check.apk
ls -lh release/tmux-android-gitea.apk ls -lh release/tmux-android-gitea-compile-check.apk
sha256sum release/tmux-android-gitea.apk sha256sum release/tmux-android-gitea-compile-check.apk
echo "Gitea-built APK is a compile check only."
echo "Do not publish it as a release asset; release APKs are GitHub-built and mirrored byte-for-byte to Gitea."
+56 -30
View File
@@ -13,17 +13,23 @@ APIs directly:
session counts session counts
- native tmux session list - native tmux session list
- Sessions page with current API/server and loaded session count - 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 GitHub or Gitea update manifest source - selectable Gitea or GitHub update source, defaulting to Gitea for mobile
- native Tools page for health, server status, timeline, preferences, reachability
kanban projects, group messages, hook events, image file/URL upload, image - native Tools page for health, server status, timeline, preferences, hook
preview info, and native image preview display 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, - 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 - terminal viewport resize based on the phone's visible text area, including
keyboard height changes, so tmux output wraps at the same width the user sees
- automatic update checks with Gitea first and GitHub fallback, plus manual
source-specific checks
- one-download-per-version APK cache, SHA-256 verification, and installer - one-download-per-version APK cache, SHA-256 verification, and installer
handoff handoff
- native Update and About pages for version/build type, protocol, permission, - native Update and About pages for version/build type, protocol, permission,
@@ -91,10 +97,19 @@ https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
``` ```
Those GitHub links are the primary public install/update channel. Gitea releases Gitea is the app's default install/update channel because phones may not be able
are mirrored as a second public source. This Gitea instance does not support the to reach GitHub reliably. GitHub remains an optional public source. This Gitea
GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea instance does not support the GitHub-style `/releases/latest/download/...` URL,
Release API as the stable Gitea update entrypoint. so the app uses the Gitea Release API as the stable Gitea update entrypoint.
Release APKs must be identical on GitHub and Gitea. The canonical APK is the
GitHub Release asset built by `.github/workflows/android.yml`; publish to Gitea
by mirroring that same `tmux-android.apk` byte-for-byte and uploading a
Gitea-specific `latest.json` whose `apkUrl` points at the Gitea asset but whose
`versionCode`, `versionName`, and `sha256` match the GitHub manifest. Do not use
a separately built Gitea APK as a release asset unless it is proven to have the
same SHA-256 as the GitHub APK. This keeps Android signatures and update
compatibility identical no matter which platform the phone can reach.
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,17 +126,21 @@ 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 monospace text with basic ANSI
SGR color support. The terminal view stays bottom-aligned when output is short, SGR color support. The terminal view stays bottom-aligned when output is short,
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
opens. It is enough for shell-oriented remote testing, but it is not yet a opens. Rendering is throttled and the local terminal buffer is capped so opening
complete xterm-compatible renderer for full-screen TUIs such as `vim` or `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
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
@@ -140,27 +159,33 @@ 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 manifest is: The default update source is the Gitea Release API:
```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 public manual APK download is: The app has four update checks on the `Update` page:
```text - `Auto check` tries Gitea first, then tries GitHub only if Gitea cannot be
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk reached.
``` - `Gitea` checks only the public Gitea release API.
- `GitHub` checks only the public GitHub manifest.
- `Selected` checks the source chosen with `Source`, including a custom
manifest/API URL.
The app checks only the selected update source. It does not probe GitHub and Each source retries transient network failures before that source is considered
Gitea during the same update check. Choose the source in the app's `Update` failed. The `APK` and `Release page` buttons still resolve from the selected
page, or use a custom manifest/API URL. source, so they can be forced to Gitea on phones that cannot reliably reach
GitHub.
The built-in Gitea API endpoint is:
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
Gitea tag-specific assets are also public, for example: Gitea tag-specific assets are also public, for example:
@@ -171,8 +196,9 @@ https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux
In the app: In the app:
- Open the `Update` page to check `latest.json`, download the APK, verify - Open the `Update` page and tap `Auto check` to check `latest.json`, download
SHA-256, and open Android's installer. the APK, verify SHA-256, and open Android's installer. Use `Gitea`, `GitHub`,
or `Selected` to force a single update source.
- If the same version APK was already downloaded and its SHA-256 still matches, - If the same version APK was already downloaded and its SHA-256 still matches,
the app reuses that file instead of downloading it again. the app reuses that file instead of downloading it again.
- If Android sends you to the unknown-app install permission screen, return to - If Android sends you to the unknown-app install permission screen, return to
+3
View File
@@ -9,6 +9,8 @@ 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")
@@ -36,6 +38,7 @@ 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()}\"")
@@ -26,6 +26,7 @@ import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan; import android.text.style.StyleSpan;
import android.view.Gravity; import android.view.Gravity;
import android.view.HapticFeedbackConstants;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
@@ -41,6 +42,7 @@ import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
@@ -54,12 +56,23 @@ import java.util.concurrent.Executors;
public final class MainActivity extends Activity { public final class MainActivity extends Activity {
private static final int IMAGE_PICK_REQUEST = 2001; private static final int IMAGE_PICK_REQUEST = 2001;
private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L; private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L;
private static final int TERMINAL_COLS = 96; private static final int DEFAULT_TERMINAL_COLS = 80;
private static final int TERMINAL_ROWS = 32; private static final int DEFAULT_TERMINAL_ROWS = 24;
private static final int MAX_TERMINAL_CHARS = 120_000; private static final int MIN_TERMINAL_COLS = 36;
private static final int MAX_TERMINAL_COLS = 140;
private static final int MIN_TERMINAL_ROWS = 8;
private static final int MAX_TERMINAL_ROWS = 80;
private static final int MAX_TERMINAL_CHARS = 40_000;
private static final int STATUS_NORMAL = 0;
private static final int STATUS_BUSY = 1;
private static final int STATUS_SUCCESS = 2;
private static final int STATUS_ERROR = 3;
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000"; private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
private static final String OLD_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116: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_SESSIONS = "Sessions";
private static final String PAGE_PROJECTS = "Projects";
private static final String PAGE_TOOLS = "Tools"; private static final String PAGE_TOOLS = "Tools";
private static final String PAGE_UPDATE = "Update"; private static final String PAGE_UPDATE = "Update";
private static final String PAGE_ABOUT = "About"; private static final String PAGE_ABOUT = "About";
@@ -82,6 +95,7 @@ public final class MainActivity extends Activity {
private ProgressBar progressBar; private ProgressBar progressBar;
private TextView statusText; private TextView statusText;
private TextView sessionSummaryText; private TextView sessionSummaryText;
private LinearLayout projectList;
private TextView terminalText; private TextView terminalText;
private ScrollView terminalScroll; private ScrollView terminalScroll;
private EditText inputField; private EditText inputField;
@@ -89,6 +103,12 @@ public final class MainActivity extends Activity {
private String activeMainPage = PAGE_SESSIONS; private String activeMainPage = PAGE_SESSIONS;
private String pendingImageUploadSession; private String pendingImageUploadSession;
private final StringBuilder terminalBuffer = new StringBuilder(); private final StringBuilder terminalBuffer = new StringBuilder();
private final StringBuilder queuedTerminalInput = new StringBuilder();
private boolean terminalConnected;
private boolean terminalRenderPending;
private long lastTerminalRenderMs;
private int terminalCols = DEFAULT_TERMINAL_COLS;
private int terminalRows = DEFAULT_TERMINAL_ROWS;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -108,6 +128,7 @@ public final class MainActivity extends Activity {
.putBoolean("tailscale_defaults_applied_v1", true) .putBoolean("tailscale_defaults_applied_v1", true)
.apply(); .apply();
} }
migrateDefaultUpdateSourceToGitea();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24)); getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24)); getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
@@ -146,7 +167,6 @@ public final class MainActivity extends Activity {
statusText.setTextSize(12); statusText.setTextSize(12);
statusText.setGravity(Gravity.CENTER_VERTICAL); statusText.setGravity(Gravity.CENTER_VERTICAL);
statusText.setPadding(dp(10), 0, dp(10), 0); statusText.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true); statusText.setSingleLine(true);
setStatus("Ready"); setStatus("Ready");
applySystemBarInsets(root); applySystemBarInsets(root);
@@ -282,6 +302,59 @@ public final class MainActivity extends Activity {
setStatus("Tools"); setStatus("Tools");
} }
private void renderProjectsScreen() {
closeTerminalSocket();
activeSessionName = null;
activeMainPage = PAGE_PROJECTS;
root.removeAllViews();
root.addView(createServerBar(), matchWrap());
root.addView(createMainTabs(PAGE_PROJECTS), 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(
"Project groups",
"Kanban projects group tmux sessions and group messages through the server API."
));
content.addView(sectionTitle("Projects"));
content.addView(actionPanel(
actionButton("Refresh", view -> refreshProjects()),
actionButton("New project", view -> promptCreateKanbanProject()),
actionButton("Delete project", view -> promptDeleteKanbanProject()),
actionButton("Remove session", view -> promptRemoveKanbanSession())
));
projectList = new LinearLayout(this);
projectList.setOrientation(LinearLayout.VERTICAL);
content.addView(projectList, matchWrap());
content.addView(sectionTitle("Group messages"));
content.addView(actionPanel(
actionButton("Messages", view -> promptGroupMessages()),
actionButton("Send message", view -> promptSendGroupMessage()),
actionButton("Scan message", view -> promptScanGroupMessage()),
actionButton("Post hook", view -> promptPostHookEvent())
));
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("Projects");
refreshProjects();
}
private void renderUpdateScreen() { private void renderUpdateScreen() {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
@@ -307,7 +380,10 @@ public final class MainActivity extends Activity {
)); ));
content.addView(sectionTitle("Update")); content.addView(sectionTitle("Update"));
content.addView(actionPanel( content.addView(actionPanel(
actionButton("Check now", view -> updateManager.check(true)), actionButton("Auto check", view -> updateManager.check(true)),
actionButton("Gitea", view -> updateManager.checkGitea(true)),
actionButton("GitHub", view -> updateManager.checkGithub(true)),
actionButton("Selected", view -> updateManager.checkSelected(true)),
actionButton("Source", view -> showUpdateSourcePicker()), actionButton("Source", view -> showUpdateSourcePicker()),
actionButton("APK", view -> updateManager.openApkDownload()) actionButton("APK", view -> updateManager.openApkDownload())
)); ));
@@ -365,7 +441,7 @@ public final class MainActivity extends Activity {
"Update policy", "Update policy",
"Selected source: " + updateSourceHost() + "\n" "Selected source: " + updateSourceHost() + "\n"
+ prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL) + "\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." + "Auto check tries Gitea first, then GitHub if Gitea cannot be reached. Manual Gitea/GitHub/Selected checks are also available. APK downloads are cached by version and reused after Android install permission is granted."
)); ));
content.addView(actionPanel( content.addView(actionPanel(
actionButton("Release page", view -> updateManager.openReleasePage()), actionButton("Release page", view -> updateManager.openReleasePage()),
@@ -435,6 +511,11 @@ public final class MainActivity extends Activity {
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles())); actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
return; return;
} }
if (PAGE_PROJECTS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Refresh", view -> refreshProjects()));
actionRow.addView(toolbarButton("New", view -> promptCreateKanbanProject()));
return;
}
if (PAGE_TOOLS.equals(activeMainPage)) { if (PAGE_TOOLS.equals(activeMainPage)) {
actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health()))); actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health())));
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles())); actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
@@ -496,6 +577,7 @@ public final class MainActivity extends Activity {
renderSessionScreen(); renderSessionScreen();
refreshSessions(); refreshSessions();
})); }));
row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen()));
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen())); row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen())); row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen())); row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen()));
@@ -617,6 +699,8 @@ public final class MainActivity extends Activity {
connectAppEvents(); connectAppEvents();
if (PAGE_SESSIONS.equals(activeMainPage)) { if (PAGE_SESSIONS.equals(activeMainPage)) {
refreshSessions(); refreshSessions();
} else if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects();
} }
} }
@@ -640,6 +724,141 @@ public final class MainActivity extends Activity {
}); });
} }
private void refreshProjects() {
if (projectList != null) {
projectList.removeAllViews();
projectList.addView(projectStateText("Loading projects..."), matchWrap());
}
setStatus("Loading projects from " + api.getBaseUrl());
progressBar.setVisibility(View.VISIBLE);
executor.execute(() -> {
try {
String text = api.kanbanProjects();
runOnUiThread(() -> {
renderProjectList(text);
setStatus("Loaded projects");
});
} catch (Exception error) {
runOnUiThread(() -> {
if (projectList != null) {
projectList.removeAllViews();
projectList.addView(projectStateText("Project load failed:\n" + error.getMessage()), matchWrap());
}
showMessage("Project load failed: " + error.getMessage());
});
} finally {
runOnUiThread(() -> progressBar.setVisibility(View.GONE));
}
});
}
private void renderProjectList(String text) {
if (projectList == null) {
return;
}
projectList.removeAllViews();
try {
JSONObject rootObject = new JSONObject(text == null || text.isEmpty() ? "{}" : text);
JSONArray projects = rootObject.optJSONArray("projects");
if (projects == null || projects.length() == 0) {
projectList.addView(projectStateText("No projects"), matchWrap());
return;
}
for (int index = 0; index < projects.length(); index++) {
projectList.addView(projectCard(projects.getJSONObject(index)), matchWrap());
}
} catch (Exception error) {
projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap());
}
}
private TextView projectStateText(String text) {
TextView view = bodyText(text);
view.setTypeface(Typeface.MONOSPACE);
view.setTextIsSelectable(true);
view.setPadding(dp(12), dp(10), dp(12), dp(10));
view.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(42, 51, 61), 1));
return view;
}
private View projectCard(JSONObject project) {
String name = project.optString("name", "(unnamed)");
String path = project.optString("path", "");
String server = project.isNull("server") ? "" : project.optString("server", "");
JSONArray agents = project.optJSONArray("agents");
int agentCount = agents == null ? 0 : agents.length();
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.setPadding(dp(12), dp(11), dp(12), dp(11));
card.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
TextView title = new TextView(this);
title.setText(name);
title.setTextColor(Color.WHITE);
title.setTextSize(17);
title.setTypeface(Typeface.DEFAULT_BOLD);
String detail = "path:" + defaultValue(path, "~")
+ (server.isEmpty() ? "" : " server:" + server)
+ " agents:" + agentCount;
TextView meta = bodyText(detail);
meta.setPadding(0, dp(4), 0, dp(8));
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.HORIZONTAL);
actions.addView(toolbarButton("Messages", view -> showRaw("Group messages: " + name, () -> api.groupMessages(name))));
actions.addView(toolbarButton("Add", view -> promptAddKanbanSessionToProject(name)));
actions.addView(toolbarButton("Delete", view -> runApiAction("Delete kanban project", () -> api.deleteKanbanProject(name))));
card.addView(title);
card.addView(meta);
card.addView(actions);
if (agents != null && agents.length() > 0) {
TextView agentsTitle = bodyText("Agents");
agentsTitle.setTextColor(Color.rgb(139, 148, 158));
agentsTitle.setTypeface(Typeface.DEFAULT_BOLD);
agentsTitle.setPadding(0, dp(10), 0, dp(4));
card.addView(agentsTitle);
for (int index = 0; index < agents.length(); index++) {
JSONObject agent = agents.optJSONObject(index);
if (agent != null) {
card.addView(projectAgentRow(name, agent), matchWrap());
}
}
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
card.setLayoutParams(params);
return card;
}
private View projectAgentRow(String projectName, JSONObject agent) {
String sessionName = defaultValue(agent.optString("sessionName", ""), agent.optString("name", ""));
String label = defaultValue(agent.optString("name", ""), sessionName);
String kind = agent.optString("kind", "session");
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(3), 0, dp(3));
TextView name = bodyText(kind + " " + label);
name.setSingleLine(true);
row.addView(name, new LinearLayout.LayoutParams(0, dp(38), 1));
if (!sessionName.isEmpty()) {
row.addView(toolbarButton("Open", view -> openTerminal(sessionName)));
row.addView(toolbarButton("Remove", view -> runApiAction("Remove kanban session", () ->
api.removeKanbanSession(projectName, sessionName, false))));
}
return row;
}
private void renderSessionList(List<SessionSummary> sessions) { private void renderSessionList(List<SessionSummary> sessions) {
LinearLayout list = root.findViewWithTag("session-list"); LinearLayout list = root.findViewWithTag("session-list");
if (list == null) { if (list == null) {
@@ -753,6 +972,12 @@ public final class MainActivity extends Activity {
private void openTerminal(String sessionName) { private void openTerminal(String sessionName) {
activeSessionName = sessionName; activeSessionName = sessionName;
terminalBuffer.setLength(0); terminalBuffer.setLength(0);
queuedTerminalInput.setLength(0);
terminalConnected = false;
terminalRenderPending = false;
lastTerminalRenderMs = 0L;
terminalCols = DEFAULT_TERMINAL_COLS;
terminalRows = DEFAULT_TERMINAL_ROWS;
root.removeAllViews(); root.removeAllViews();
root.addView(createTerminalTopBar(sessionName), matchWrap()); root.addView(createTerminalTopBar(sessionName), matchWrap());
@@ -773,6 +998,8 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
)); ));
terminalScroll.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
resizeTerminalToViewport(false));
root.addView(terminalScroll, new LinearLayout.LayoutParams( root.addView(terminalScroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
0, 0,
@@ -787,7 +1014,10 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
dp(28) dp(28)
)); ));
connectTerminal(sessionName); terminalScroll.post(() -> {
resizeTerminalToViewport(false);
connectTerminal(sessionName);
});
} }
private LinearLayout createTerminalTopBar(String sessionName) { private LinearLayout createTerminalTopBar(String sessionName) {
@@ -916,12 +1146,12 @@ public final class MainActivity extends Activity {
break; break;
case 3: case 3:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(-TERMINAL_ROWS); terminalSocket.scroll(-terminalRows);
} }
break; break;
case 4: case 4:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(TERMINAL_ROWS); terminalSocket.scroll(terminalRows);
} }
break; break;
case 5: case 5:
@@ -1023,6 +1253,10 @@ public final class MainActivity extends Activity {
promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName)); promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName));
} }
private void promptAddKanbanSessionToProject(String projectName) {
promptText("Add session to " + projectName, "session", "", sessionName -> api.addKanbanSession(projectName, sessionName));
}
private void promptRemoveKanbanSession() { private void promptRemoveKanbanSession() {
LinearLayout form = formRoot(); LinearLayout form = formRoot();
EditText project = formField(form, "Project", ""); EditText project = formField(form, "Project", "");
@@ -1219,15 +1453,15 @@ public final class MainActivity extends Activity {
text.append("Tap Release on the About page. The app resolves the page from the selected update source.\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('\n');
text.append("In-app update:\n"); text.append("In-app update:\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("Tap Auto check on the Update page to try Gitea first and GitHub only if Gitea cannot be reached. Use Gitea, GitHub, or Selected to force one source. Each source retries transient network failures before failing. The app downloads one APK per version, verifies SHA-256, then opens Android's installer.\n");
text.append("Selected manifest: ") text.append("Selected manifest: ")
.append(updateSourceHost()) .append(updateSourceHost())
.append('\n') .append('\n')
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)) .append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n'); .append('\n');
text.append("Available sources:\n"); text.append("Available sources:\n");
text.append("GitHub: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n'); text.append("Gitea default: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append("Gitea: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n'); text.append("GitHub optional: ").append(BuildConfig.DEFAULT_GITHUB_UPDATE_URL).append('\n');
text.append('\n'); text.append('\n');
text.append(permissionSummary()); text.append(permissionSummary());
@@ -1264,17 +1498,17 @@ public final class MainActivity extends Activity {
private void showUpdateSourcePicker() { private void showUpdateSourcePicker() {
String[] items = { String[] items = {
"GitHub: " + BuildConfig.DEFAULT_UPDATE_URL, "Gitea default: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
"Gitea: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL, "GitHub optional: " + BuildConfig.DEFAULT_GITHUB_UPDATE_URL,
"Custom URL" "Custom URL"
}; };
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle("Update source") .setTitle("Update source")
.setItems(items, (dialog, which) -> { .setItems(items, (dialog, which) -> {
if (which == 0) { if (which == 0) {
setUpdateUrl(BuildConfig.DEFAULT_UPDATE_URL);
} else if (which == 1) {
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL); setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
} else if (which == 1) {
setUpdateUrl(BuildConfig.DEFAULT_GITHUB_UPDATE_URL);
} else { } else {
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl); promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
} }
@@ -1292,6 +1526,15 @@ public final class MainActivity extends Activity {
} }
} }
private void migrateDefaultUpdateSourceToGitea() {
String current = prefs.getString("update_url", "");
if (current == null || current.trim().isEmpty() || OLD_GITHUB_DEFAULT_UPDATE_URL.equals(current.trim())) {
prefs.edit()
.putString("update_url", BuildConfig.DEFAULT_GITEA_UPDATE_URL)
.apply();
}
}
private void probeServerProfiles() { private void probeServerProfiles() {
progressBar.setVisibility(View.VISIBLE); progressBar.setVisibility(View.VISIBLE);
setStatus("Probing Tailscale APIs..."); setStatus("Probing Tailscale APIs...");
@@ -1405,7 +1648,9 @@ public final class MainActivity extends Activity {
runOnUiThread(() -> { runOnUiThread(() -> {
progressBar.setVisibility(View.GONE); progressBar.setVisibility(View.GONE);
showMessage(label + " done"); showMessage(label + " done");
if (activeSessionName == null) { if (PAGE_PROJECTS.equals(activeMainPage)) {
refreshProjects();
} else if (activeSessionName == null) {
refreshSessions(); refreshSessions();
} }
}); });
@@ -1525,12 +1770,20 @@ public final class MainActivity extends Activity {
private void connectTerminal(String sessionName) { private void connectTerminal(String sessionName) {
closeTerminalSocket(); closeTerminalSocket();
queuedTerminalInput.setLength(0);
terminalConnected = false;
setStatus("Connecting " + sessionName); setStatus("Connecting " + sessionName);
resizeTerminalToViewport(false);
appendTerminal("[connecting]\r\n"); appendTerminal("[connecting]\r\n");
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() { terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
@Override @Override
public void onConnected() { public void onConnected() {
runOnUiThread(() -> setStatus("Connected " + sessionName)); runOnUiThread(() -> {
terminalConnected = true;
setStatus("Connected " + sessionName);
resizeTerminalToViewport(true);
flushQueuedTerminalInput();
});
} }
@Override @Override
@@ -1548,10 +1801,45 @@ public final class MainActivity extends Activity {
@Override @Override
public void onClosed() { public void onClosed() {
runOnUiThread(() -> setStatus("Disconnected " + sessionName)); runOnUiThread(() -> {
terminalConnected = false;
setStatus("Disconnected " + sessionName);
});
} }
}); });
terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS); terminalSocket.connect(api.getBaseUrl(), sessionName, terminalCols, terminalRows);
}
private void resizeTerminalToViewport(boolean forceSend) {
if (terminalText == null || terminalScroll == null) {
return;
}
int width = terminalScroll.getWidth();
int height = terminalScroll.getHeight();
if (width <= 0 || height <= 0) {
return;
}
int horizontalPadding = terminalText.getPaddingLeft() + terminalText.getPaddingRight();
int verticalPadding = terminalText.getPaddingTop() + terminalText.getPaddingBottom();
float charWidth = terminalText.getPaint().measureText("W");
if (charWidth <= 0f) {
charWidth = dp(8);
}
int lineHeight = terminalText.getLineHeight();
if (lineHeight <= 0) {
lineHeight = dp(16);
}
int cols = clamp((int) Math.floor((width - horizontalPadding) / charWidth), MIN_TERMINAL_COLS, MAX_TERMINAL_COLS);
int rows = clamp((height - verticalPadding) / lineHeight, MIN_TERMINAL_ROWS, MAX_TERMINAL_ROWS);
if (cols == terminalCols && rows == terminalRows && !forceSend) {
return;
}
terminalCols = cols;
terminalRows = rows;
TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) {
socket.resize(cols, rows);
}
} }
private void sendLine() { private void sendLine() {
@@ -1572,8 +1860,14 @@ public final class MainActivity extends Activity {
return; return;
} }
TerminalSocketClient socket = terminalSocket; TerminalSocketClient socket = terminalSocket;
if (socket != null) { if (socket != null && !socket.isClosed() && terminalConnected) {
socket.sendInput(data); socket.sendInput(data);
setStatus("Sent input");
return;
}
if (socket != null && !socket.isClosed()) {
queuedTerminalInput.append(data);
setStatus("Queued input until terminal connects");
return; return;
} }
executor.execute(() -> { executor.execute(() -> {
@@ -1581,24 +1875,38 @@ public final class MainActivity extends Activity {
for (int i = 0; i < data.length(); i += 200) { for (int i = 0; i < data.length(); i += 200) {
api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length()))); api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length())));
} }
runOnUiThread(() -> setStatus("Sent input"));
} catch (Exception error) { } catch (Exception error) {
runOnUiThread(() -> showMessage("Input failed: " + error.getMessage())); runOnUiThread(() -> showMessage("Input failed: " + error.getMessage()));
} }
}); });
} }
private void flushQueuedTerminalInput() {
if (!terminalConnected || terminalSocket == null || queuedTerminalInput.length() == 0) {
return;
}
String data = queuedTerminalInput.toString();
queuedTerminalInput.setLength(0);
terminalSocket.sendInput(data);
}
private void pasteClipboard() { private void pasteClipboard() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null || !clipboard.hasPrimaryClip()) { if (clipboard == null || !clipboard.hasPrimaryClip()) {
showMessage("Clipboard is empty");
return; return;
} }
ClipData clip = clipboard.getPrimaryClip(); ClipData clip = clipboard.getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) { if (clip == null || clip.getItemCount() == 0) {
showMessage("Clipboard is empty");
return; return;
} }
CharSequence text = clip.getItemAt(0).coerceToText(this); CharSequence text = clip.getItemAt(0).coerceToText(this);
if (text != null && text.length() > 0) { if (text != null && text.length() > 0) {
sendTerminalInput(text.toString()); sendTerminalInput(text.toString());
} else {
showMessage("Clipboard is empty");
} }
} }
@@ -1607,6 +1915,27 @@ public final class MainActivity extends Activity {
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS); terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
} }
scheduleTerminalRender();
}
private void scheduleTerminalRender() {
if (terminalRenderPending || terminalText == null) {
return;
}
terminalRenderPending = true;
long now = System.currentTimeMillis();
long delay = Math.max(0L, TERMINAL_RENDER_INTERVAL_MS - (now - lastTerminalRenderMs));
terminalText.postDelayed(() -> {
terminalRenderPending = false;
renderTerminalNow();
}, delay);
}
private void renderTerminalNow() {
if (terminalText == null || terminalScroll == null) {
return;
}
lastTerminalRenderMs = System.currentTimeMillis();
terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString())); terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
@@ -1620,6 +1949,14 @@ public final class MainActivity extends Activity {
while (index < text.length()) { while (index < text.length()) {
char item = text.charAt(index); char item = text.charAt(index);
if (item == '\r') { if (item == '\r') {
deleteCurrentTerminalLine(output);
index++;
continue;
}
if (item == '\b') {
if (output.length() > 0 && output.charAt(output.length() - 1) != '\n') {
output.delete(output.length() - 1, output.length());
}
index++; index++;
continue; continue;
} }
@@ -1634,26 +1971,92 @@ public final class MainActivity extends Activity {
fg = state[0]; fg = state[0];
bg = state[1]; bg = state[1];
bold = state[2] == 1; bold = state[2] == 1;
} else if (command == 'K') {
String params = text.substring(index + 2, end);
if (params.startsWith("2")) {
deleteCurrentTerminalLine(output);
}
} else if (command == 'J') {
String params = text.substring(index + 2, end);
if (params.startsWith("2") || params.startsWith("3")) {
output.clear();
}
} }
index = end + 1; index = end + 1;
continue; continue;
} }
if (item == '\u001b') {
int skipped = skipNonCsiEscape(text, index);
if (skipped > index) {
index = skipped;
continue;
}
}
int start = output.length(); int runStart = index;
output.append(item); while (index < text.length()) {
int finish = output.length(); char runItem = text.charAt(index);
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); if (runItem == '\r' || runItem == '\b' || runItem == '\u001b') {
if (bg != Color.TRANSPARENT) { break;
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); }
index++;
} }
if (bold) { appendTerminalRun(output, text.substring(runStart, index), fg, bg, bold);
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
index++;
} }
return output; return output;
} }
private void deleteCurrentTerminalLine(SpannableStringBuilder output) {
int start = output.length();
while (start > 0 && output.charAt(start - 1) != '\n') {
start--;
}
if (start < output.length()) {
output.delete(start, output.length());
}
}
private int skipNonCsiEscape(String text, int index) {
if (index + 1 >= text.length()) {
return index + 1;
}
char next = text.charAt(index + 1);
if (next == ']' || next == 'P' || next == '^' || next == '_') {
int cursor = index + 2;
while (cursor < text.length()) {
char item = text.charAt(cursor);
if (item == '\u0007') {
return cursor + 1;
}
if (item == '\u001b' && cursor + 1 < text.length() && text.charAt(cursor + 1) == '\\') {
return cursor + 2;
}
cursor++;
}
return text.length();
}
if (next == '(' || next == ')' || next == '*' || next == '+' || next == '-' || next == '.') {
return Math.min(index + 3, text.length());
}
return Math.min(index + 2, text.length());
}
private void appendTerminalRun(SpannableStringBuilder output, String text, int fg, int bg, boolean bold) {
if (text.isEmpty()) {
return;
}
int start = output.length();
output.append(text);
int finish = output.length();
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bg != Color.TRANSPARENT) {
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (bold) {
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
private int findAnsiEnd(String text, int start) { private int findAnsiEnd(String text, int start) {
for (int index = start; index < text.length(); index++) { for (int index = start; index < text.length(); index++) {
char item = text.charAt(index); char item = text.charAt(index);
@@ -1795,7 +2198,20 @@ public final class MainActivity extends Activity {
button.setPadding(dp(10), 0, dp(10), 0); button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground()); button.setBackground(buttonBackground());
button.setGravity(Gravity.CENTER); button.setGravity(Gravity.CENTER);
button.setOnClickListener(listener); button.setHapticFeedbackEnabled(true);
button.setOnClickListener(view -> {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
view.animate().cancel();
view.setScaleX(0.96f);
view.setScaleY(0.96f);
view.animate()
.scaleX(1f)
.scaleY(1f)
.setDuration(120L)
.start();
setStatus(label);
listener.onClick(view);
});
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
dp(42) dp(42)
@@ -1816,6 +2232,7 @@ public final class MainActivity extends Activity {
private StateListDrawable buttonBackground() { private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable(); StateListDrawable states = new StateListDrawable();
states.addState(new int[]{-android.R.attr.state_enabled}, rounded(Color.rgb(24, 30, 37), 8, Color.rgb(35, 42, 50), 1));
states.addState(new int[]{android.R.attr.state_pressed}, rounded(Color.rgb(64, 78, 94), 8, Color.rgb(91, 108, 128), 1)); states.addState(new int[]{android.R.attr.state_pressed}, rounded(Color.rgb(64, 78, 94), 8, Color.rgb(91, 108, 128), 1));
states.addState(new int[]{android.R.attr.state_focused}, rounded(Color.rgb(48, 61, 76), 8, Color.rgb(98, 128, 164), 1)); states.addState(new int[]{android.R.attr.state_focused}, rounded(Color.rgb(48, 61, 76), 8, Color.rgb(98, 128, 164), 1));
states.addState(new int[]{}, rounded(Color.rgb(34, 43, 53), 8, Color.rgb(55, 66, 80), 1)); states.addState(new int[]{}, rounded(Color.rgb(34, 43, 53), 8, Color.rgb(55, 66, 80), 1));
@@ -1870,21 +2287,99 @@ public final class MainActivity extends Activity {
} }
private void showMessage(String message) { private void showMessage(String message) {
if (message == null || message.trim().isEmpty()) {
return;
}
Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
setStatus(message); setStatus(message);
} }
private void setStatus(String message) { private void setStatus(String message) {
setStatus(message, inferStatusTone(message));
}
private void setStatus(String message, int tone) {
if (statusText != null) { if (statusText != null) {
statusText.setText(message); String value = message == null || message.trim().isEmpty() ? "Ready" : message.trim();
statusText.setText(value);
int bg = Color.rgb(22, 27, 34);
int stroke = Color.TRANSPARENT;
int text = Color.rgb(210, 215, 224);
if (tone == STATUS_BUSY) {
bg = Color.rgb(20, 44, 68);
stroke = Color.rgb(44, 96, 142);
text = Color.rgb(220, 238, 255);
} else if (tone == STATUS_SUCCESS) {
bg = Color.rgb(20, 56, 40);
stroke = Color.rgb(42, 118, 78);
text = Color.rgb(218, 245, 228);
} else if (tone == STATUS_ERROR) {
bg = Color.rgb(72, 28, 31);
stroke = Color.rgb(154, 66, 72);
text = Color.rgb(255, 226, 226);
}
statusText.setTextColor(text);
statusText.setBackground(rounded(bg, 0, stroke, tone == STATUS_NORMAL ? 0 : 1));
} }
} }
private int inferStatusTone(String message) {
if (message == null) {
return STATUS_NORMAL;
}
String value = message.toLowerCase(java.util.Locale.ROOT);
if (value.contains("failed")
|| value.contains("error")
|| value.contains("invalid")
|| value.contains("cannot")
|| value.contains("mismatch")
|| value.contains("disconnected")
|| value.contains("empty")
|| value.contains("no package")
|| value.contains("no app")) {
return STATUS_ERROR;
}
if (value.contains("loading")
|| value.contains("checking")
|| value.contains("connecting")
|| value.contains("probing")
|| value.contains("downloading")
|| value.contains("preparing")
|| value.contains("verifying")
|| value.contains("retrying")
|| value.contains("queued")
|| value.contains("resolving")) {
return STATUS_BUSY;
}
if (value.contains("done")
|| value.contains("loaded")
|| value.contains("connected")
|| value.contains("created")
|| value.contains("killed")
|| value.contains("opened")
|| value.contains("sent")
|| value.contains("selected")
|| value.contains("saved")
|| value.contains("using downloaded")
|| value.contains("update found")
|| value.contains("already up to date")) {
return STATUS_SUCCESS;
}
return STATUS_NORMAL;
}
private int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private int dp(int value) { private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density); return Math.round(value * getResources().getDisplayMetrics().density);
} }
private void closeTerminalSocket() { private void closeTerminalSocket() {
terminalConnected = false;
queuedTerminalInput.setLength(0);
terminalRenderPending = false;
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.close(); terminalSocket.close();
terminalSocket = null; terminalSocket = null;
@@ -12,6 +12,9 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays; import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.SSLSocketFactory;
@@ -25,6 +28,8 @@ final class TerminalSocketClient {
private final Object writeLock = new Object(); private final Object writeLock = new Object();
private final Listener listener; private final Listener listener;
private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor(runnable ->
new Thread(runnable, "terminal-ws-write"));
private Socket socket; private Socket socket;
private BufferedInputStream input; private BufferedInputStream input;
private BufferedOutputStream output; private BufferedOutputStream output;
@@ -42,35 +47,41 @@ final class TerminalSocketClient {
} }
void sendInput(String data) { void sendInput(String data) {
sendMessage("input", "data", data); sendMessageAsync("input", "data", data);
} }
void resize(int cols, int rows) { void resize(int cols, int rows) {
sendMessage("resize", "cols", cols, "rows", rows); sendMessageAsync("resize", "cols", cols, "rows", rows);
} }
void scroll(int lines) { void scroll(int lines) {
sendMessage("scroll", "lines", lines); sendMessageAsync("scroll", "lines", lines);
} }
void clearHistory() { void clearHistory() {
sendMessage("clear-history"); sendMessageAsync("clear-history");
} }
void close() { void close() {
closed = true; closed = true;
try { try {
sendFrame(8, new byte[0]); writeExecutor.execute(() -> {
} catch (Exception ignored) { try {
} sendFrame(8, new byte[0]);
try { } catch (Exception ignored) {
if (socket != null) { }
socket.close(); closeSocketQuietly();
} });
} catch (Exception ignored) { writeExecutor.shutdown();
} catch (RejectedExecutionException ignored) {
closeSocketQuietly();
} }
} }
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,14 +89,14 @@ 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(); sendMessageSync(
sendMessage(
"attach", "attach",
"tabId", "android-" + System.currentTimeMillis(), "tabId", "android-" + System.currentTimeMillis(),
"sessionName", sessionName, "sessionName", sessionName,
"cols", cols, "cols", cols,
"rows", rows "rows", rows
); );
listener.onConnected();
readLoop(); readLoop();
} catch (Exception error) { } catch (Exception error) {
if (!closed) { if (!closed) {
@@ -94,12 +105,8 @@ final class TerminalSocketClient {
} finally { } finally {
closed = true; closed = true;
listener.onClosed(); listener.onClosed();
try { closeSocketQuietly();
if (socket != null) { writeExecutor.shutdownNow();
socket.close();
}
} catch (Exception ignored) {
}
} }
} }
@@ -239,7 +246,7 @@ final class TerminalSocketClient {
} }
} }
private void sendJson(JSONObject object) { private void sendJsonSync(JSONObject object) {
try { try {
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8)); sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
} catch (Exception error) { } catch (Exception error) {
@@ -249,14 +256,21 @@ final class TerminalSocketClient {
} }
} }
private void sendMessage(String type, Object... keyValues) { private void sendMessageAsync(String type, Object... keyValues) {
try {
writeExecutor.execute(() -> sendMessageSync(type, keyValues));
} catch (RejectedExecutionException ignored) {
}
}
private void sendMessageSync(String type, Object... keyValues) {
try { try {
JSONObject object = new JSONObject(); JSONObject object = new JSONObject();
object.put("type", type); object.put("type", type);
for (int i = 0; i + 1 < keyValues.length; i += 2) { for (int i = 0; i + 1 < keyValues.length; i += 2) {
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]); object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
} }
sendJson(object); sendJsonSync(object);
} catch (Exception error) { } catch (Exception error) {
if (!closed) { if (!closed) {
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage()); listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
@@ -264,6 +278,15 @@ final class TerminalSocketClient {
} }
} }
private void closeSocketQuietly() {
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
}
private void sendFrame(int opcode, byte[] payload) throws Exception { private void sendFrame(int opcode, byte[] payload) throws Exception {
synchronized (writeLock) { synchronized (writeLock) {
if (output == null) { if (output == null) {
@@ -26,6 +26,8 @@ import java.util.concurrent.Executors;
final class UpdateManager { final class UpdateManager {
private static final String PREF_PENDING_INSTALL_APK = "pending_install_apk"; private static final String PREF_PENDING_INSTALL_APK = "pending_install_apk";
private static final int NETWORK_ATTEMPTS = 3;
private static final long RETRY_DELAY_MS = 1200L;
interface Callback { interface Callback {
void onChecking(boolean checking); void onChecking(boolean checking);
@@ -51,25 +53,77 @@ final class UpdateManager {
} }
void check(boolean userInitiated) { void check(boolean userInitiated) {
checkWithFallback(userInitiated);
}
void checkSelected(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"selected source",
new String[]{getUpdateManifestUrl()}
);
}
void checkGitea(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"Gitea",
new String[]{BuildConfig.DEFAULT_GITEA_UPDATE_URL}
);
}
void checkGithub(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"GitHub",
new String[]{BuildConfig.DEFAULT_GITHUB_UPDATE_URL}
);
}
void checkWithFallback(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"Gitea, then GitHub",
new String[]{BuildConfig.DEFAULT_GITEA_UPDATE_URL, BuildConfig.DEFAULT_GITHUB_UPDATE_URL}
);
}
private void startUpdateCheck(boolean userInitiated, String label, String[] manifestUrls) {
if (checkInProgress) { if (checkInProgress) {
postMessage("Update check already running"); postMessage("Update check already running");
return; return;
} }
String manifestUrl = getUpdateManifestUrl();
checkInProgress = true; checkInProgress = true;
callback.onChecking(true); callback.onChecking(true);
postMessage("Checking update from " + hostLabel(manifestUrl) + "..."); postMessage("Checking update: " + label + "...");
executor.execute(() -> { executor.execute(() -> {
Exception lastError = null;
try { try {
ReleaseInfo info = fetchReleaseInfo(manifestUrl); for (int index = 0; index < manifestUrls.length; index++) {
if (info.versionCode <= BuildConfig.VERSION_CODE) { String manifestUrl = manifestUrls[index];
postMessage("Already up to date: " + BuildConfig.VERSION_NAME); try {
return; postMessage("Checking " + hostLabel(manifestUrl) + "...");
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (info.versionCode <= BuildConfig.VERSION_CODE) {
postMessage("Already up to date: " + BuildConfig.VERSION_NAME + " from " + hostLabel(manifestUrl));
return;
}
postMessage("Update found: " + info.versionName + " from " + hostLabel(manifestUrl));
activity.runOnUiThread(() -> showUpdateDialog(info));
return;
} catch (Exception error) {
lastError = error;
if (index + 1 < manifestUrls.length) {
postMessage(hostLabel(manifestUrl) + " failed; trying " + hostLabel(manifestUrls[index + 1]) + "...");
}
}
} }
postMessage("Update found: " + info.versionName); if (lastError != null) {
activity.runOnUiThread(() -> showUpdateDialog(info)); throw lastError;
} catch (Exception error) { }
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null); throw new IllegalStateException("No update sources configured");
} catch (Exception finalError) {
postMessage(userInitiated ? "Update check failed: " + finalError.getMessage() : null);
} finally { } finally {
checkInProgress = false; checkInProgress = false;
activity.runOnUiThread(() -> callback.onChecking(false)); activity.runOnUiThread(() -> callback.onChecking(false));
@@ -177,6 +231,22 @@ final class UpdateManager {
} }
private String readText(String url) throws Exception { private String readText(String url) throws Exception {
Exception lastError = null;
for (int attempt = 1; attempt <= NETWORK_ATTEMPTS; attempt++) {
try {
return readTextOnce(url);
} catch (Exception error) {
lastError = error;
if (attempt < NETWORK_ATTEMPTS) {
postMessage("Retrying " + hostLabel(url) + " (" + (attempt + 1) + "/" + NETWORK_ATTEMPTS + ")...");
waitBeforeRetry();
}
}
}
throw lastError == null ? new IllegalStateException("Request failed") : lastError;
}
private String readTextOnce(String url) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(12000); connection.setConnectTimeout(12000);
connection.setReadTimeout(12000); connection.setReadTimeout(12000);
@@ -238,7 +308,36 @@ final class UpdateManager {
} }
postMessage("Downloading " + info.versionName + "..."); postMessage("Downloading " + info.versionName + "...");
HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection(); File partial = new File(dir, "tmux-android-" + info.versionCode + ".apk.tmp");
if (partial.exists()) {
partial.delete();
}
if (apk.exists()) {
apk.delete();
}
Exception lastError = null;
for (int attempt = 1; attempt <= NETWORK_ATTEMPTS; attempt++) {
try {
downloadApkOnce(info.apkUrl, partial);
if (!partial.renameTo(apk)) {
throw new IllegalStateException("Cannot finalize APK download");
}
return apk;
} catch (Exception error) {
lastError = error;
partial.delete();
if (attempt < NETWORK_ATTEMPTS) {
postMessage("Retrying APK download (" + (attempt + 1) + "/" + NETWORK_ATTEMPTS + ")...");
waitBeforeRetry();
}
}
}
throw lastError == null ? new IllegalStateException("APK download failed") : lastError;
}
private void downloadApkOnce(String apkUrl, File apk) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(apkUrl).openConnection();
connection.setConnectTimeout(12000); connection.setConnectTimeout(12000);
connection.setReadTimeout(60000); connection.setReadTimeout(60000);
try (InputStream input = new BufferedInputStream(connection.getInputStream()); try (InputStream input = new BufferedInputStream(connection.getInputStream());
@@ -251,7 +350,6 @@ final class UpdateManager {
} finally { } finally {
connection.disconnect(); connection.disconnect();
} }
return apk;
} }
private boolean isCachedApkValid(File apk, ReleaseInfo info) throws Exception { private boolean isCachedApkValid(File apk, ReleaseInfo info) throws Exception {
@@ -366,4 +464,8 @@ final class UpdateManager {
} }
activity.runOnUiThread(() -> callback.onMessage(message)); activity.runOnUiThread(() -> callback.onMessage(message));
} }
private void waitBeforeRetry() throws InterruptedException {
Thread.sleep(RETRY_DELAY_MS);
}
} }
+25 -12
View File
@@ -70,7 +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`, `Tools`, `Update`, and `About` - 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
@@ -79,15 +80,19 @@ 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 Tools page 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`
- selected-source update checks; GitHub and Gitea are not probed in the same - Auto/Gitea/GitHub/Selected update checks, with Auto falling back from Gitea
update check to GitHub
- one-download-per-version APK cache, SHA-256 verification, and installer - one-download-per-version APK cache, SHA-256 verification, and installer
handoff handoff
- terminal viewport resize derived from the Android text area, including
keyboard height changes, to avoid fixed-width tmux output wrapping on phones
- permission/about surfaces for unknown-app install status, notification status, - permission/about surfaces for unknown-app install status, notification status,
app settings, app version/build type, package name, selected update source, and app settings, app version/build type, package name, selected update source, and
HTTP/WebSocket API/protocol summary HTTP/WebSocket API/protocol summary
@@ -98,11 +103,11 @@ 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 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. 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. The app provides explicit update checks for Auto, Gitea, GitHub, and Selected.
GitHub is the default public source. Gitea is available as a public mirror, but Auto checks Gitea first because phones may not reach GitHub reliably, then tries
the app does not fall back across both providers during a normal check. This GitHub only if Gitea cannot be reached. The manual Gitea, GitHub, and Selected
keeps update behavior predictable on mobile networks and avoids duplicate buttons force one source. Transient network failures are retried against the
provider checks. current source before a source is considered failed.
Downloaded APKs are cached by `versionCode`. If a cached APK exists and its 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 SHA-256 matches the manifest, the app reuses it instead of downloading the same
@@ -116,11 +121,19 @@ 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 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. is a useful feature or test batch for phone-side validation.
Release APKs must be identical across the public GitHub and Gitea download
channels. Treat the GitHub Release APK as the canonical build artifact, then
mirror that same APK byte-for-byte to the matching Gitea Release. The Gitea
`latest.json` should point to the Gitea APK URL, but it must keep the same
`versionCode`, `versionName`, and `sha256` as the GitHub manifest. A Gitea-built
APK is only a compile check unless its SHA-256 exactly matches the GitHub
release APK.
## 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`