Compare commits

...
4 Commits
Author SHA1 Message Date
Codex 5122683761 Compact terminal composer controls
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 02:43:11 +00:00
Codex 924835dd55 Fix terminal enter and ungrouped sessions
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 11m55s
2026-07-09 16:36:52 +00:00
Codex 9fd7b02522 Ignore local Android tool cache
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m55s
2026-07-09 10:02:29 +00:00
Codex 8e871a3ce6 Add preview APK update channel 2026-07-09 10:01:14 +00:00
8 changed files with 426 additions and 29 deletions
+1 -1
View File
@@ -1,4 +1,5 @@
.gradle/
.ci/
build/
app/build/
local.properties
@@ -6,4 +7,3 @@ signing.properties
*.jks
*.keystore
release/
+3
View File
@@ -14,6 +14,8 @@ val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultPreviewUpdateUrl = providers.gradleProperty("defaultPreviewUpdateUrl")
.orElse("https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/latest.json")
val defaultApkUrl = providers.gradleProperty("defaultApkUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/tmux-android.apk")
val defaultReleasePageUrl = providers.gradleProperty("defaultReleasePageUrl")
@@ -40,6 +42,7 @@ android {
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_PREVIEW_UPDATE_URL", "\"${defaultPreviewUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
}
@@ -19,6 +19,7 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import android.text.InputType;
import android.view.Gravity;
import android.view.HapticFeedbackConstants;
@@ -45,7 +46,9 @@ import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -59,7 +62,7 @@ public final class MainActivity extends Activity {
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 TERMINAL_KEYS_HEIGHT_DP = 76;
private static final int TERMINAL_KEYS_HEIGHT_DP = 64;
private static final int STATUS_NORMAL = 0;
private static final int STATUS_BUSY = 1;
private static final int STATUS_SUCCESS = 2;
@@ -91,7 +94,7 @@ public final class MainActivity extends Activity {
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 TERMINAL_ENTER = "\n";
private static final String TERMINAL_ENTER = "\r";
private static final String[] SERVER_PROFILES = {
"http://100.89.0.116:3000",
"http://100.89.0.2:3000",
@@ -113,12 +116,16 @@ public final class MainActivity extends Activity {
private TextView sessionSummaryText;
private LinearLayout projectList;
private TextView terminalText;
private TextView terminalMetaText;
private ScrollView terminalScroll;
private EditText inputField;
private Button terminalKeyPageButton;
private View terminalComposerBar;
private LinearLayout terminalGroupRow;
private String activeSessionName;
private String terminalPathStatus = "";
private String terminalSocketStatus = "";
private String terminalEventStatus = "";
private String activeMainPage = PAGE_SERVERS;
private String pendingImageUploadSession;
private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
@@ -505,6 +512,7 @@ public final class MainActivity extends Activity {
actionButton("Auto check", view -> updateManager.check(true)),
actionButton("Gitea", view -> updateManager.checkGitea(true)),
actionButton("GitHub", view -> updateManager.checkGithub(true)),
actionButton("Preview", view -> updateManager.checkPreview(true)),
actionButton("Selected", view -> updateManager.checkSelected(true)),
actionButton("Source", view -> showUpdateSourcePicker()),
actionButton("APK", view -> updateManager.openApkDownload())
@@ -1116,6 +1124,9 @@ public final class MainActivity extends Activity {
terminalSelectionEnabled = false;
terminalFollowOutput = true;
terminalKeyPage = 0;
terminalPathStatus = "path loading";
terminalSocketStatus = "terminal idle";
terminalEventStatus = "events listening";
lastTerminalRenderMs = 0L;
terminalCols = DEFAULT_TERMINAL_COLS;
terminalRows = DEFAULT_TERMINAL_ROWS;
@@ -1166,6 +1177,7 @@ public final class MainActivity extends Activity {
resizeTerminalToViewport(false);
connectTerminal(sessionName);
refreshTerminalGroupSessions(sessionName);
refreshTerminalSessionMeta(sessionName);
});
inputField.post(() -> {
inputField.requestFocus();
@@ -1177,17 +1189,41 @@ public final class MainActivity extends Activity {
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPadding(dp(5), dp(5), dp(5), dp(5));
bar.setPadding(dp(5), dp(3), dp(5), dp(3));
bar.setBackgroundColor(COLOR_BAR);
bar.addView(terminalToolButton("", view -> openSessionPage()));
LinearLayout titleBlock = new LinearLayout(this);
titleBlock.setOrientation(LinearLayout.VERTICAL);
titleBlock.setGravity(Gravity.CENTER_VERTICAL);
titleBlock.setPadding(dp(8), 0, dp(8), 0);
TextView title = new TextView(this);
title.setText(sessionName);
title.setTextColor(COLOR_TEXT);
title.setTextSize(15);
title.setTextSize(14);
title.setTypeface(Typeface.DEFAULT_BOLD);
title.setSingleLine(true);
title.setPadding(dp(8), 0, dp(8), 0);
bar.addView(title, new LinearLayout.LayoutParams(0, dp(36), 1));
title.setEllipsize(TextUtils.TruncateAt.END);
title.setIncludeFontPadding(false);
terminalMetaText = new TextView(this);
terminalMetaText.setTextColor(COLOR_TEXT_DIM);
terminalMetaText.setTextSize(10);
terminalMetaText.setSingleLine(true);
terminalMetaText.setEllipsize(TextUtils.TruncateAt.MIDDLE);
terminalMetaText.setIncludeFontPadding(false);
updateTerminalMeta();
titleBlock.addView(title, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(18)
));
titleBlock.addView(terminalMetaText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(14)
));
bar.addView(titleBlock, new LinearLayout.LayoutParams(0, dp(34), 1));
bar.addView(terminalToolButton("", view -> connectTerminal(sessionName)));
bar.addView(terminalToolButton("", view -> showTerminalActions(sessionName)));
return bar;
@@ -1227,6 +1263,56 @@ public final class MainActivity extends Activity {
});
}
private void refreshTerminalSessionMeta(String sessionName) {
executor.execute(() -> {
try {
List<SessionSummary> sessions = api.getSessions();
String path = "";
String command = "";
for (SessionSummary session : sessions) {
if (sessionName.equals(session.name)) {
path = defaultValue(session.currentPath, "");
command = defaultValue(session.currentCommand, "");
break;
}
}
String meta = path.isEmpty() ? "path unavailable" : path;
if (!command.isEmpty()) {
meta = meta + " · " + command;
}
String finalMeta = meta;
runOnUiThread(() -> {
if (sessionName.equals(activeSessionName)) {
terminalPathStatus = finalMeta;
updateTerminalMeta();
}
});
} catch (Exception error) {
runOnUiThread(() -> {
if (sessionName.equals(activeSessionName)) {
terminalPathStatus = "path unavailable";
updateTerminalMeta();
}
});
}
});
}
private void updateTerminalMeta() {
if (terminalMetaText == null) {
return;
}
StringBuilder meta = new StringBuilder();
meta.append(defaultValue(terminalPathStatus, "path loading"));
if (!terminalSocketStatus.isEmpty()) {
meta.append(" · ").append(terminalSocketStatus);
}
if (!terminalEventStatus.isEmpty()) {
meta.append(" · ").append(terminalEventStatus);
}
terminalMetaText.setText(meta.toString());
}
private void renderTerminalGroupSessions(String sessionName, String text) {
if (!sessionName.equals(activeSessionName) || terminalGroupRow == null) {
return;
@@ -1236,9 +1322,10 @@ public final class MainActivity extends Activity {
JSONObject rootObject = new JSONObject(text == null || text.isEmpty() ? "{}" : text);
JSONArray projects = rootObject.optJSONArray("projects");
if (projects == null) {
terminalGroupRow.addView(groupLabel("no group"));
renderUngroupedTerminalSessions(sessionName, null);
return;
}
Set<String> groupedSessions = collectGroupedSessions(projects);
for (int projectIndex = 0; projectIndex < projects.length(); projectIndex++) {
JSONObject project = projects.optJSONObject(projectIndex);
if (project == null) {
@@ -1258,12 +1345,62 @@ public final class MainActivity extends Activity {
}
return;
}
terminalGroupRow.addView(groupLabel("no group"));
renderUngroupedTerminalSessions(sessionName, groupedSessions);
} catch (Exception error) {
terminalGroupRow.addView(groupLabel("group parse"));
}
}
private Set<String> collectGroupedSessions(JSONArray projects) {
Set<String> names = new HashSet<>();
for (int projectIndex = 0; projectIndex < projects.length(); projectIndex++) {
JSONObject project = projects.optJSONObject(projectIndex);
JSONArray agents = project == null ? null : project.optJSONArray("agents");
for (int agentIndex = 0; agents != null && agentIndex < agents.length(); agentIndex++) {
String sessionName = agentSessionName(agents.optJSONObject(agentIndex));
if (!sessionName.isEmpty()) {
names.add(sessionName);
}
}
}
return names;
}
private void renderUngroupedTerminalSessions(String sessionName, Set<String> groupedSessions) {
terminalGroupRow.addView(groupLabel("ungrouped..."));
executor.execute(() -> {
try {
List<SessionSummary> sessions = api.getSessions();
runOnUiThread(() -> {
if (!sessionName.equals(activeSessionName) || terminalGroupRow == null) {
return;
}
terminalGroupRow.removeAllViews();
terminalGroupRow.addView(groupLabel("ungrouped"));
int count = 0;
for (SessionSummary session : sessions) {
if (groupedSessions != null && groupedSessions.contains(session.name)) {
continue;
}
terminalGroupRow.addView(groupSessionButton(session.name, session.name.equals(sessionName)));
count++;
}
if (count == 0) {
terminalGroupRow.removeAllViews();
terminalGroupRow.addView(groupLabel("no group"));
}
});
} catch (Exception error) {
runOnUiThread(() -> {
if (sessionName.equals(activeSessionName) && terminalGroupRow != null) {
terminalGroupRow.removeAllViews();
terminalGroupRow.addView(groupLabel("ungrouped failed"));
}
});
}
});
}
private boolean projectContainsSession(JSONArray agents, String sessionName) {
if (agents == null) {
return false;
@@ -1459,10 +1596,10 @@ public final class MainActivity extends Activity {
terminalKeyPageButton = terminalPageButton();
LinearLayout.LayoutParams pageParams = new LinearLayout.LayoutParams(
dp(70),
dp(48)
dp(62),
dp(42)
);
pageParams.rightMargin = dp(6);
pageParams.rightMargin = dp(4);
row.addView(terminalKeyPageButton, pageParams);
inputField = new EditText(this);
@@ -1471,7 +1608,7 @@ public final class MainActivity extends Activity {
inputField.setHint("type, edit, paste");
inputField.setSingleLine(false);
inputField.setMinLines(1);
inputField.setMaxLines(3);
inputField.setMaxLines(2);
inputField.setCursorVisible(true);
inputField.setFocusableInTouchMode(true);
inputField.setGravity(Gravity.TOP | Gravity.START);
@@ -1499,7 +1636,7 @@ public final class MainActivity extends Activity {
row.addView(inputField, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
row.addView(toolbarButton("Send", view -> sendLine()), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(48)
dp(42)
));
bar.addView(row, matchWrap());
return bar;
@@ -1749,7 +1886,7 @@ 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('\n');
text.append("In-app update:\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("Tap Auto check on the Update page to try Gitea first and GitHub only if Gitea cannot be reached. Use Gitea, GitHub, Preview, or Selected to force one source. Preview resolves the mutable Gitea preview attachment for fast UI testing. 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: ")
.append(updateSourceHost())
.append('\n')
@@ -1758,6 +1895,7 @@ public final class MainActivity extends Activity {
text.append("Available sources:\n");
text.append("Gitea default: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append("GitHub optional: ").append(BuildConfig.DEFAULT_GITHUB_UPDATE_URL).append('\n');
text.append("Preview manual: ").append(BuildConfig.DEFAULT_PREVIEW_UPDATE_URL).append('\n');
text.append('\n');
text.append(permissionSummary());
@@ -1796,6 +1934,7 @@ public final class MainActivity extends Activity {
String[] items = {
"Gitea default: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
"GitHub optional: " + BuildConfig.DEFAULT_GITHUB_UPDATE_URL,
"Preview manual: " + BuildConfig.DEFAULT_PREVIEW_UPDATE_URL,
"Custom URL"
};
new AlertDialog.Builder(this)
@@ -1805,6 +1944,8 @@ public final class MainActivity extends Activity {
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
} else if (which == 1) {
setUpdateUrl(BuildConfig.DEFAULT_GITHUB_UPDATE_URL);
} else if (which == 2) {
setUpdateUrl(BuildConfig.DEFAULT_PREVIEW_UPDATE_URL);
} else {
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
}
@@ -2138,7 +2279,8 @@ public final class MainActivity extends Activity {
addComposerButton(topRow, "", () -> moveComposerCursor(1));
addSoftKey(topRow, "", "\u001b[A");
addSoftKey(topRow, "", "\u001b[B");
addSoftKey(topRow, "", TERMINAL_ENTER);
addSoftKey(topRow, "Enter", TERMINAL_ENTER);
addSoftKey(topRow, "Tab", "\t");
addAccessoryButton(topRow, "NL", view -> insertComposerText("\n"));
addSoftButton(bottomRow, "Pst", view -> pasteClipboard());
addAccessoryButton(bottomRow, "", view -> backspaceComposerText());
@@ -2168,6 +2310,8 @@ public final class MainActivity extends Activity {
closeTerminalSocket();
queuedTerminalInput.setLength(0);
terminalConnected = false;
terminalSocketStatus = "terminal connecting";
updateTerminalMeta();
setStatus("Connecting " + sessionName);
resizeTerminalToViewport(false);
appendTerminal("[connecting]\r\n");
@@ -2176,6 +2320,8 @@ public final class MainActivity extends Activity {
public void onConnected() {
runOnUiThread(() -> {
terminalConnected = true;
terminalSocketStatus = "terminal connected";
updateTerminalMeta();
setStatus("Connected " + sessionName);
resizeTerminalToViewport(true);
flushQueuedTerminalInput();
@@ -2191,6 +2337,8 @@ public final class MainActivity extends Activity {
public void onError(String message) {
runOnUiThread(() -> {
appendTerminal("\r\n[error] " + message + "\r\n");
terminalSocketStatus = "terminal error";
updateTerminalMeta();
setStatus("Terminal error: " + message);
});
}
@@ -2199,6 +2347,8 @@ public final class MainActivity extends Activity {
public void onClosed() {
runOnUiThread(() -> {
terminalConnected = false;
terminalSocketStatus = "terminal disconnected";
updateTerminalMeta();
setStatus("Disconnected " + sessionName);
});
}
@@ -2557,9 +2707,9 @@ public final class MainActivity extends Activity {
private Button terminalPageButton() {
Button button = toolbarButton("", view -> setTerminalKeyPage(terminalKeyPage + 1));
button.setTextSize(10);
button.setPadding(dp(5), 0, dp(5), 0);
button.setMinWidth(dp(70));
button.setMinimumWidth(dp(70));
button.setPadding(dp(4), 0, dp(4), 0);
button.setMinWidth(dp(62));
button.setMinimumWidth(dp(62));
button.setOnLongClickListener(view -> {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
setTerminalKeyPage(terminalKeyPage - 1);
@@ -2575,7 +2725,7 @@ public final class MainActivity extends Activity {
private void updateTerminalPageButton(Button button) {
if (button != null) {
button.setText(" " + accessoryPageName() + " ");
button.setText(accessoryPageName());
}
}
@@ -2592,7 +2742,7 @@ public final class MainActivity extends Activity {
input.setTextColor(COLOR_TEXT);
input.setHintTextColor(COLOR_TEXT_DIM);
input.setTextSize(14);
input.setPadding(dp(10), dp(8), dp(10), dp(8));
input.setPadding(dp(9), dp(6), dp(9), dp(6));
input.setBackground(inputBackground());
}
@@ -2654,18 +2804,18 @@ public final class MainActivity extends Activity {
private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener);
button.setTextSize(isArrowLabel(label) ? 17 : 10);
button.setPadding(dp(5), 0, dp(5), 0);
int width = "Space".equals(label) ? 64 : (isArrowLabel(label) ? 38 : 44);
button.setTextSize(isArrowLabel(label) ? 15 : 9);
button.setPadding(dp(4), 0, dp(4), 0);
int width = "Space".equals(label) ? 56 : (isArrowLabel(label) ? 32 : 38);
button.setMinWidth(dp(width));
button.setMinimumWidth(dp(width));
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
dp(27)
);
params.leftMargin = dp(2);
params.rightMargin = dp(2);
params.topMargin = dp(1);
params.leftMargin = dp(1);
params.rightMargin = dp(1);
params.topMargin = dp(2);
params.bottomMargin = dp(1);
row.addView(button, params);
}
@@ -2820,7 +2970,11 @@ public final class MainActivity extends Activity {
@Override
public void onClosed() {
runOnUiThread(() -> setStatus("Event stream disconnected"));
runOnUiThread(() -> {
terminalEventStatus = "events disconnected";
updateTerminalMeta();
setStatus("Event stream disconnected");
});
}
});
eventSocket.connect(api.getBaseUrl());
@@ -2831,10 +2985,17 @@ public final class MainActivity extends Activity {
JSONObject event = new JSONObject(text);
String type = event.optString("type", "");
if ("hello".equals(type)) {
terminalEventStatus = "events connected";
updateTerminalMeta();
setStatus("Event stream connected");
return;
}
if ("sessions-invalidated".equals(type)) {
terminalEventStatus = "sessions changed";
updateTerminalMeta();
if (activeSessionName != null) {
refreshTerminalSessionMeta(activeSessionName);
}
setStatus("Sessions changed: " + event.optString("reason", "update"));
if (activeSessionName == null) {
refreshSessions();
@@ -2842,11 +3003,17 @@ public final class MainActivity extends Activity {
return;
}
if ("hook-event".equals(type)) {
terminalEventStatus = "hook event";
updateTerminalMeta();
showMessage(event.optString("title", "Hook event"));
return;
}
terminalEventStatus = type.isEmpty() ? "event received" : type;
updateTerminalMeta();
setStatus(type.isEmpty() ? "Event received" : type);
} catch (Exception error) {
terminalEventStatus = "event received";
updateTerminalMeta();
setStatus("Event received");
}
}
@@ -80,6 +80,14 @@ final class UpdateManager {
);
}
void checkPreview(boolean userInitiated) {
startUpdateCheck(
userInitiated,
"Preview",
new String[]{BuildConfig.DEFAULT_PREVIEW_UPDATE_URL}
);
}
void checkWithFallback(boolean userInitiated) {
startUpdateCheck(
userInitiated,
+41
View File
@@ -0,0 +1,41 @@
# Preview APK workflow
Use this path for fast UI testing before a formal tag/release.
The preview build is a debug APK with package id `com.neatstudio.tmuxandroid.debug`.
It can be installed next to the formal release app, so preview version codes do not
block future formal releases.
## Build locally
```bash
scripts/setup-android-local.sh
scripts/build-preview-apk.sh
```
Output:
- `release/preview/tmux-android-preview.apk`
- `release/preview/latest.json`
## Upload to Gitea preview release
```bash
scripts/upload-gitea-preview.sh
```
The script reads `TMUX_GITEA_TOKEN` or prompts for a hidden token.
Before uploading, it deletes existing assets with the same names, so the preview
release keeps only one current APK and one current manifest.
Fixed preview URLs:
- Manifest: `https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/latest.json`
- APK: `https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/tmux-android-preview.apk`
## Notes
- Preview APKs are not formal releases and should not be tagged as `v*`.
- Preview uses debug signing unless a separate debug signing setup is added.
- The installed preview app is separate from the release app because Gradle applies
`applicationIdSuffix = ".debug"` for debug builds.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="${ROOT_DIR}/.ci"
GRADLE_VERSION="${GRADLE_VERSION:-8.10.2}"
GRADLE_HOME="${CI_DIR}/gradle-${GRADLE_VERSION}"
ANDROID_HOME="${ANDROID_HOME:-${CI_DIR}/android-sdk}"
CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
BUILD_NUMBER="${BUILD_NUMBER:-$(date -u +%m%d%H%M)}"
VERSION_CODE="${VERSION_CODE:-$((900000000 + 10#${BUILD_NUMBER}))}"
VERSION_NAME="${VERSION_NAME:-preview-${BUILD_NUMBER}}"
if [ ! -x "${GRADLE_HOME}/bin/gradle" ] || [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
"${ROOT_DIR}/scripts/setup-android-local.sh"
fi
export ANDROID_HOME
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export PATH="${GRADLE_HOME}/bin:${CMDLINE_TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}"
cd "${ROOT_DIR}"
gradle :app:assembleDebug \
-PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \
-PrepoSlug="neatstudio/tmux-browser-android"
OUT_DIR="${ROOT_DIR}/release/preview"
mkdir -p "${OUT_DIR}"
APK_PATH="$(find app/build/outputs/apk/debug -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" "${OUT_DIR}/tmux-android-preview.apk"
SHA256="$(sha256sum "${OUT_DIR}/tmux-android-preview.apk" | cut -d " " -f 1)"
cat > "${OUT_DIR}/latest.json" <<JSON
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"apkUrl": "https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/tmux-android-preview.apk",
"sha256": "${SHA256}",
"releasePageUrl": "https://gitea.neatcn.com/tmux/tmux-browser-android/releases/tag/preview",
"minSdk": 26
}
JSON
ls -lh "${OUT_DIR}/tmux-android-preview.apk" "${OUT_DIR}/latest.json"
sha256sum "${OUT_DIR}/tmux-android-preview.apk"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="${ROOT_DIR}/.ci"
GRADLE_VERSION="${GRADLE_VERSION:-8.10.2}"
ANDROID_TOOLS_ZIP="${ANDROID_TOOLS_ZIP:-commandlinetools-linux-11076708_latest.zip}"
GRADLE_HOME="${CI_DIR}/gradle-${GRADLE_VERSION}"
ANDROID_HOME="${ANDROID_HOME:-${CI_DIR}/android-sdk}"
CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
mkdir -p "${CI_DIR}"
if [ ! -x "${GRADLE_HOME}/bin/gradle" ]; then
curl -fSL --connect-timeout 20 --retry 3 --retry-delay 2 --max-time 600 \
-o "${CI_DIR}/gradle.zip" \
"https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip"
unzip -q "${CI_DIR}/gradle.zip" -d "${CI_DIR}"
fi
if [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
mkdir -p "${ANDROID_HOME}/cmdline-tools"
curl -fSL --connect-timeout 20 --retry 3 --retry-delay 2 --max-time 600 \
-o "${CI_DIR}/android-tools.zip" \
"https://dl.google.com/android/repository/${ANDROID_TOOLS_ZIP}"
unzip -q "${CI_DIR}/android-tools.zip" -d "${ANDROID_HOME}/cmdline-tools"
rm -rf "${CMDLINE_TOOLS}"
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${CMDLINE_TOOLS}"
fi
export ANDROID_HOME
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export PATH="${GRADLE_HOME}/bin:${CMDLINE_TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
echo "ANDROID_HOME=${ANDROID_HOME}"
echo "GRADLE_HOME=${GRADLE_HOME}"
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APK_PATH="${1:-${ROOT_DIR}/release/preview/tmux-android-preview.apk}"
LATEST_JSON="${2:-${ROOT_DIR}/release/preview/latest.json}"
TAG="preview"
API_ROOT="https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android"
if [ ! -f "${APK_PATH}" ]; then
echo "APK not found: ${APK_PATH}" >&2
exit 1
fi
if [ ! -f "${LATEST_JSON}" ]; then
echo "latest.json not found: ${LATEST_JSON}" >&2
exit 1
fi
TOKEN="${TMUX_GITEA_TOKEN:-}"
if [ -z "${TOKEN}" ]; then
printf "Gitea token: " >&2
IFS= read -rs TOKEN
printf "\n" >&2
fi
if [ -z "${TOKEN}" ]; then
echo "Missing Gitea token." >&2
exit 1
fi
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "${WORK_DIR}"' EXIT
RELEASE_JSON="${WORK_DIR}/release.json"
BODY='{"tag_name":"preview","target_commitish":"main","name":"tmux Android Preview","body":"Mutable preview APK for fast UI testing. This is not a formal release.","draft":false,"prerelease":true}'
if ! curl -fsSL \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "${BODY}" \
"${API_ROOT}/releases" \
-o "${RELEASE_JSON}"; then
curl -fsSL \
-H "Authorization: token ${TOKEN}" \
"${API_ROOT}/releases/tags/${TAG}" \
-o "${RELEASE_JSON}"
fi
RELEASE_ID="$(sed -n 's/^{"id":\([0-9][0-9]*\),.*/\1/p' "${RELEASE_JSON}" | head -1)"
if [ -z "${RELEASE_ID}" ]; then
echo "Cannot resolve Gitea release id for preview." >&2
exit 1
fi
upload_asset() {
local file="$1"
local name="$2"
local type="$3"
local assets_json old_ids old_id
assets_json="${WORK_DIR}/assets-${name}.json"
curl -fsSL -H "Authorization: token ${TOKEN}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets" \
-o "${assets_json}"
old_ids="$(python3 - "${assets_json}" "${name}" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as handle:
assets = json.load(handle)
for asset in assets:
if asset.get("name") == sys.argv[2]:
print(asset.get("id"))
PY
)"
for old_id in ${old_ids}; do
curl -fsSL -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets/${old_id}" \
-o /dev/null
done
curl -fsSL \
-X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${file};type=${type}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets?name=${name}" \
-o "${WORK_DIR}/${name}.asset.json"
}
upload_asset "${APK_PATH}" "tmux-android-preview.apk" "application/vnd.android.package-archive"
upload_asset "${LATEST_JSON}" "latest.json" "application/json"
echo "Uploaded preview release ${RELEASE_ID}"
echo "Manifest: https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/latest.json"
echo "APK: https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/preview/tmux-android-preview.apk"