Compare commits

..
2 Commits
Author SHA1 Message Date
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
4 changed files with 124 additions and 10 deletions
+7 -5
View File
@@ -1,4 +1,4 @@
name: Gitea Android APK
name: Gitea Android Compile Check
on: [push]
@@ -6,7 +6,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Build APK
- name: Compile Android APK
env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: |
@@ -62,6 +62,8 @@ jobs:
mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" release/tmux-android-gitea.apk
ls -lh release/tmux-android-gitea.apk
sha256sum release/tmux-android-gitea.apk
cp "${APK_PATH}" release/tmux-android-gitea-compile-check.apk
ls -lh release/tmux-android-gitea-compile-check.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."
+9
View File
@@ -100,6 +100,15 @@ to reach GitHub reliably. GitHub remains an optional public source. This Gitea
instance does not support the GitHub-style `/releases/latest/download/...` URL,
so the app uses the Gitea Release API as the stable Gitea update entrypoint.
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
verification, but releases are the stable download/update channel.
@@ -26,6 +26,7 @@ import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.Gravity;
import android.view.HapticFeedbackConstants;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
@@ -58,6 +59,10 @@ public final class MainActivity extends Activity {
private static final int TERMINAL_COLS = 96;
private static final int TERMINAL_ROWS = 32;
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_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
@@ -156,7 +161,6 @@ public final class MainActivity extends Activity {
statusText.setTextSize(12);
statusText.setGravity(Gravity.CENTER_VERTICAL);
statusText.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true);
setStatus("Ready");
applySystemBarInsets(root);
@@ -1811,6 +1815,7 @@ public final class MainActivity extends Activity {
TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) {
socket.sendInput(data);
setStatus("Sent input");
return;
}
if (socket != null && !socket.isClosed()) {
@@ -1823,6 +1828,7 @@ public final class MainActivity extends Activity {
for (int i = 0; i < data.length(); i += 200) {
api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length())));
}
runOnUiThread(() -> setStatus("Sent input"));
} catch (Exception error) {
runOnUiThread(() -> showMessage("Input failed: " + error.getMessage()));
}
@@ -1841,15 +1847,19 @@ public final class MainActivity extends Activity {
private void pasteClipboard() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null || !clipboard.hasPrimaryClip()) {
showMessage("Clipboard is empty");
return;
}
ClipData clip = clipboard.getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) {
showMessage("Clipboard is empty");
return;
}
CharSequence text = clip.getItemAt(0).coerceToText(this);
if (text != null && text.length() > 0) {
sendTerminalInput(text.toString());
} else {
showMessage("Clipboard is empty");
}
}
@@ -2081,7 +2091,20 @@ public final class MainActivity extends Activity {
button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground());
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(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(42)
@@ -2102,6 +2125,7 @@ public final class MainActivity extends Activity {
private StateListDrawable buttonBackground() {
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_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));
@@ -2156,14 +2180,85 @@ public final class MainActivity extends Activity {
}
private void showMessage(String message) {
if (message == null || message.trim().isEmpty()) {
return;
}
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
setStatus(message);
}
private void setStatus(String message) {
if (statusText != null) {
statusText.setText(message);
setStatus(message, inferStatusTone(message));
}
private void setStatus(String message, int tone) {
if (statusText != null) {
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 dp(int value) {
+10 -2
View File
@@ -87,8 +87,8 @@ Implemented now:
preview display
- GitHub Actions APK build
- release manifest `latest.json`
- selected-source update checks; Gitea and GitHub are not probed in the same
update check
- Auto/Gitea/GitHub/Selected update checks, with Auto falling back from Gitea
to GitHub
- one-download-per-version APK cache, SHA-256 verification, and installer
handoff
- permission/about surfaces for unknown-app install status, notification status,
@@ -119,6 +119,14 @@ 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.
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
To converge with the upstream mobile design, the next implementation should add