package com.neatstudio.tmuxandroid; import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Build; import android.provider.Settings; import android.widget.Toast; import org.json.JSONArray; import org.json.JSONObject; import java.io.BufferedInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.security.MessageDigest; import java.util.Locale; import java.util.concurrent.ExecutorService; 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); void onMessage(String message); } private interface ReleaseUrlPicker { String pick(ReleaseInfo info); } private final Activity activity; private final SharedPreferences prefs; private final Callback callback; private final ExecutorService executor = Executors.newSingleThreadExecutor(); private volatile boolean checkInProgress; private volatile boolean downloadInProgress; private File pendingInstallApk; UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) { this.activity = activity; this.prefs = prefs; this.callback = callback; } 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 checkPreview(boolean userInitiated) { startUpdateCheck( userInitiated, "Preview", new String[]{BuildConfig.DEFAULT_PREVIEW_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; } checkInProgress = true; callback.onChecking(true); postMessage("Checking update: " + label + "..."); executor.execute(() -> { Exception lastError = null; try { 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]) + "..."); } } } 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)); } }); } void openApkDownload() { openSelectedReleaseUrl("APK", info -> info.apkUrl); } void openReleasePage() { openSelectedReleaseUrl("Release page", info -> info.releasePageUrl); } private void openSelectedReleaseUrl(String label, ReleaseUrlPicker picker) { if (checkInProgress) { postMessage("Update check already running"); return; } String manifestUrl = getUpdateManifestUrl(); checkInProgress = true; callback.onChecking(true); postMessage("Resolving " + label + " from " + hostLabel(manifestUrl) + "..."); executor.execute(() -> { try { ReleaseInfo info = fetchReleaseInfo(manifestUrl); String url = picker.pick(info); if (url == null || url.trim().isEmpty()) { throw new IllegalStateException(label + " URL is missing"); } activity.runOnUiThread(() -> openExternalUrl(url.trim(), label)); } catch (Exception error) { postMessage(label + " failed: " + error.getMessage()); } finally { checkInProgress = false; activity.runOnUiThread(() -> callback.onChecking(false)); } }); } private String getUpdateManifestUrl() { String url = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL); if (url == null || url.trim().isEmpty()) { return BuildConfig.DEFAULT_UPDATE_URL; } return url.trim(); } private String hostLabel(String url) { try { return new URL(url).getHost(); } catch (Exception ignored) { return url; } } private ReleaseInfo fetchReleaseInfo(String manifestUrl) throws Exception { String json = readText(manifestUrl); JSONObject root = new JSONObject(json); if (root.has("assets") && root.has("tag_name")) { return fetchReleaseInfoFromReleaseApi(root); } return new ReleaseInfo( root.getInt("versionCode"), root.optString("versionName", ""), root.getString("apkUrl"), root.optString("sha256", ""), root.optString("releasePageUrl", "") ); } private ReleaseInfo fetchReleaseInfoFromReleaseApi(JSONObject release) throws Exception { JSONArray assets = release.getJSONArray("assets"); String manifestUrl = findAssetUrl(assets, "latest.json"); String apkUrl = findAssetUrl(assets, "tmux-android.apk"); if (manifestUrl.isEmpty()) { throw new IllegalStateException("Release has no latest.json"); } ReleaseInfo info = fetchReleaseInfo(manifestUrl); if (apkUrl.isEmpty()) { return info; } return new ReleaseInfo( info.versionCode, info.versionName, apkUrl, info.sha256, release.optString("html_url", info.releasePageUrl) ); } private String findAssetUrl(JSONArray assets, String name) { for (int index = 0; index < assets.length(); index++) { JSONObject asset = assets.optJSONObject(index); if (asset == null || !name.equals(asset.optString("name"))) { continue; } String url = asset.optString("browser_download_url", ""); if (!url.isEmpty()) { return url; } } return ""; } 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); connection.setRequestProperty("Accept", "application/json"); try (InputStream input = new BufferedInputStream(connection.getInputStream())) { byte[] bytes = readAllBytes(input); return new String(bytes, java.nio.charset.StandardCharsets.UTF_8); } finally { connection.disconnect(); } } private void showUpdateDialog(ReleaseInfo info) { new AlertDialog.Builder(activity) .setTitle("Update available") .setMessage("Install " + info.versionName + " now?") .setNegativeButton("Later", null) .setPositiveButton("Install", (dialog, which) -> downloadAndInstall(info)) .show(); } private void downloadAndInstall(ReleaseInfo info) { if (downloadInProgress) { postMessage("Update download already running"); return; } downloadInProgress = true; callback.onChecking(true); postMessage("Preparing " + info.versionName + "..."); executor.execute(() -> { try { File apk = downloadApk(info); if (!info.sha256.isEmpty()) { postMessage("Verifying APK..."); String actual = sha256(apk); if (!actual.equalsIgnoreCase(info.sha256)) { throw new IllegalStateException("APK SHA-256 mismatch"); } } activity.runOnUiThread(() -> installApk(apk)); } catch (Exception error) { postMessage("Update download failed: " + error.getMessage()); } finally { downloadInProgress = false; activity.runOnUiThread(() -> callback.onChecking(false)); } }); } private File downloadApk(ReleaseInfo info) throws Exception { File dir = new File(activity.getCacheDir(), "updates"); if (!dir.exists() && !dir.mkdirs()) { throw new IllegalStateException("Cannot create update cache"); } File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk"); if (isCachedApkValid(apk, info)) { postMessage("Using downloaded APK"); return apk; } postMessage("Downloading " + info.versionName + "..."); 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()); FileOutputStream output = new FileOutputStream(apk)) { byte[] buffer = new byte[64 * 1024]; int read; while ((read = input.read(buffer)) != -1) { output.write(buffer, 0, read); } } finally { connection.disconnect(); } } private boolean isCachedApkValid(File apk, ReleaseInfo info) throws Exception { if (!apk.exists() || apk.length() <= 0) { return false; } if (info.sha256.isEmpty()) { return true; } return sha256(apk).equalsIgnoreCase(info.sha256); } private void installApk(File apk) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !activity.getPackageManager().canRequestPackageInstalls()) { pendingInstallApk = apk; prefs.edit().putString(PREF_PENDING_INSTALL_APK, apk.getAbsolutePath()).apply(); Intent settingsIntent = new Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + activity.getPackageName()) ); activity.startActivity(settingsIntent); Toast.makeText(activity, "Allow installs, then return to continue", Toast.LENGTH_LONG).show(); postMessage("Install permission required"); return; } pendingInstallApk = null; prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply(); Uri apkUri = UpdateFileProvider.getUriForFile( activity, activity.getPackageName() + ".fileprovider", apk ); Intent intent = new Intent(Intent.ACTION_VIEW) .setDataAndType(apkUri, "application/vnd.android.package-archive") .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { activity.startActivity(intent); postMessage("Opened Android package installer"); } catch (ActivityNotFoundException error) { postMessage("No package installer found"); } } private void openExternalUrl(String url, String label) { try { activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); postMessage("Opened " + label); } catch (ActivityNotFoundException error) { postMessage("No app can open " + label); } } void resumePendingInstall() { File apk = pendingInstallApk; if (apk == null) { String path = prefs.getString(PREF_PENDING_INSTALL_APK, ""); if (path != null && !path.isEmpty()) { apk = new File(path); pendingInstallApk = apk; } } if (apk == null) { return; } if (!apk.exists() || apk.length() <= 0) { pendingInstallApk = null; prefs.edit().remove(PREF_PENDING_INSTALL_APK).apply(); postMessage("Downloaded APK is no longer available"); return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !activity.getPackageManager().canRequestPackageInstalls()) { return; } postMessage("Continuing APK install..."); installApk(apk); } private static byte[] readAllBytes(InputStream input) throws Exception { java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); byte[] buffer = new byte[16 * 1024]; int read; while ((read = input.read(buffer)) != -1) { output.write(buffer, 0, read); } return output.toByteArray(); } private static String sha256(File file) throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-256"); try (InputStream input = new BufferedInputStream(new java.io.FileInputStream(file))) { byte[] buffer = new byte[64 * 1024]; int read; while ((read = input.read(buffer)) != -1) { digest.update(buffer, 0, read); } } byte[] bytes = digest.digest(); StringBuilder builder = new StringBuilder(bytes.length * 2); for (byte item : bytes) { builder.append(String.format(Locale.US, "%02x", item)); } return builder.toString(); } private void postMessage(String message) { if (message == null) { return; } activity.runOnUiThread(() -> callback.onMessage(message)); } private void waitBeforeRetry() throws InterruptedException { Thread.sleep(RETRY_DELAY_MS); } }