Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e465bed5 | ||
|
|
7b13d42930 | ||
|
|
72913c5571 |
@@ -19,13 +19,15 @@ APIs directly:
|
||||
and kill session through HTTP API
|
||||
- open one live terminal viewer through `/ws/terminal`
|
||||
- native `/ws/events` listener for session invalidation and hook notifications
|
||||
- selectable GitHub or Gitea update manifest source
|
||||
- selectable Gitea or GitHub update source, defaulting to Gitea for mobile
|
||||
reachability
|
||||
- native Tools page for health, server status, timeline, preferences, hook
|
||||
events, image file/URL upload, image preview info, and native image preview
|
||||
display
|
||||
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
|
||||
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
|
||||
- automatic update checks against the selected release manifest
|
||||
- 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
|
||||
handoff
|
||||
- native Update and About pages for version/build type, protocol, permission,
|
||||
@@ -93,10 +95,10 @@ https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux
|
||||
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
Those GitHub links are the primary public install/update channel. Gitea releases
|
||||
are mirrored as a second public source. This Gitea instance does not support the
|
||||
GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
|
||||
Release API as the stable Gitea update entrypoint.
|
||||
Gitea is the app's default install/update channel because phones may not be able
|
||||
to reach GitHub reliably. GitHub remains an optional public source. This Gitea
|
||||
instance does not support the GitHub-style `/releases/latest/download/...` URL,
|
||||
so the app uses the Gitea Release API as the stable Gitea update entrypoint.
|
||||
|
||||
Plain branch builds only create Actions artifacts; they are useful for CI
|
||||
verification, but releases are the stable download/update channel.
|
||||
@@ -146,27 +148,33 @@ release manifest automatically, downloads the newer APK, verifies its SHA-256,
|
||||
then opens Android's package installer. The user still has to approve the
|
||||
install, and Android 8+ may require allowing this app to install unknown apps.
|
||||
|
||||
The default update manifest is:
|
||||
The default update source is the Gitea Release API:
|
||||
|
||||
```text
|
||||
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
|
||||
```
|
||||
|
||||
The default source resolves `latest.json` and `tmux-android.apk` from Gitea
|
||||
release assets, so APK downloads do not require GitHub. The optional GitHub
|
||||
manifest is:
|
||||
|
||||
```text
|
||||
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
The public manual APK download is:
|
||||
The app has four update checks on the `Update` page:
|
||||
|
||||
```text
|
||||
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
|
||||
```
|
||||
- `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.
|
||||
|
||||
The app checks only the selected update source. It does not probe GitHub and
|
||||
Gitea during the same update check. Choose the source in the app's `Update`
|
||||
page, or use a custom manifest/API URL.
|
||||
|
||||
The built-in Gitea API endpoint is:
|
||||
|
||||
```text
|
||||
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
|
||||
```
|
||||
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:
|
||||
|
||||
@@ -177,8 +185,9 @@ https://gitea.neatcn.com/tmux/tmux-browser-android/releases/download/v0.1.7/tmux
|
||||
|
||||
In the app:
|
||||
|
||||
- Open the `Update` page to check `latest.json`, download the APK, verify
|
||||
SHA-256, and open Android's installer.
|
||||
- Open the `Update` page and tap `Auto check` to check `latest.json`, download
|
||||
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,
|
||||
the app reuses that file instead of downloading it again.
|
||||
- If Android sends you to the unknown-app install permission screen, return to
|
||||
|
||||
@@ -9,6 +9,8 @@ val repoSlug = providers.gradleProperty("repoSlug")
|
||||
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
|
||||
.orElse("http://100.89.0.116:3000")
|
||||
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
|
||||
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
|
||||
val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
|
||||
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
|
||||
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
|
||||
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
|
||||
@@ -36,6 +38,7 @@ android {
|
||||
|
||||
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
|
||||
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
|
||||
buildConfigField("String", "DEFAULT_GITHUB_UPDATE_URL", "\"${defaultGithubUpdateUrl.get()}\"")
|
||||
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
|
||||
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
|
||||
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
|
||||
|
||||
@@ -60,6 +60,7 @@ public final class MainActivity extends Activity {
|
||||
private static final int MAX_TERMINAL_CHARS = 40_000;
|
||||
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
|
||||
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
|
||||
private static final String OLD_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
|
||||
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
|
||||
private static final String PAGE_SESSIONS = "Sessions";
|
||||
private static final String PAGE_PROJECTS = "Projects";
|
||||
@@ -116,6 +117,7 @@ public final class MainActivity extends Activity {
|
||||
.putBoolean("tailscale_defaults_applied_v1", true)
|
||||
.apply();
|
||||
}
|
||||
migrateDefaultUpdateSourceToGitea();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
|
||||
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
|
||||
@@ -368,7 +370,10 @@ public final class MainActivity extends Activity {
|
||||
));
|
||||
content.addView(sectionTitle("Update"));
|
||||
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("APK", view -> updateManager.openApkDownload())
|
||||
));
|
||||
@@ -426,7 +431,7 @@ public final class MainActivity extends Activity {
|
||||
"Update policy",
|
||||
"Selected source: " + updateSourceHost() + "\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(
|
||||
actionButton("Release page", view -> updateManager.openReleasePage()),
|
||||
@@ -1431,15 +1436,15 @@ public final class MainActivity extends Activity {
|
||||
text.append("Tap Release on the About page. The app resolves the page from the selected update source.\n");
|
||||
text.append('\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: ")
|
||||
.append(updateSourceHost())
|
||||
.append('\n')
|
||||
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
|
||||
.append('\n');
|
||||
text.append("Available sources:\n");
|
||||
text.append("GitHub: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
|
||||
text.append("Gitea: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\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('\n');
|
||||
text.append(permissionSummary());
|
||||
|
||||
@@ -1476,17 +1481,17 @@ public final class MainActivity extends Activity {
|
||||
|
||||
private void showUpdateSourcePicker() {
|
||||
String[] items = {
|
||||
"GitHub: " + BuildConfig.DEFAULT_UPDATE_URL,
|
||||
"Gitea: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
|
||||
"Gitea default: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
|
||||
"GitHub optional: " + BuildConfig.DEFAULT_GITHUB_UPDATE_URL,
|
||||
"Custom URL"
|
||||
};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Update source")
|
||||
.setItems(items, (dialog, which) -> {
|
||||
if (which == 0) {
|
||||
setUpdateUrl(BuildConfig.DEFAULT_UPDATE_URL);
|
||||
} else if (which == 1) {
|
||||
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
|
||||
} else if (which == 1) {
|
||||
setUpdateUrl(BuildConfig.DEFAULT_GITHUB_UPDATE_URL);
|
||||
} else {
|
||||
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
|
||||
}
|
||||
@@ -1504,6 +1509,15 @@ public final class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateDefaultUpdateSourceToGitea() {
|
||||
String current = prefs.getString("update_url", "");
|
||||
if (current == null || current.trim().isEmpty() || OLD_GITHUB_DEFAULT_UPDATE_URL.equals(current.trim())) {
|
||||
prefs.edit()
|
||||
.putString("update_url", BuildConfig.DEFAULT_GITEA_UPDATE_URL)
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
|
||||
private void probeServerProfiles() {
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
setStatus("Probing Tailscale APIs...");
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
@@ -25,6 +28,8 @@ final class TerminalSocketClient {
|
||||
|
||||
private final Object writeLock = new Object();
|
||||
private final Listener listener;
|
||||
private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor(runnable ->
|
||||
new Thread(runnable, "terminal-ws-write"));
|
||||
private Socket socket;
|
||||
private BufferedInputStream input;
|
||||
private BufferedOutputStream output;
|
||||
@@ -42,32 +47,34 @@ final class TerminalSocketClient {
|
||||
}
|
||||
|
||||
void sendInput(String data) {
|
||||
sendMessage("input", "data", data);
|
||||
sendMessageAsync("input", "data", data);
|
||||
}
|
||||
|
||||
void resize(int cols, int rows) {
|
||||
sendMessage("resize", "cols", cols, "rows", rows);
|
||||
sendMessageAsync("resize", "cols", cols, "rows", rows);
|
||||
}
|
||||
|
||||
void scroll(int lines) {
|
||||
sendMessage("scroll", "lines", lines);
|
||||
sendMessageAsync("scroll", "lines", lines);
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
sendMessage("clear-history");
|
||||
sendMessageAsync("clear-history");
|
||||
}
|
||||
|
||||
void close() {
|
||||
closed = true;
|
||||
try {
|
||||
sendFrame(8, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
writeExecutor.execute(() -> {
|
||||
try {
|
||||
sendFrame(8, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
closeSocketQuietly();
|
||||
});
|
||||
writeExecutor.shutdown();
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
closeSocketQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +89,7 @@ final class TerminalSocketClient {
|
||||
input = new BufferedInputStream(socket.getInputStream());
|
||||
output = new BufferedOutputStream(socket.getOutputStream());
|
||||
handshake(uri);
|
||||
sendMessage(
|
||||
sendMessageSync(
|
||||
"attach",
|
||||
"tabId", "android-" + System.currentTimeMillis(),
|
||||
"sessionName", sessionName,
|
||||
@@ -98,12 +105,8 @@ final class TerminalSocketClient {
|
||||
} finally {
|
||||
closed = true;
|
||||
listener.onClosed();
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
closeSocketQuietly();
|
||||
writeExecutor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +246,7 @@ final class TerminalSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendJson(JSONObject object) {
|
||||
private void sendJsonSync(JSONObject object) {
|
||||
try {
|
||||
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
|
||||
} 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 {
|
||||
JSONObject object = new JSONObject();
|
||||
object.put("type", type);
|
||||
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
||||
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
|
||||
}
|
||||
sendJson(object);
|
||||
sendJsonSync(object);
|
||||
} catch (Exception error) {
|
||||
if (!closed) {
|
||||
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 {
|
||||
synchronized (writeLock) {
|
||||
if (output == null) {
|
||||
|
||||
@@ -26,6 +26,8 @@ import java.util.concurrent.Executors;
|
||||
|
||||
final class UpdateManager {
|
||||
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 {
|
||||
void onChecking(boolean checking);
|
||||
@@ -51,25 +53,77 @@ final class UpdateManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
postMessage("Update check already running");
|
||||
return;
|
||||
}
|
||||
String manifestUrl = getUpdateManifestUrl();
|
||||
checkInProgress = true;
|
||||
callback.onChecking(true);
|
||||
postMessage("Checking update from " + hostLabel(manifestUrl) + "...");
|
||||
postMessage("Checking update: " + label + "...");
|
||||
executor.execute(() -> {
|
||||
Exception lastError = null;
|
||||
try {
|
||||
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
|
||||
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
||||
postMessage("Already up to date: " + BuildConfig.VERSION_NAME);
|
||||
return;
|
||||
for (int index = 0; index < manifestUrls.length; index++) {
|
||||
String manifestUrl = manifestUrls[index];
|
||||
try {
|
||||
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);
|
||||
activity.runOnUiThread(() -> showUpdateDialog(info));
|
||||
} catch (Exception error) {
|
||||
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
|
||||
if (lastError != null) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new IllegalStateException("No update sources configured");
|
||||
} catch (Exception finalError) {
|
||||
postMessage(userInitiated ? "Update check failed: " + finalError.getMessage() : null);
|
||||
} finally {
|
||||
checkInProgress = false;
|
||||
activity.runOnUiThread(() -> callback.onChecking(false));
|
||||
@@ -177,6 +231,22 @@ final class UpdateManager {
|
||||
}
|
||||
|
||||
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();
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
@@ -238,7 +308,36 @@ final class UpdateManager {
|
||||
}
|
||||
|
||||
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.setReadTimeout(60000);
|
||||
try (InputStream input = new BufferedInputStream(connection.getInputStream());
|
||||
@@ -251,7 +350,6 @@ final class UpdateManager {
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
return apk;
|
||||
}
|
||||
|
||||
private boolean isCachedApkValid(File apk, ReleaseInfo info) throws Exception {
|
||||
@@ -366,4 +464,8 @@ final class UpdateManager {
|
||||
}
|
||||
activity.runOnUiThread(() -> callback.onMessage(message));
|
||||
}
|
||||
|
||||
private void waitBeforeRetry() throws InterruptedException {
|
||||
Thread.sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ Implemented now:
|
||||
preview display
|
||||
- GitHub Actions APK build
|
||||
- release manifest `latest.json`
|
||||
- selected-source update checks; GitHub and Gitea are not probed in the same
|
||||
- selected-source update checks; Gitea and GitHub are not probed in the same
|
||||
update check
|
||||
- one-download-per-version APK cache, SHA-256 verification, and installer
|
||||
handoff
|
||||
@@ -101,11 +101,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
|
||||
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.
|
||||
GitHub is the default public source. Gitea is available as a public mirror, but
|
||||
the app does not fall back across both providers during a normal check. This
|
||||
keeps update behavior predictable on mobile networks and avoids duplicate
|
||||
provider checks.
|
||||
The app provides explicit update checks for Auto, Gitea, GitHub, and Selected.
|
||||
Auto checks Gitea first because phones may not reach GitHub reliably, then tries
|
||||
GitHub only if Gitea cannot be reached. The manual Gitea, GitHub, and Selected
|
||||
buttons force one source. Transient network failures are retried against the
|
||||
current source before a source is considered failed.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user