Add update source fallback checks
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m57s

This commit is contained in:
Codex
2026-07-07 03:45:46 +00:00
parent 7b13d42930
commit f9e465bed5
4 changed files with 143 additions and 26 deletions
@@ -370,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())
));
@@ -428,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()),
@@ -1433,7 +1436,7 @@ public final class MainActivity extends Activity {
text.append("Tap Release on the About page. The app resolves the page from the selected update source.\n");
text.append('\n');
text.append("In-app update:\n");
text.append("Tap 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')
@@ -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);
}
}