Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba3930417 | ||
|
|
4476f41aa6 | ||
|
|
5122683761 | ||
|
|
924835dd55 | ||
|
|
9fd7b02522 | ||
|
|
8e871a3ce6 | ||
|
|
df29546ff9 |
+1
-1
@@ -1,4 +1,5 @@
|
||||
.gradle/
|
||||
.ci/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
@@ -6,4 +7,3 @@ signing.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
release/
|
||||
|
||||
|
||||
@@ -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()}\"")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Base64;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -14,16 +15,22 @@ import java.util.Arrays;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
final class AppEventSocketClient {
|
||||
private static final long HEARTBEAT_INTERVAL_MS = 15000L;
|
||||
private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000;
|
||||
private static final int SOCKET_READ_TIMEOUT_MS = 45000;
|
||||
|
||||
interface Listener {
|
||||
void onMessage(String text);
|
||||
void onClosed();
|
||||
}
|
||||
|
||||
private final Listener listener;
|
||||
private final Object writeLock = new Object();
|
||||
private Socket socket;
|
||||
private BufferedInputStream input;
|
||||
private BufferedOutputStream output;
|
||||
private volatile boolean closed;
|
||||
private Thread heartbeatThread;
|
||||
|
||||
AppEventSocketClient(Listener listener) {
|
||||
this.listener = listener;
|
||||
@@ -40,12 +47,11 @@ final class AppEventSocketClient {
|
||||
sendFrame(8, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
closeSocketQuietly();
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return closed;
|
||||
}
|
||||
|
||||
private void run(String baseUrl) {
|
||||
@@ -55,6 +61,7 @@ final class AppEventSocketClient {
|
||||
input = new BufferedInputStream(socket.getInputStream());
|
||||
output = new BufferedOutputStream(socket.getOutputStream());
|
||||
handshake(uri);
|
||||
startHeartbeat();
|
||||
while (!closed) {
|
||||
Frame frame = readFrame();
|
||||
if (frame.opcode == 1) {
|
||||
@@ -69,12 +76,7 @@ final class AppEventSocketClient {
|
||||
} finally {
|
||||
closed = true;
|
||||
listener.onClosed();
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
closeSocketQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +93,39 @@ final class AppEventSocketClient {
|
||||
if (port == -1) {
|
||||
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
Socket raw = new Socket();
|
||||
raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS);
|
||||
Socket connected;
|
||||
if ("wss".equalsIgnoreCase(uri.getScheme())) {
|
||||
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
|
||||
connected = ((SSLSocketFactory) SSLSocketFactory.getDefault())
|
||||
.createSocket(raw, uri.getHost(), port, true);
|
||||
} else {
|
||||
connected = raw;
|
||||
}
|
||||
return new Socket(uri.getHost(), port);
|
||||
connected.setKeepAlive(true);
|
||||
connected.setTcpNoDelay(true);
|
||||
connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS);
|
||||
return connected;
|
||||
}
|
||||
|
||||
private void startHeartbeat() {
|
||||
heartbeatThread = new Thread(() -> {
|
||||
while (!closed) {
|
||||
try {
|
||||
Thread.sleep(HEARTBEAT_INTERVAL_MS);
|
||||
if (!closed) {
|
||||
sendFrame(9, new byte[0]);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Exception error) {
|
||||
closeSocketQuietly();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, "app-events-ws-heartbeat");
|
||||
heartbeatThread.start();
|
||||
}
|
||||
|
||||
private void handshake(URI uri) throws Exception {
|
||||
@@ -181,32 +212,47 @@ final class AppEventSocketClient {
|
||||
}
|
||||
|
||||
private void sendFrame(int opcode, byte[] payload) throws Exception {
|
||||
if (output == null) {
|
||||
return;
|
||||
}
|
||||
output.write(0x80 | opcode);
|
||||
byte[] mask = new byte[4];
|
||||
new SecureRandom().nextBytes(mask);
|
||||
int length = payload.length;
|
||||
if (length < 126) {
|
||||
output.write(0x80 | length);
|
||||
} else if (length <= 0xffff) {
|
||||
output.write(0x80 | 126);
|
||||
output.write((length >>> 8) & 0xff);
|
||||
output.write(length & 0xff);
|
||||
} else {
|
||||
output.write(0x80 | 127);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
output.write((length >>> (8 * i)) & 0xff);
|
||||
synchronized (writeLock) {
|
||||
if (output == null) {
|
||||
return;
|
||||
}
|
||||
output.write(0x80 | opcode);
|
||||
byte[] mask = new byte[4];
|
||||
new SecureRandom().nextBytes(mask);
|
||||
int length = payload.length;
|
||||
if (length < 126) {
|
||||
output.write(0x80 | length);
|
||||
} else if (length <= 0xffff) {
|
||||
output.write(0x80 | 126);
|
||||
output.write((length >>> 8) & 0xff);
|
||||
output.write(length & 0xff);
|
||||
} else {
|
||||
output.write(0x80 | 127);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
output.write((length >>> (8 * i)) & 0xff);
|
||||
}
|
||||
}
|
||||
output.write(mask);
|
||||
byte[] masked = Arrays.copyOf(payload, payload.length);
|
||||
for (int i = 0; i < masked.length; i++) {
|
||||
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
|
||||
}
|
||||
output.write(masked);
|
||||
output.flush();
|
||||
}
|
||||
output.write(mask);
|
||||
byte[] masked = Arrays.copyOf(payload, payload.length);
|
||||
for (int i = 0; i < masked.length; i++) {
|
||||
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
|
||||
}
|
||||
|
||||
private void closeSocketQuietly() {
|
||||
if (heartbeatThread != null) {
|
||||
heartbeatThread.interrupt();
|
||||
heartbeatThread = null;
|
||||
}
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
output.write(masked);
|
||||
output.flush();
|
||||
}
|
||||
|
||||
private static final class Frame {
|
||||
|
||||
@@ -18,7 +18,10 @@ import android.graphics.drawable.StateListDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.text.TextUtils;
|
||||
import android.text.InputType;
|
||||
import android.view.Gravity;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
@@ -45,7 +48,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,12 +64,13 @@ 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;
|
||||
private static final int STATUS_ERROR = 3;
|
||||
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
|
||||
private static final long[] SOCKET_RECONNECT_DELAYS_MS = {1000L, 2000L, 4000L, 8000L, 15000L};
|
||||
private static final int COLOR_APP_BG = Color.rgb(9, 11, 13);
|
||||
private static final int COLOR_BAR = Color.rgb(15, 18, 21);
|
||||
private static final int COLOR_PANEL = Color.rgb(22, 26, 30);
|
||||
@@ -91,7 +97,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",
|
||||
@@ -101,6 +107,7 @@ public final class MainActivity extends Activity {
|
||||
};
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private SharedPreferences prefs;
|
||||
private UpdateManager updateManager;
|
||||
private SessionApiClient api;
|
||||
@@ -113,20 +120,33 @@ 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);
|
||||
private final StringBuilder queuedTerminalInput = new StringBuilder();
|
||||
private boolean terminalConnected;
|
||||
private boolean terminalConnecting;
|
||||
private boolean terminalRenderPending;
|
||||
private boolean terminalSelectionEnabled;
|
||||
private boolean terminalFollowOutput = true;
|
||||
private int terminalKeyPage;
|
||||
private int terminalReconnectAttempt;
|
||||
private int terminalConnectionGeneration;
|
||||
private int eventReconnectAttempt;
|
||||
private int eventConnectionGeneration;
|
||||
private boolean activityDestroyed;
|
||||
private Runnable terminalReconnectTask;
|
||||
private Runnable eventReconnectTask;
|
||||
private long lastTerminalRenderMs;
|
||||
private int terminalCols = DEFAULT_TERMINAL_COLS;
|
||||
private int terminalRows = DEFAULT_TERMINAL_ROWS;
|
||||
@@ -504,6 +524,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())
|
||||
@@ -1115,6 +1136,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;
|
||||
@@ -1165,6 +1189,7 @@ public final class MainActivity extends Activity {
|
||||
resizeTerminalToViewport(false);
|
||||
connectTerminal(sessionName);
|
||||
refreshTerminalGroupSessions(sessionName);
|
||||
refreshTerminalSessionMeta(sessionName);
|
||||
});
|
||||
inputField.post(() -> {
|
||||
inputField.requestFocus();
|
||||
@@ -1176,17 +1201,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;
|
||||
@@ -1226,6 +1275,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;
|
||||
@@ -1235,9 +1334,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) {
|
||||
@@ -1257,12 +1357,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;
|
||||
@@ -1456,13 +1606,21 @@ public final class MainActivity extends Activity {
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.BOTTOM);
|
||||
|
||||
terminalKeyPageButton = terminalPageButton();
|
||||
LinearLayout.LayoutParams pageParams = new LinearLayout.LayoutParams(
|
||||
dp(62),
|
||||
dp(42)
|
||||
);
|
||||
pageParams.rightMargin = dp(4);
|
||||
row.addView(terminalKeyPageButton, pageParams);
|
||||
|
||||
inputField = new EditText(this);
|
||||
inputField.setTextColor(COLOR_TEXT);
|
||||
inputField.setHintTextColor(COLOR_TEXT_DIM);
|
||||
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);
|
||||
@@ -1490,7 +1648,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;
|
||||
@@ -1740,7 +1898,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')
|
||||
@@ -1749,6 +1907,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());
|
||||
|
||||
@@ -1787,6 +1946,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)
|
||||
@@ -1796,6 +1956,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);
|
||||
}
|
||||
@@ -2056,10 +2218,7 @@ public final class MainActivity extends Activity {
|
||||
|
||||
LinearLayout firstRow = terminalKeyRow();
|
||||
LinearLayout secondRow = terminalKeyRow();
|
||||
addAccessoryButton(firstRow, "<", view -> setTerminalKeyPage(terminalKeyPage - 1));
|
||||
addPageLabel(firstRow);
|
||||
addAccessoryPageKeys(firstRow, secondRow);
|
||||
addAccessoryButton(firstRow, ">", view -> setTerminalKeyPage(terminalKeyPage + 1));
|
||||
pad.addView(firstRow, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
0,
|
||||
@@ -2085,16 +2244,6 @@ public final class MainActivity extends Activity {
|
||||
return row;
|
||||
}
|
||||
|
||||
private void addPageLabel(LinearLayout row) {
|
||||
TextView label = new TextView(this);
|
||||
label.setText(accessoryPageName());
|
||||
label.setTextColor(COLOR_TEXT_DIM);
|
||||
label.setTextSize(11);
|
||||
label.setGravity(Gravity.CENTER);
|
||||
label.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
row.addView(label, new LinearLayout.LayoutParams(dp(44), ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
}
|
||||
|
||||
private void addAccessoryPageKeys(LinearLayout topRow, LinearLayout bottomRow) {
|
||||
switch (terminalKeyPage) {
|
||||
case 1:
|
||||
@@ -2142,7 +2291,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());
|
||||
@@ -2169,9 +2319,24 @@ public final class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private void connectTerminal(String sessionName) {
|
||||
closeTerminalSocket();
|
||||
queuedTerminalInput.setLength(0);
|
||||
terminalReconnectAttempt = 0;
|
||||
terminalConnectionGeneration++;
|
||||
cancelTerminalReconnect();
|
||||
closeTerminalSocket(false);
|
||||
connectTerminalSocket(sessionName, terminalConnectionGeneration);
|
||||
}
|
||||
|
||||
private void connectTerminalSocket(String sessionName, int generation) {
|
||||
if (activityDestroyed
|
||||
|| activeSessionName == null
|
||||
|| !sessionName.equals(activeSessionName)
|
||||
|| generation != terminalConnectionGeneration) {
|
||||
return;
|
||||
}
|
||||
terminalConnected = false;
|
||||
terminalConnecting = true;
|
||||
terminalSocketStatus = "terminal connecting";
|
||||
updateTerminalMeta();
|
||||
setStatus("Connecting " + sessionName);
|
||||
resizeTerminalToViewport(false);
|
||||
appendTerminal("[connecting]\r\n");
|
||||
@@ -2179,7 +2344,14 @@ public final class MainActivity extends Activity {
|
||||
@Override
|
||||
public void onConnected() {
|
||||
runOnUiThread(() -> {
|
||||
if (!isCurrentTerminalConnection(sessionName, generation)) {
|
||||
return;
|
||||
}
|
||||
terminalConnected = true;
|
||||
terminalConnecting = false;
|
||||
terminalReconnectAttempt = 0;
|
||||
terminalSocketStatus = "terminal connected";
|
||||
updateTerminalMeta();
|
||||
setStatus("Connected " + sessionName);
|
||||
resizeTerminalToViewport(true);
|
||||
flushQueuedTerminalInput();
|
||||
@@ -2188,13 +2360,23 @@ public final class MainActivity extends Activity {
|
||||
|
||||
@Override
|
||||
public void onOutput(String data) {
|
||||
runOnUiThread(() -> appendTerminal(data));
|
||||
runOnUiThread(() -> {
|
||||
if (isCurrentTerminalConnection(sessionName, generation)) {
|
||||
appendTerminal(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
runOnUiThread(() -> {
|
||||
if (!isCurrentTerminalConnection(sessionName, generation)) {
|
||||
return;
|
||||
}
|
||||
terminalConnecting = false;
|
||||
appendTerminal("\r\n[error] " + message + "\r\n");
|
||||
terminalSocketStatus = "terminal error";
|
||||
updateTerminalMeta();
|
||||
setStatus("Terminal error: " + message);
|
||||
});
|
||||
}
|
||||
@@ -2202,8 +2384,15 @@ public final class MainActivity extends Activity {
|
||||
@Override
|
||||
public void onClosed() {
|
||||
runOnUiThread(() -> {
|
||||
if (!isCurrentTerminalConnection(sessionName, generation)) {
|
||||
return;
|
||||
}
|
||||
terminalConnected = false;
|
||||
terminalConnecting = false;
|
||||
terminalSocketStatus = "terminal disconnected";
|
||||
updateTerminalMeta();
|
||||
setStatus("Disconnected " + sessionName);
|
||||
scheduleTerminalReconnect(sessionName, generation);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2264,7 +2453,8 @@ public final class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private void sendTerminalInput(String data) {
|
||||
if (activeSessionName == null) {
|
||||
String sessionName = activeSessionName;
|
||||
if (sessionName == null) {
|
||||
return;
|
||||
}
|
||||
TerminalSocketClient socket = terminalSocket;
|
||||
@@ -2273,7 +2463,7 @@ public final class MainActivity extends Activity {
|
||||
setStatus("Sent input");
|
||||
return;
|
||||
}
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
if ((socket != null && !socket.isClosed()) || terminalConnecting || terminalReconnectTask != null) {
|
||||
queuedTerminalInput.append(data);
|
||||
setStatus("Queued input until terminal connects");
|
||||
return;
|
||||
@@ -2281,7 +2471,7 @@ public final class MainActivity extends Activity {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
for (int i = 0; i < data.length(); i += 200) {
|
||||
api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length())));
|
||||
api.sendInput(sessionName, data.substring(i, Math.min(i + 200, data.length())));
|
||||
}
|
||||
runOnUiThread(() -> setStatus("Sent input"));
|
||||
} catch (Exception error) {
|
||||
@@ -2393,6 +2583,7 @@ public final class MainActivity extends Activity {
|
||||
|
||||
private void setTerminalKeyPage(int page) {
|
||||
terminalKeyPage = (page + 4) % 4;
|
||||
updateTerminalPageButton();
|
||||
if (activeSessionName != null) {
|
||||
renderTerminalControlsOnly();
|
||||
}
|
||||
@@ -2557,6 +2748,31 @@ public final class MainActivity extends Activity {
|
||||
return button;
|
||||
}
|
||||
|
||||
private Button terminalPageButton() {
|
||||
Button button = toolbarButton("", view -> setTerminalKeyPage(terminalKeyPage + 1));
|
||||
button.setTextSize(10);
|
||||
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);
|
||||
return true;
|
||||
});
|
||||
updateTerminalPageButton(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
private void updateTerminalPageButton() {
|
||||
updateTerminalPageButton(terminalKeyPageButton);
|
||||
}
|
||||
|
||||
private void updateTerminalPageButton(Button button) {
|
||||
if (button != null) {
|
||||
button.setText(accessoryPageName());
|
||||
}
|
||||
}
|
||||
|
||||
private void styleInput(EditText input) {
|
||||
input.setTextColor(COLOR_TEXT);
|
||||
input.setHintTextColor(COLOR_TEXT_DIM);
|
||||
@@ -2570,7 +2786,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());
|
||||
}
|
||||
|
||||
@@ -2632,18 +2848,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);
|
||||
}
|
||||
@@ -2776,9 +2992,53 @@ public final class MainActivity extends Activity {
|
||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
|
||||
private boolean isCurrentTerminalConnection(String sessionName, int generation) {
|
||||
return !activityDestroyed
|
||||
&& generation == terminalConnectionGeneration
|
||||
&& sessionName.equals(activeSessionName);
|
||||
}
|
||||
|
||||
private void scheduleTerminalReconnect(String sessionName, int generation) {
|
||||
if (!isCurrentTerminalConnection(sessionName, generation) || terminalReconnectTask != null) {
|
||||
return;
|
||||
}
|
||||
long delay = reconnectDelay(terminalReconnectAttempt++);
|
||||
terminalSocketStatus = "retry in " + Math.max(1L, delay / 1000L) + "s";
|
||||
updateTerminalMeta();
|
||||
setStatus("Terminal reconnecting in " + Math.max(1L, delay / 1000L) + "s");
|
||||
terminalReconnectTask = () -> {
|
||||
terminalReconnectTask = null;
|
||||
if (isCurrentTerminalConnection(sessionName, generation)) {
|
||||
connectTerminalSocket(sessionName, generation);
|
||||
}
|
||||
};
|
||||
mainHandler.postDelayed(terminalReconnectTask, delay);
|
||||
}
|
||||
|
||||
private void cancelTerminalReconnect() {
|
||||
if (terminalReconnectTask != null) {
|
||||
mainHandler.removeCallbacks(terminalReconnectTask);
|
||||
terminalReconnectTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private long reconnectDelay(int attempt) {
|
||||
return SOCKET_RECONNECT_DELAYS_MS[Math.min(attempt, SOCKET_RECONNECT_DELAYS_MS.length - 1)];
|
||||
}
|
||||
|
||||
private void closeTerminalSocket() {
|
||||
closeTerminalSocket(true);
|
||||
}
|
||||
|
||||
private void closeTerminalSocket(boolean invalidateConnection) {
|
||||
if (invalidateConnection) {
|
||||
terminalConnectionGeneration++;
|
||||
terminalReconnectAttempt = 0;
|
||||
cancelTerminalReconnect();
|
||||
queuedTerminalInput.setLength(0);
|
||||
}
|
||||
terminalConnected = false;
|
||||
queuedTerminalInput.setLength(0);
|
||||
terminalConnecting = false;
|
||||
terminalRenderPending = false;
|
||||
if (terminalSocket != null) {
|
||||
terminalSocket.close();
|
||||
@@ -2787,21 +3047,69 @@ public final class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private void connectAppEvents() {
|
||||
eventReconnectAttempt = 0;
|
||||
eventConnectionGeneration++;
|
||||
cancelEventReconnect();
|
||||
if (eventSocket != null) {
|
||||
eventSocket.close();
|
||||
eventSocket = null;
|
||||
}
|
||||
connectAppEventSocket(api.getBaseUrl(), eventConnectionGeneration);
|
||||
}
|
||||
|
||||
private void connectAppEventSocket(String baseUrl, int generation) {
|
||||
if (activityDestroyed || generation != eventConnectionGeneration) {
|
||||
return;
|
||||
}
|
||||
eventSocket = new AppEventSocketClient(new AppEventSocketClient.Listener() {
|
||||
@Override
|
||||
public void onMessage(String text) {
|
||||
runOnUiThread(() -> handleAppEvent(text));
|
||||
runOnUiThread(() -> {
|
||||
if (generation == eventConnectionGeneration && !activityDestroyed) {
|
||||
handleAppEvent(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed() {
|
||||
runOnUiThread(() -> setStatus("Event stream disconnected"));
|
||||
runOnUiThread(() -> {
|
||||
if (generation != eventConnectionGeneration || activityDestroyed) {
|
||||
return;
|
||||
}
|
||||
terminalEventStatus = "events disconnected";
|
||||
updateTerminalMeta();
|
||||
setStatus("Event stream disconnected");
|
||||
scheduleEventReconnect(baseUrl, generation);
|
||||
});
|
||||
}
|
||||
});
|
||||
eventSocket.connect(api.getBaseUrl());
|
||||
eventSocket.connect(baseUrl);
|
||||
}
|
||||
|
||||
private void scheduleEventReconnect(String baseUrl, int generation) {
|
||||
if (activityDestroyed
|
||||
|| generation != eventConnectionGeneration
|
||||
|| eventReconnectTask != null) {
|
||||
return;
|
||||
}
|
||||
long delay = reconnectDelay(eventReconnectAttempt++);
|
||||
terminalEventStatus = "events retry in " + Math.max(1L, delay / 1000L) + "s";
|
||||
updateTerminalMeta();
|
||||
eventReconnectTask = () -> {
|
||||
eventReconnectTask = null;
|
||||
if (!activityDestroyed && generation == eventConnectionGeneration) {
|
||||
connectAppEventSocket(baseUrl, generation);
|
||||
}
|
||||
};
|
||||
mainHandler.postDelayed(eventReconnectTask, delay);
|
||||
}
|
||||
|
||||
private void cancelEventReconnect() {
|
||||
if (eventReconnectTask != null) {
|
||||
mainHandler.removeCallbacks(eventReconnectTask);
|
||||
eventReconnectTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleAppEvent(String text) {
|
||||
@@ -2809,10 +3117,18 @@ public final class MainActivity extends Activity {
|
||||
JSONObject event = new JSONObject(text);
|
||||
String type = event.optString("type", "");
|
||||
if ("hello".equals(type)) {
|
||||
eventReconnectAttempt = 0;
|
||||
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();
|
||||
@@ -2820,11 +3136,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");
|
||||
}
|
||||
}
|
||||
@@ -2888,11 +3210,23 @@ public final class MainActivity extends Activity {
|
||||
if (updateManager != null) {
|
||||
updateManager.resumePendingInstall();
|
||||
}
|
||||
if (activeSessionName != null
|
||||
&& !terminalConnected
|
||||
&& !terminalConnecting
|
||||
&& terminalReconnectTask == null) {
|
||||
scheduleTerminalReconnect(activeSessionName, terminalConnectionGeneration);
|
||||
}
|
||||
if ((eventSocket == null || eventSocket.isClosed()) && eventReconnectTask == null) {
|
||||
connectAppEvents();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
activityDestroyed = true;
|
||||
closeTerminalSocket();
|
||||
eventConnectionGeneration++;
|
||||
cancelEventReconnect();
|
||||
if (eventSocket != null) {
|
||||
eventSocket.close();
|
||||
eventSocket = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -19,6 +20,10 @@ import java.util.concurrent.RejectedExecutionException;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
final class TerminalSocketClient {
|
||||
private static final long HEARTBEAT_INTERVAL_MS = 15000L;
|
||||
private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000;
|
||||
private static final int SOCKET_READ_TIMEOUT_MS = 45000;
|
||||
|
||||
interface Listener {
|
||||
void onConnected();
|
||||
void onOutput(String data);
|
||||
@@ -35,6 +40,7 @@ final class TerminalSocketClient {
|
||||
private BufferedOutputStream output;
|
||||
private volatile boolean closed;
|
||||
private Thread thread;
|
||||
private Thread heartbeatThread;
|
||||
|
||||
TerminalSocketClient(Listener listener) {
|
||||
this.listener = listener;
|
||||
@@ -97,6 +103,7 @@ final class TerminalSocketClient {
|
||||
"rows", rows
|
||||
);
|
||||
listener.onConnected();
|
||||
startHeartbeat();
|
||||
readLoop();
|
||||
} catch (Exception error) {
|
||||
if (!closed) {
|
||||
@@ -123,10 +130,39 @@ final class TerminalSocketClient {
|
||||
if (port == -1) {
|
||||
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
Socket raw = new Socket();
|
||||
raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS);
|
||||
Socket connected;
|
||||
if ("wss".equalsIgnoreCase(uri.getScheme())) {
|
||||
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
|
||||
connected = ((SSLSocketFactory) SSLSocketFactory.getDefault())
|
||||
.createSocket(raw, uri.getHost(), port, true);
|
||||
} else {
|
||||
connected = raw;
|
||||
}
|
||||
return new Socket(uri.getHost(), port);
|
||||
connected.setKeepAlive(true);
|
||||
connected.setTcpNoDelay(true);
|
||||
connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS);
|
||||
return connected;
|
||||
}
|
||||
|
||||
private void startHeartbeat() {
|
||||
heartbeatThread = new Thread(() -> {
|
||||
while (!closed) {
|
||||
try {
|
||||
Thread.sleep(HEARTBEAT_INTERVAL_MS);
|
||||
if (!closed) {
|
||||
sendFrame(9, new byte[0]);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Exception error) {
|
||||
closeSocketQuietly();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, "terminal-ws-heartbeat");
|
||||
heartbeatThread.start();
|
||||
}
|
||||
|
||||
private void handshake(URI uri) throws Exception {
|
||||
@@ -279,6 +315,10 @@ final class TerminalSocketClient {
|
||||
}
|
||||
|
||||
private void closeSocketQuietly() {
|
||||
if (heartbeatThread != null) {
|
||||
heartbeatThread.interrupt();
|
||||
heartbeatThread = null;
|
||||
}
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
Executable
+46
@@ -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"
|
||||
Executable
+39
@@ -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}"
|
||||
Executable
+93
@@ -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"
|
||||
Reference in New Issue
Block a user