Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced6523c00 | ||
|
|
2e8a0a164f | ||
|
|
c4713dccbd | ||
|
|
981e94efc3 | ||
|
|
434cb67372 | ||
|
|
10e8139a32 | ||
|
|
f9e465bed5 | ||
|
|
7b13d42930 |
@@ -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."
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ APIs directly:
|
|||||||
display
|
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,
|
||||||
@@ -99,6 +102,15 @@ to reach GitHub reliably. GitHub remains an optional public source. This Gitea
|
|||||||
instance does not support the GitHub-style `/releases/latest/download/...` URL,
|
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.
|
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.
|
||||||
|
|
||||||
@@ -161,9 +173,19 @@ manifest is:
|
|||||||
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 app checks only the selected update source. It does not probe GitHub and
|
The app has four update checks on the `Update` page:
|
||||||
Gitea during the same update check. Choose the source in the app's `Update`
|
|
||||||
page, or use a custom manifest/API URL.
|
- `Auto check` tries Gitea first, then tries GitHub only if Gitea cannot be
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each source retries transient network failures before that source is considered
|
||||||
|
failed. The `APK` and `Release page` buttons still resolve from the selected
|
||||||
|
source, so they can be forced to Gitea on phones that cannot reliably reach
|
||||||
|
GitHub.
|
||||||
|
|
||||||
Gitea tag-specific assets are also public, for example:
|
Gitea tag-specific assets are also public, for example:
|
||||||
|
|
||||||
@@ -174,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
|
||||||
|
|||||||
@@ -19,18 +19,16 @@ import android.net.Uri;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.text.SpannableStringBuilder;
|
|
||||||
import android.text.Spanned;
|
|
||||||
import android.text.InputType;
|
import android.text.InputType;
|
||||||
import android.text.style.BackgroundColorSpan;
|
|
||||||
import android.text.style.ForegroundColorSpan;
|
|
||||||
import android.text.style.StyleSpan;
|
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
|
import android.view.HapticFeedbackConstants;
|
||||||
|
import android.view.KeyEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.view.inputmethod.EditorInfo;
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.HorizontalScrollView;
|
import android.widget.HorizontalScrollView;
|
||||||
@@ -55,9 +53,16 @@ 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 = 40_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 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 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 OLD_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
|
||||||
@@ -93,11 +98,13 @@ public final class MainActivity extends Activity {
|
|||||||
private String activeSessionName;
|
private String activeSessionName;
|
||||||
private String activeMainPage = PAGE_SESSIONS;
|
private String activeMainPage = PAGE_SESSIONS;
|
||||||
private String pendingImageUploadSession;
|
private String pendingImageUploadSession;
|
||||||
private final StringBuilder terminalBuffer = new StringBuilder();
|
private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
|
||||||
private final StringBuilder queuedTerminalInput = new StringBuilder();
|
private final StringBuilder queuedTerminalInput = new StringBuilder();
|
||||||
private boolean terminalConnected;
|
private boolean terminalConnected;
|
||||||
private boolean terminalRenderPending;
|
private boolean terminalRenderPending;
|
||||||
private long lastTerminalRenderMs;
|
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) {
|
||||||
@@ -156,7 +163,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);
|
||||||
@@ -370,7 +376,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())
|
||||||
));
|
));
|
||||||
@@ -428,7 +437,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()),
|
||||||
@@ -958,11 +967,13 @@ public final class MainActivity extends Activity {
|
|||||||
|
|
||||||
private void openTerminal(String sessionName) {
|
private void openTerminal(String sessionName) {
|
||||||
activeSessionName = sessionName;
|
activeSessionName = sessionName;
|
||||||
terminalBuffer.setLength(0);
|
terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
|
||||||
queuedTerminalInput.setLength(0);
|
queuedTerminalInput.setLength(0);
|
||||||
terminalConnected = false;
|
terminalConnected = false;
|
||||||
terminalRenderPending = false;
|
terminalRenderPending = false;
|
||||||
lastTerminalRenderMs = 0L;
|
lastTerminalRenderMs = 0L;
|
||||||
|
terminalCols = DEFAULT_TERMINAL_COLS;
|
||||||
|
terminalRows = DEFAULT_TERMINAL_ROWS;
|
||||||
root.removeAllViews();
|
root.removeAllViews();
|
||||||
root.addView(createTerminalTopBar(sessionName), matchWrap());
|
root.addView(createTerminalTopBar(sessionName), matchWrap());
|
||||||
|
|
||||||
@@ -983,21 +994,26 @@ 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,
|
||||||
1
|
1
|
||||||
));
|
));
|
||||||
root.addView(createInputBar(), matchWrap());
|
root.addView(createComposerBar(), matchWrap());
|
||||||
root.addView(createSoftKeyBar(), new LinearLayout.LayoutParams(
|
root.addView(createSoftKeyPad(), new LinearLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
dp(46)
|
dp(92)
|
||||||
));
|
));
|
||||||
root.addView(statusText, new LinearLayout.LayoutParams(
|
root.addView(statusText, new LinearLayout.LayoutParams(
|
||||||
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) {
|
||||||
@@ -1112,7 +1128,7 @@ public final class MainActivity extends Activity {
|
|||||||
.setItems(items, (dialog, which) -> {
|
.setItems(items, (dialog, which) -> {
|
||||||
switch (which) {
|
switch (which) {
|
||||||
case 0:
|
case 0:
|
||||||
terminalBuffer.setLength(0);
|
terminalScreen.clear();
|
||||||
terminalText.setText("");
|
terminalText.setText("");
|
||||||
if (terminalSocket != null) {
|
if (terminalSocket != null) {
|
||||||
terminalSocket.clearHistory();
|
terminalSocket.clearHistory();
|
||||||
@@ -1126,12 +1142,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:
|
||||||
@@ -1162,30 +1178,55 @@ public final class MainActivity extends Activity {
|
|||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private LinearLayout createInputBar() {
|
private LinearLayout createComposerBar() {
|
||||||
LinearLayout bar = new LinearLayout(this);
|
LinearLayout bar = new LinearLayout(this);
|
||||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
bar.setOrientation(LinearLayout.VERTICAL);
|
||||||
bar.setGravity(Gravity.CENTER_VERTICAL);
|
bar.setPadding(dp(8), dp(5), dp(8), dp(6));
|
||||||
bar.setPadding(dp(8), dp(5), dp(8), dp(5));
|
|
||||||
bar.setBackgroundColor(Color.rgb(17, 20, 24));
|
bar.setBackgroundColor(Color.rgb(17, 20, 24));
|
||||||
|
|
||||||
|
LinearLayout row = new LinearLayout(this);
|
||||||
|
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
row.setGravity(Gravity.BOTTOM);
|
||||||
|
|
||||||
inputField = new EditText(this);
|
inputField = new EditText(this);
|
||||||
inputField.setSingleLine(true);
|
|
||||||
inputField.setTextColor(Color.WHITE);
|
inputField.setTextColor(Color.WHITE);
|
||||||
inputField.setHintTextColor(Color.rgb(150, 158, 168));
|
inputField.setHintTextColor(Color.rgb(150, 158, 168));
|
||||||
inputField.setHint("type command or text");
|
inputField.setHint("type command, text, or multi-line paste");
|
||||||
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND);
|
inputField.setSingleLine(false);
|
||||||
inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
|
inputField.setMinLines(1);
|
||||||
styleInput(inputField);
|
inputField.setMaxLines(4);
|
||||||
|
inputField.setGravity(Gravity.TOP | Gravity.START);
|
||||||
|
inputField.setImeOptions(EditorInfo.IME_ACTION_NONE);
|
||||||
|
inputField.setInputType(InputType.TYPE_CLASS_TEXT
|
||||||
|
| InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
||||||
|
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
|
||||||
|
styleComposerInput(inputField);
|
||||||
inputField.setOnEditorActionListener((view, actionId, event) -> {
|
inputField.setOnEditorActionListener((view, actionId, event) -> {
|
||||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
if (event != null
|
||||||
|
&& event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|
||||||
|
&& event.isShiftPressed()
|
||||||
|
&& event.getAction() == KeyEvent.ACTION_UP) {
|
||||||
sendLine();
|
sendLine();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
bar.addView(inputField, new LinearLayout.LayoutParams(0, dp(42), 1));
|
row.addView(inputField, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
|
||||||
bar.addView(toolbarButton("Send", view -> sendLine()));
|
row.addView(toolbarButton("Send", view -> sendLine()), new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
dp(54)
|
||||||
|
));
|
||||||
|
bar.addView(row, matchWrap());
|
||||||
|
|
||||||
|
LinearLayout actions = new LinearLayout(this);
|
||||||
|
actions.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
actions.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
actions.setPadding(0, dp(5), 0, 0);
|
||||||
|
actions.addView(compactButton("Enter", view -> sendTerminalInput("\r")));
|
||||||
|
actions.addView(compactButton("NL", view -> insertComposerText("\n")));
|
||||||
|
actions.addView(compactButton("Hide", view -> hideKeyboard()));
|
||||||
|
actions.addView(compactButton("Clear", view -> inputField.setText("")));
|
||||||
|
bar.addView(actions, matchWrap());
|
||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1433,7 +1474,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("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')
|
||||||
@@ -1706,7 +1747,22 @@ public final class MainActivity extends Activity {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
private HorizontalScrollView createSoftKeyBar() {
|
private LinearLayout createSoftKeyPad() {
|
||||||
|
LinearLayout pad = new LinearLayout(this);
|
||||||
|
pad.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
pad.setBackgroundColor(Color.rgb(22, 27, 34));
|
||||||
|
pad.addView(createSoftKeyRow(
|
||||||
|
new String[]{"Esc", "Tab", "^C", "^D", "^L", "^R", "^A", "^E", "^V", "^Z", "^\\", "Paste"},
|
||||||
|
new String[]{"\u001b", "\t", "\u0003", "\u0004", "\u000c", "\u0012", "\u0001", "\u0005", "\u0016", "\u001a", "\u001c", null}
|
||||||
|
), new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||||
|
pad.addView(createSoftKeyRow(
|
||||||
|
new String[]{"Tmux", "Detach", "NewWin", "PrevWin", "NextWin", "Left", "Down", "Up", "Right", "PgUp", "PgDn", "Home", "End"},
|
||||||
|
new String[]{"\u0002", "\u0002d", "\u0002c", "\u0002p", "\u0002n", "\u001b[D", "\u001b[B", "\u001b[A", "\u001b[C", "\u001b[5~", "\u001b[6~", "\u001b[H", "\u001b[F"}
|
||||||
|
), new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||||
|
return pad;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalScrollView createSoftKeyRow(String[] labels, String[] sequences) {
|
||||||
HorizontalScrollView scroller = new HorizontalScrollView(this);
|
HorizontalScrollView scroller = new HorizontalScrollView(this);
|
||||||
scroller.setHorizontalScrollBarEnabled(false);
|
scroller.setHorizontalScrollBarEnabled(false);
|
||||||
scroller.setBackgroundColor(Color.rgb(22, 27, 34));
|
scroller.setBackgroundColor(Color.rgb(22, 27, 34));
|
||||||
@@ -1715,32 +1771,15 @@ public final class MainActivity extends Activity {
|
|||||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
row.setGravity(Gravity.CENTER_VERTICAL);
|
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
row.setPadding(dp(6), dp(4), dp(6), dp(4));
|
row.setPadding(dp(6), dp(4), dp(6), dp(4));
|
||||||
addSoftKey(row, "Enter", "\r");
|
for (int index = 0; index < labels.length; index++) {
|
||||||
addSoftKey(row, "Esc", "\u001b");
|
String label = labels[index];
|
||||||
addSoftKey(row, "Tab", "\t");
|
String sequence = sequences[index];
|
||||||
addSoftKey(row, "^C", "\u0003");
|
if (sequence == null) {
|
||||||
addSoftKey(row, "^D", "\u0004");
|
addSoftButton(row, label, view -> pasteClipboard());
|
||||||
addSoftKey(row, "^L", "\u000c");
|
} else {
|
||||||
addSoftKey(row, "^R", "\u0012");
|
addSoftKey(row, label, sequence);
|
||||||
addSoftKey(row, "^A", "\u0001");
|
}
|
||||||
addSoftKey(row, "^E", "\u0005");
|
}
|
||||||
addSoftKey(row, "^V", "\u0016");
|
|
||||||
addSoftKey(row, "^Z", "\u001a");
|
|
||||||
addSoftKey(row, "^\\", "\u001c");
|
|
||||||
addSoftKey(row, "Tmux", "\u0002");
|
|
||||||
addSoftKey(row, "Detach", "\u0002d");
|
|
||||||
addSoftKey(row, "NewWin", "\u0002c");
|
|
||||||
addSoftKey(row, "NextWin", "\u0002n");
|
|
||||||
addSoftKey(row, "PrevWin", "\u0002p");
|
|
||||||
addSoftKey(row, "Left", "\u001b[D");
|
|
||||||
addSoftKey(row, "Down", "\u001b[B");
|
|
||||||
addSoftKey(row, "Up", "\u001b[A");
|
|
||||||
addSoftKey(row, "Right", "\u001b[C");
|
|
||||||
addSoftKey(row, "PgUp", "\u001b[5~");
|
|
||||||
addSoftKey(row, "PgDn", "\u001b[6~");
|
|
||||||
addSoftKey(row, "Home", "\u001b[H");
|
|
||||||
addSoftKey(row, "End", "\u001b[F");
|
|
||||||
addSoftButton(row, "Paste", view -> pasteClipboard());
|
|
||||||
scroller.addView(row, new HorizontalScrollView.LayoutParams(
|
scroller.addView(row, new HorizontalScrollView.LayoutParams(
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
@@ -1753,6 +1792,7 @@ public final class MainActivity extends Activity {
|
|||||||
queuedTerminalInput.setLength(0);
|
queuedTerminalInput.setLength(0);
|
||||||
terminalConnected = false;
|
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
|
||||||
@@ -1760,6 +1800,7 @@ public final class MainActivity extends Activity {
|
|||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
terminalConnected = true;
|
terminalConnected = true;
|
||||||
setStatus("Connected " + sessionName);
|
setStatus("Connected " + sessionName);
|
||||||
|
resizeTerminalToViewport(true);
|
||||||
flushQueuedTerminalInput();
|
flushQueuedTerminalInput();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1785,7 +1826,41 @@ public final class MainActivity extends Activity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
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) - 1, 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;
|
||||||
|
terminalScreen.resize(cols, rows);
|
||||||
|
scheduleTerminalRender();
|
||||||
|
TerminalSocketClient socket = terminalSocket;
|
||||||
|
if (socket != null && !socket.isClosed() && terminalConnected) {
|
||||||
|
socket.resize(cols, rows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendLine() {
|
private void sendLine() {
|
||||||
@@ -1795,10 +1870,16 @@ public final class MainActivity extends Activity {
|
|||||||
String text = inputField.getText().toString();
|
String text = inputField.getText().toString();
|
||||||
if (text.isEmpty()) {
|
if (text.isEmpty()) {
|
||||||
sendTerminalInput("\r");
|
sendTerminalInput("\r");
|
||||||
} else {
|
return;
|
||||||
sendTerminalInput(text + "\r");
|
|
||||||
inputField.setText("");
|
|
||||||
}
|
}
|
||||||
|
String normalized = text.replace("\r\n", "\n").replace('\r', '\n').replace('\n', '\r');
|
||||||
|
if (!normalized.endsWith("\r")) {
|
||||||
|
normalized = normalized + "\r";
|
||||||
|
}
|
||||||
|
sendTerminalInput(normalized);
|
||||||
|
inputField.setText("");
|
||||||
|
hideKeyboard();
|
||||||
|
setStatus("Sent " + text.length() + " chars");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendTerminalInput(String data) {
|
private void sendTerminalInput(String data) {
|
||||||
@@ -1808,6 +1889,7 @@ public final class MainActivity extends Activity {
|
|||||||
TerminalSocketClient socket = terminalSocket;
|
TerminalSocketClient socket = terminalSocket;
|
||||||
if (socket != null && !socket.isClosed() && terminalConnected) {
|
if (socket != null && !socket.isClosed() && terminalConnected) {
|
||||||
socket.sendInput(data);
|
socket.sendInput(data);
|
||||||
|
setStatus("Sent input");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (socket != null && !socket.isClosed()) {
|
if (socket != null && !socket.isClosed()) {
|
||||||
@@ -1820,6 +1902,7 @@ 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()));
|
||||||
}
|
}
|
||||||
@@ -1838,23 +1921,45 @@ public final class MainActivity extends Activity {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void appendTerminal(String data) {
|
private void insertComposerText(String text) {
|
||||||
terminalBuffer.append(data);
|
if (inputField == null) {
|
||||||
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
|
return;
|
||||||
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
|
|
||||||
}
|
}
|
||||||
|
int start = Math.max(0, inputField.getSelectionStart());
|
||||||
|
int end = Math.max(0, inputField.getSelectionEnd());
|
||||||
|
inputField.getText().replace(Math.min(start, end), Math.max(start, end), text);
|
||||||
|
inputField.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hideKeyboard() {
|
||||||
|
if (inputField == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
InputMethodManager manager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
if (manager != null) {
|
||||||
|
manager.hideSoftInputFromWindow(inputField.getWindowToken(), 0);
|
||||||
|
}
|
||||||
|
inputField.clearFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendTerminal(String data) {
|
||||||
|
terminalScreen.write(data);
|
||||||
scheduleTerminalRender();
|
scheduleTerminalRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1876,134 +1981,10 @@ public final class MainActivity extends Activity {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastTerminalRenderMs = System.currentTimeMillis();
|
lastTerminalRenderMs = System.currentTimeMillis();
|
||||||
terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
|
terminalText.setText(terminalScreen.render());
|
||||||
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
|
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
|
||||||
}
|
}
|
||||||
|
|
||||||
private CharSequence renderAnsiForTerminal(String text) {
|
|
||||||
SpannableStringBuilder output = new SpannableStringBuilder();
|
|
||||||
int fg = Color.rgb(230, 235, 242);
|
|
||||||
int bg = Color.TRANSPARENT;
|
|
||||||
boolean bold = false;
|
|
||||||
int index = 0;
|
|
||||||
while (index < text.length()) {
|
|
||||||
char item = text.charAt(index);
|
|
||||||
if (item == '\r') {
|
|
||||||
index++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (item == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[') {
|
|
||||||
int end = findAnsiEnd(text, index + 2);
|
|
||||||
if (end == -1) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
char command = text.charAt(end);
|
|
||||||
if (command == 'm') {
|
|
||||||
int[] state = applySgr(text.substring(index + 2, end), fg, bg, bold);
|
|
||||||
fg = state[0];
|
|
||||||
bg = state[1];
|
|
||||||
bold = state[2] == 1;
|
|
||||||
}
|
|
||||||
index = end + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int runStart = index;
|
|
||||||
while (index < text.length()) {
|
|
||||||
char runItem = text.charAt(index);
|
|
||||||
if (runItem == '\r' || (runItem == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[')) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
appendTerminalRun(output, text.substring(runStart, index), fg, bg, bold);
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
for (int index = start; index < text.length(); index++) {
|
|
||||||
char item = text.charAt(index);
|
|
||||||
if (item >= '@' && item <= '~') {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int[] applySgr(String params, int fg, int bg, boolean bold) {
|
|
||||||
if (params.isEmpty()) {
|
|
||||||
params = "0";
|
|
||||||
}
|
|
||||||
String[] parts = params.split(";");
|
|
||||||
for (String part : parts) {
|
|
||||||
int value;
|
|
||||||
try {
|
|
||||||
value = part.isEmpty() ? 0 : Integer.parseInt(part);
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value == 0) {
|
|
||||||
fg = Color.rgb(230, 235, 242);
|
|
||||||
bg = Color.TRANSPARENT;
|
|
||||||
bold = false;
|
|
||||||
} else if (value == 1) {
|
|
||||||
bold = true;
|
|
||||||
} else if (value == 22) {
|
|
||||||
bold = false;
|
|
||||||
} else if (value == 39) {
|
|
||||||
fg = Color.rgb(230, 235, 242);
|
|
||||||
} else if (value == 49) {
|
|
||||||
bg = Color.TRANSPARENT;
|
|
||||||
} else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) {
|
|
||||||
fg = ansiColor(value, false);
|
|
||||||
} else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) {
|
|
||||||
bg = ansiColor(value, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new int[]{fg, bg, bold ? 1 : 0};
|
|
||||||
}
|
|
||||||
|
|
||||||
private int ansiColor(int code, boolean background) {
|
|
||||||
int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30);
|
|
||||||
boolean bright = code >= 90;
|
|
||||||
switch (base) {
|
|
||||||
case 0:
|
|
||||||
return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45);
|
|
||||||
case 1:
|
|
||||||
return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73);
|
|
||||||
case 2:
|
|
||||||
return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80);
|
|
||||||
case 3:
|
|
||||||
return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34);
|
|
||||||
case 4:
|
|
||||||
return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255);
|
|
||||||
case 5:
|
|
||||||
return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255);
|
|
||||||
case 6:
|
|
||||||
return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187);
|
|
||||||
case 7:
|
|
||||||
default:
|
|
||||||
return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveServerAndRefresh() {
|
private void saveServerAndRefresh() {
|
||||||
String url = normalizeServerUrl(urlField.getText().toString());
|
String url = normalizeServerUrl(urlField.getText().toString());
|
||||||
prefs.edit().putString("server_url", url).apply();
|
prefs.edit().putString("server_url", url).apply();
|
||||||
@@ -2078,7 +2059,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)
|
||||||
@@ -2097,8 +2091,26 @@ public final class MainActivity extends Activity {
|
|||||||
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
|
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void styleComposerInput(EditText input) {
|
||||||
|
input.setTextColor(Color.rgb(240, 246, 252));
|
||||||
|
input.setHintTextColor(Color.rgb(139, 148, 158));
|
||||||
|
input.setTextSize(14);
|
||||||
|
input.setPadding(dp(10), dp(8), dp(10), dp(8));
|
||||||
|
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button compactButton(String label, View.OnClickListener listener) {
|
||||||
|
Button button = toolbarButton(label, listener);
|
||||||
|
button.setTextSize(11);
|
||||||
|
button.setPadding(dp(8), 0, dp(8), 0);
|
||||||
|
button.setMinWidth(dp(48));
|
||||||
|
button.setMinimumWidth(dp(48));
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
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));
|
||||||
@@ -2153,16 +2165,91 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
package com.neatstudio.tmuxandroid;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.Spanned;
|
||||||
|
import android.text.style.BackgroundColorSpan;
|
||||||
|
import android.text.style.ForegroundColorSpan;
|
||||||
|
import android.text.style.StyleSpan;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class TerminalScreenBuffer {
|
||||||
|
private static final int DEFAULT_FG = 0xffe6ebf2;
|
||||||
|
private static final int DEFAULT_BG = Color.TRANSPARENT;
|
||||||
|
|
||||||
|
private int cols;
|
||||||
|
private int rows;
|
||||||
|
private Cell[][] cells;
|
||||||
|
private int cursorRow;
|
||||||
|
private int cursorCol;
|
||||||
|
private int savedRow;
|
||||||
|
private int savedCol;
|
||||||
|
private String pendingControl = "";
|
||||||
|
private boolean wrapPending;
|
||||||
|
private int fg = DEFAULT_FG;
|
||||||
|
private int bg = DEFAULT_BG;
|
||||||
|
private boolean bold;
|
||||||
|
|
||||||
|
TerminalScreenBuffer(int cols, int rows) {
|
||||||
|
resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resize(int nextCols, int nextRows) {
|
||||||
|
nextCols = Math.max(1, nextCols);
|
||||||
|
nextRows = Math.max(1, nextRows);
|
||||||
|
Cell[][] previous = cells;
|
||||||
|
int previousRows = rows;
|
||||||
|
int previousCols = cols;
|
||||||
|
cols = nextCols;
|
||||||
|
rows = nextRows;
|
||||||
|
cells = new Cell[rows][cols];
|
||||||
|
for (int row = 0; row < rows; row++) {
|
||||||
|
for (int col = 0; col < cols; col++) {
|
||||||
|
cells[row][col] = new Cell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previous != null) {
|
||||||
|
int copyRows = Math.min(previousRows, rows);
|
||||||
|
int copyCols = Math.min(previousCols, cols);
|
||||||
|
int previousStart = Math.max(0, previousRows - copyRows);
|
||||||
|
int nextStart = Math.max(0, rows - copyRows);
|
||||||
|
for (int row = 0; row < copyRows; row++) {
|
||||||
|
for (int col = 0; col < copyCols; col++) {
|
||||||
|
cells[nextStart + row][col].copyFrom(previous[previousStart + row][col]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursorRow = clamp(cursorRow, 0, rows - 1);
|
||||||
|
cursorCol = clamp(cursorCol, 0, cols - 1);
|
||||||
|
savedRow = clamp(savedRow, 0, rows - 1);
|
||||||
|
savedCol = clamp(savedCol, 0, cols - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
clearScreen();
|
||||||
|
cursorRow = 0;
|
||||||
|
cursorCol = 0;
|
||||||
|
savedRow = 0;
|
||||||
|
savedCol = 0;
|
||||||
|
pendingControl = "";
|
||||||
|
wrapPending = false;
|
||||||
|
fg = DEFAULT_FG;
|
||||||
|
bg = DEFAULT_BG;
|
||||||
|
bold = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(String text) {
|
||||||
|
if (!pendingControl.isEmpty()) {
|
||||||
|
text = pendingControl + text;
|
||||||
|
pendingControl = "";
|
||||||
|
}
|
||||||
|
int index = 0;
|
||||||
|
while (index < text.length()) {
|
||||||
|
char item = text.charAt(index);
|
||||||
|
if (item == '\u001b') {
|
||||||
|
int next = handleEscape(text, index);
|
||||||
|
if (next < 0) {
|
||||||
|
pendingControl = text.substring(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
index = next;
|
||||||
|
} else if (item == '\r') {
|
||||||
|
wrapPending = false;
|
||||||
|
cursorCol = 0;
|
||||||
|
index++;
|
||||||
|
} else if (item == '\n') {
|
||||||
|
wrapPending = false;
|
||||||
|
newLine();
|
||||||
|
index++;
|
||||||
|
} else if (item == '\b') {
|
||||||
|
wrapPending = false;
|
||||||
|
cursorCol = Math.max(0, cursorCol - 1);
|
||||||
|
index++;
|
||||||
|
} else if (item == '\t') {
|
||||||
|
int nextTab = ((cursorCol / 8) + 1) * 8;
|
||||||
|
while (cursorCol < Math.min(nextTab, cols)) {
|
||||||
|
putChar(' ');
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
} else if (isNakedDeviceAttributesTail(text, index)) {
|
||||||
|
index = skipNakedDeviceAttributesTail(text, index);
|
||||||
|
} else if (item >= 0x20 && item != 0x7f) {
|
||||||
|
putChar(item);
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CharSequence render() {
|
||||||
|
SpannableStringBuilder output = new SpannableStringBuilder();
|
||||||
|
for (int row = 0; row < rows; row++) {
|
||||||
|
appendRow(output, row);
|
||||||
|
if (row + 1 < rows) {
|
||||||
|
output.append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int handleEscape(String text, int index) {
|
||||||
|
if (index + 1 >= text.length()) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
char next = text.charAt(index + 1);
|
||||||
|
wrapPending = false;
|
||||||
|
if (next == '[') {
|
||||||
|
int end = findAnsiEnd(text, index + 2);
|
||||||
|
if (end == -1) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
applyCsi(text.substring(index + 2, end), text.charAt(end));
|
||||||
|
return end + 1;
|
||||||
|
}
|
||||||
|
if (next == ']') {
|
||||||
|
return skipStringEscape(text, index + 2);
|
||||||
|
}
|
||||||
|
if (next == 'P' || next == '^' || next == '_') {
|
||||||
|
return skipStringEscape(text, index + 2);
|
||||||
|
}
|
||||||
|
if (next == '(' || next == ')' || next == '*' || next == '+' || next == '-' || next == '.') {
|
||||||
|
if (index + 2 >= text.length()) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return Math.min(index + 3, text.length());
|
||||||
|
}
|
||||||
|
if (next == '7') {
|
||||||
|
saveCursor();
|
||||||
|
} else if (next == '8') {
|
||||||
|
restoreCursor();
|
||||||
|
} else if (next == 'D') {
|
||||||
|
newLine();
|
||||||
|
} else if (next == 'E') {
|
||||||
|
cursorCol = 0;
|
||||||
|
newLine();
|
||||||
|
} else if (next == 'M') {
|
||||||
|
reverseIndex();
|
||||||
|
} else if (next == 'c') {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
return Math.min(index + 2, text.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCsi(String rawParams, char command) {
|
||||||
|
wrapPending = false;
|
||||||
|
String params = rawParams;
|
||||||
|
while (!params.isEmpty()) {
|
||||||
|
char first = params.charAt(0);
|
||||||
|
if (first == '?' || first == '>' || first == '!' || first == '=') {
|
||||||
|
params = params.substring(1);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Integer> values = parseParams(params);
|
||||||
|
switch (command) {
|
||||||
|
case 'm':
|
||||||
|
applySgr(values);
|
||||||
|
break;
|
||||||
|
case 'H':
|
||||||
|
case 'f':
|
||||||
|
cursorRow = clamp(param(values, 0, 1) - 1, 0, rows - 1);
|
||||||
|
cursorCol = clamp(param(values, 1, 1) - 1, 0, cols - 1);
|
||||||
|
break;
|
||||||
|
case 'A':
|
||||||
|
cursorRow = clamp(cursorRow - param(values, 0, 1), 0, rows - 1);
|
||||||
|
break;
|
||||||
|
case 'B':
|
||||||
|
cursorRow = clamp(cursorRow + param(values, 0, 1), 0, rows - 1);
|
||||||
|
break;
|
||||||
|
case 'C':
|
||||||
|
cursorCol = clamp(cursorCol + param(values, 0, 1), 0, cols - 1);
|
||||||
|
break;
|
||||||
|
case 'D':
|
||||||
|
cursorCol = clamp(cursorCol - param(values, 0, 1), 0, cols - 1);
|
||||||
|
break;
|
||||||
|
case 'G':
|
||||||
|
cursorCol = clamp(param(values, 0, 1) - 1, 0, cols - 1);
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
cursorRow = clamp(param(values, 0, 1) - 1, 0, rows - 1);
|
||||||
|
break;
|
||||||
|
case 'J':
|
||||||
|
eraseDisplay(param(values, 0, 0));
|
||||||
|
break;
|
||||||
|
case 'K':
|
||||||
|
eraseLine(param(values, 0, 0));
|
||||||
|
break;
|
||||||
|
case 'P':
|
||||||
|
deleteChars(param(values, 0, 1));
|
||||||
|
break;
|
||||||
|
case '@':
|
||||||
|
insertChars(param(values, 0, 1));
|
||||||
|
break;
|
||||||
|
case 'X':
|
||||||
|
eraseChars(param(values, 0, 1));
|
||||||
|
break;
|
||||||
|
case 'L':
|
||||||
|
insertLines(param(values, 0, 1));
|
||||||
|
break;
|
||||||
|
case 'M':
|
||||||
|
deleteLines(param(values, 0, 1));
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
saveCursor();
|
||||||
|
break;
|
||||||
|
case 'u':
|
||||||
|
restoreCursor();
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySgr(List<Integer> values) {
|
||||||
|
if (values.isEmpty()) {
|
||||||
|
values.add(0);
|
||||||
|
}
|
||||||
|
for (int index = 0; index < values.size(); index++) {
|
||||||
|
int value = values.get(index);
|
||||||
|
if (value == 0) {
|
||||||
|
fg = DEFAULT_FG;
|
||||||
|
bg = DEFAULT_BG;
|
||||||
|
bold = false;
|
||||||
|
} else if (value == 1) {
|
||||||
|
bold = true;
|
||||||
|
} else if (value == 22) {
|
||||||
|
bold = false;
|
||||||
|
} else if (value == 39) {
|
||||||
|
fg = DEFAULT_FG;
|
||||||
|
} else if (value == 49) {
|
||||||
|
bg = DEFAULT_BG;
|
||||||
|
} else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) {
|
||||||
|
fg = ansiColor(value, false);
|
||||||
|
} else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) {
|
||||||
|
bg = ansiColor(value, true);
|
||||||
|
} else if ((value == 38 || value == 48) && index + 2 < values.size()) {
|
||||||
|
boolean background = value == 48;
|
||||||
|
int mode = values.get(index + 1);
|
||||||
|
if (mode == 5) {
|
||||||
|
int color = xtermColor(values.get(index + 2));
|
||||||
|
if (background) {
|
||||||
|
bg = color;
|
||||||
|
} else {
|
||||||
|
fg = color;
|
||||||
|
}
|
||||||
|
index += 2;
|
||||||
|
} else if (mode == 2 && index + 4 < values.size()) {
|
||||||
|
int color = Color.rgb(
|
||||||
|
clamp(values.get(index + 2), 0, 255),
|
||||||
|
clamp(values.get(index + 3), 0, 255),
|
||||||
|
clamp(values.get(index + 4), 0, 255)
|
||||||
|
);
|
||||||
|
if (background) {
|
||||||
|
bg = color;
|
||||||
|
} else {
|
||||||
|
fg = color;
|
||||||
|
}
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putChar(char value) {
|
||||||
|
if (wrapPending) {
|
||||||
|
wrapPending = false;
|
||||||
|
newLine();
|
||||||
|
}
|
||||||
|
cells[cursorRow][cursorCol].set(value, fg, bg, bold);
|
||||||
|
if (cursorCol == cols - 1) {
|
||||||
|
wrapPending = true;
|
||||||
|
} else {
|
||||||
|
cursorCol++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void newLine() {
|
||||||
|
wrapPending = false;
|
||||||
|
cursorRow++;
|
||||||
|
if (cursorRow >= rows) {
|
||||||
|
scrollUp(1);
|
||||||
|
cursorRow = rows - 1;
|
||||||
|
}
|
||||||
|
cursorCol = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reverseIndex() {
|
||||||
|
if (cursorRow == 0) {
|
||||||
|
scrollDown(1);
|
||||||
|
} else {
|
||||||
|
cursorRow--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void eraseDisplay(int mode) {
|
||||||
|
if (mode == 2 || mode == 3) {
|
||||||
|
clearScreen();
|
||||||
|
} else if (mode == 1) {
|
||||||
|
for (int row = 0; row < cursorRow; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
clearLine(cursorRow, 0, cursorCol);
|
||||||
|
} else {
|
||||||
|
clearLine(cursorRow, cursorCol, cols - 1);
|
||||||
|
for (int row = cursorRow + 1; row < rows; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void eraseLine(int mode) {
|
||||||
|
if (mode == 2) {
|
||||||
|
clearLine(cursorRow, 0, cols - 1);
|
||||||
|
} else if (mode == 1) {
|
||||||
|
clearLine(cursorRow, 0, cursorCol);
|
||||||
|
} else {
|
||||||
|
clearLine(cursorRow, cursorCol, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void eraseChars(int count) {
|
||||||
|
int end = Math.min(cols - 1, cursorCol + Math.max(1, count) - 1);
|
||||||
|
clearLine(cursorRow, cursorCol, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteChars(int count) {
|
||||||
|
count = Math.max(1, count);
|
||||||
|
Cell[] line = cells[cursorRow];
|
||||||
|
for (int col = cursorCol; col < cols; col++) {
|
||||||
|
int source = col + count;
|
||||||
|
if (source < cols) {
|
||||||
|
line[col].copyFrom(line[source]);
|
||||||
|
} else {
|
||||||
|
line[col].clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertChars(int count) {
|
||||||
|
count = Math.max(1, count);
|
||||||
|
Cell[] line = cells[cursorRow];
|
||||||
|
for (int col = cols - 1; col >= cursorCol; col--) {
|
||||||
|
int source = col - count;
|
||||||
|
if (source >= cursorCol) {
|
||||||
|
line[col].copyFrom(line[source]);
|
||||||
|
} else {
|
||||||
|
line[col].clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertLines(int count) {
|
||||||
|
count = Math.min(Math.max(1, count), rows - cursorRow);
|
||||||
|
for (int row = rows - 1; row >= cursorRow + count; row--) {
|
||||||
|
copyLine(row, row - count);
|
||||||
|
}
|
||||||
|
for (int row = cursorRow; row < cursorRow + count; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteLines(int count) {
|
||||||
|
count = Math.min(Math.max(1, count), rows - cursorRow);
|
||||||
|
for (int row = cursorRow; row + count < rows; row++) {
|
||||||
|
copyLine(row, row + count);
|
||||||
|
}
|
||||||
|
for (int row = rows - count; row < rows; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearScreen() {
|
||||||
|
for (int row = 0; row < rows; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearLine(int row, int start, int end) {
|
||||||
|
start = clamp(start, 0, cols - 1);
|
||||||
|
end = clamp(end, 0, cols - 1);
|
||||||
|
for (int col = start; col <= end; col++) {
|
||||||
|
cells[row][col].clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scrollUp(int count) {
|
||||||
|
count = Math.min(Math.max(1, count), rows);
|
||||||
|
for (int row = 0; row + count < rows; row++) {
|
||||||
|
copyLine(row, row + count);
|
||||||
|
}
|
||||||
|
for (int row = rows - count; row < rows; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scrollDown(int count) {
|
||||||
|
count = Math.min(Math.max(1, count), rows);
|
||||||
|
for (int row = rows - 1; row - count >= 0; row--) {
|
||||||
|
copyLine(row, row - count);
|
||||||
|
}
|
||||||
|
for (int row = 0; row < count; row++) {
|
||||||
|
clearLine(row, 0, cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyLine(int target, int source) {
|
||||||
|
for (int col = 0; col < cols; col++) {
|
||||||
|
cells[target][col].copyFrom(cells[source][col]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendRow(SpannableStringBuilder output, int row) {
|
||||||
|
int col = 0;
|
||||||
|
while (col < cols) {
|
||||||
|
Cell first = cells[row][col];
|
||||||
|
int start = output.length();
|
||||||
|
int fgColor = first.fg;
|
||||||
|
int bgColor = first.bg;
|
||||||
|
boolean isBold = first.bold;
|
||||||
|
while (col < cols) {
|
||||||
|
Cell cell = cells[row][col];
|
||||||
|
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
output.append(cell.value);
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
int end = output.length();
|
||||||
|
output.setSpan(new ForegroundColorSpan(fgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
if (bgColor != DEFAULT_BG) {
|
||||||
|
output.setSpan(new BackgroundColorSpan(bgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
if (isBold) {
|
||||||
|
output.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveCursor() {
|
||||||
|
savedRow = cursorRow;
|
||||||
|
savedCol = cursorCol;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void restoreCursor() {
|
||||||
|
cursorRow = clamp(savedRow, 0, rows - 1);
|
||||||
|
cursorCol = clamp(savedCol, 0, cols - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int skipStringEscape(String text, int start) {
|
||||||
|
int cursor = start;
|
||||||
|
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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNakedDeviceAttributesTail(String text, int index) {
|
||||||
|
int cursor = index;
|
||||||
|
boolean hasSemicolon = false;
|
||||||
|
if (cursor < text.length() && (text.charAt(cursor) == '?' || text.charAt(cursor) == '>')) {
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
while (cursor < text.length()) {
|
||||||
|
char item = text.charAt(cursor);
|
||||||
|
if (item >= '0' && item <= '9') {
|
||||||
|
cursor++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item == ';') {
|
||||||
|
hasSemicolon = true;
|
||||||
|
cursor++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return item == 'c' && hasSemicolon && cursor > index && cursor - index <= 16;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int skipNakedDeviceAttributesTail(String text, int index) {
|
||||||
|
int cursor = index;
|
||||||
|
while (cursor < text.length() && text.charAt(cursor) != 'c') {
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
return Math.min(cursor + 1, text.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int findAnsiEnd(String text, int start) {
|
||||||
|
for (int index = start; index < text.length(); index++) {
|
||||||
|
char item = text.charAt(index);
|
||||||
|
if (item >= '@' && item <= '~') {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> parseParams(String params) {
|
||||||
|
List<Integer> values = new ArrayList<>();
|
||||||
|
if (params.isEmpty()) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
String[] parts = params.split(";", -1);
|
||||||
|
for (String part : parts) {
|
||||||
|
String cleaned = part.trim();
|
||||||
|
if (cleaned.isEmpty()) {
|
||||||
|
values.add(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
values.add(Integer.parseInt(cleaned));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
values.add(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int param(List<Integer> values, int index, int fallback) {
|
||||||
|
if (index >= values.size()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
int value = values.get(index);
|
||||||
|
return value == 0 ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ansiColor(int code, boolean background) {
|
||||||
|
int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30);
|
||||||
|
boolean bright = code >= 90;
|
||||||
|
switch (base) {
|
||||||
|
case 0:
|
||||||
|
return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45);
|
||||||
|
case 1:
|
||||||
|
return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73);
|
||||||
|
case 2:
|
||||||
|
return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80);
|
||||||
|
case 3:
|
||||||
|
return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34);
|
||||||
|
case 4:
|
||||||
|
return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255);
|
||||||
|
case 5:
|
||||||
|
return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255);
|
||||||
|
case 6:
|
||||||
|
return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187);
|
||||||
|
case 7:
|
||||||
|
default:
|
||||||
|
return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int xtermColor(int value) {
|
||||||
|
value = clamp(value, 0, 255);
|
||||||
|
if (value < 16) {
|
||||||
|
if (value < 8) {
|
||||||
|
return ansiColor(30 + value, false);
|
||||||
|
}
|
||||||
|
return ansiColor(90 + value - 8, false);
|
||||||
|
}
|
||||||
|
if (value >= 232) {
|
||||||
|
int shade = 8 + (value - 232) * 10;
|
||||||
|
return Color.rgb(shade, shade, shade);
|
||||||
|
}
|
||||||
|
int index = value - 16;
|
||||||
|
int red = xtermComponent(index / 36);
|
||||||
|
int green = xtermComponent((index / 6) % 6);
|
||||||
|
int blue = xtermComponent(index % 6);
|
||||||
|
return Color.rgb(red, green, blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int xtermComponent(int value) {
|
||||||
|
return value == 0 ? 0 : 55 + value * 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int value, int min, int max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Cell {
|
||||||
|
char value = ' ';
|
||||||
|
int fg = DEFAULT_FG;
|
||||||
|
int bg = DEFAULT_BG;
|
||||||
|
boolean bold;
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
value = ' ';
|
||||||
|
fg = DEFAULT_FG;
|
||||||
|
bg = DEFAULT_BG;
|
||||||
|
bold = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void set(char nextValue, int nextFg, int nextBg, boolean nextBold) {
|
||||||
|
value = nextValue;
|
||||||
|
fg = nextFg;
|
||||||
|
bg = nextBg;
|
||||||
|
bold = nextBold;
|
||||||
|
}
|
||||||
|
|
||||||
|
void copyFrom(Cell other) {
|
||||||
|
value = other.value;
|
||||||
|
fg = other.fg;
|
||||||
|
bg = other.bg;
|
||||||
|
bold = other.bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,32 +47,34 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ 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);
|
||||||
sendMessage(
|
sendMessageSync(
|
||||||
"attach",
|
"attach",
|
||||||
"tabId", "android-" + System.currentTimeMillis(),
|
"tabId", "android-" + System.currentTimeMillis(),
|
||||||
"sessionName", sessionName,
|
"sessionName", sessionName,
|
||||||
@@ -98,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) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,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) {
|
||||||
@@ -253,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());
|
||||||
@@ -268,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,12 @@ Implemented now:
|
|||||||
preview display
|
preview display
|
||||||
- GitHub Actions APK build
|
- GitHub Actions APK build
|
||||||
- release manifest `latest.json`
|
- release manifest `latest.json`
|
||||||
- selected-source update checks; Gitea and GitHub 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
|
||||||
@@ -101,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.
|
||||||
Gitea is the default public source because phones may not reach GitHub reliably.
|
Auto checks Gitea first because phones may not reach GitHub reliably, then tries
|
||||||
GitHub is available as an optional public source, but the app does not fall back
|
GitHub only if Gitea cannot be reached. The manual Gitea, GitHub, and Selected
|
||||||
across both providers during a normal check. This keeps update behavior
|
buttons force one source. Transient network failures are retried against the
|
||||||
predictable on mobile networks and avoids duplicate 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
|
||||||
@@ -119,6 +121,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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user