Add update source fallback checks
All checks were successful
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

View File

@ -26,7 +26,8 @@ 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 - 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,
@ -161,9 +162,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 +185,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

View File

@ -370,7 +370,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 +431,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()),
@ -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("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')

View File

@ -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);
}
} }

View File

@ -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 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