diff --git a/controller/app/build.gradle b/controller/app/build.gradle index 84dd525f..c3708baf 100644 --- a/controller/app/build.gradle +++ b/controller/app/build.gradle @@ -452,3 +452,70 @@ if (helpPython == null) { } tasks.matching { it.name == "preBuild" }.configureEach { dependsOn "buildHelpDb" } } +// ---- rootfs size auto-capture (offline fallback) ------------------------------ +// Fetches the stable latest__.meta4 for each edition and writes +// src/main/assets/rootfs_sizes.csv, so the app's OFFLINE fallback (RootfsCatalog) +// is captured at package time instead of hand-maintained. Non-fatal: an incomplete +// fetch (offline build) keeps the committed CSV. Runs every build (no up-to-date +// cache) but only rewrites the file when a byte size actually changed, so identical +// sizes never dirty the working tree. +task refreshRootfsSizes { + group = 'build setup' + description = 'Capture rootfs image sizes (.meta4) into assets/rootfs_sizes.csv for the offline fallback' + def csv = file('src/main/assets/rootfs_sizes.csv') + doLast { + def base = 'https://iiab.switnet.org/android/rootfs/' + def tiers = ['basic', 'standard', 'full'] + def abis = ['arm64-v8a', 'armeabi-v7a'] + def sizePat = ~/(\d+)<\/size>/ + def fetched = [:] + for (t in tiers) { + for (a in abis) { + def url = "${base}latest_${t}_${a}.meta4" + try { + def conn = (HttpURLConnection) new URL(url).openConnection() + conn.setRequestProperty('User-Agent', 'iiab-android-build') + conn.connectTimeout = 6000 + conn.readTimeout = 6000 + if (conn.responseCode != 200) { + logger.warn(">> [rootfs sizes] HTTP ${conn.responseCode} for ${url}") + conn.disconnect(); continue + } + def body = conn.inputStream.getText('UTF-8') + conn.disconnect() + def m = sizePat.matcher(body) + if (m.find()) { fetched["${t}|${a}".toString()] = m.group(1) } + else { logger.warn(">> [rootfs sizes] no element in ${url}") } + } catch (Exception e) { + logger.warn(">> [rootfs sizes] fetch failed for ${url}: ${e.message}") + } + } + } + def expected = tiers.size() * abis.size() + if (fetched.size() != expected) { + logger.warn(">> [rootfs sizes] incomplete fetch (${fetched.size()}/${expected}); keeping existing ${csv.name} (last known).") + return + } + def existing = [:] + if (csv.exists()) { + csv.eachLine { line -> + line = line.trim() + if (line.isEmpty() || line.startsWith('#')) return + def p = line.split(',') + if (p.length >= 3) existing["${p[0].trim()}|${p[1].trim()}".toString()] = p[2].trim() + } + } + if (existing == fetched) { + println ">> [rootfs sizes] up to date (${fetched.size()} entries unchanged)." + return + } + def today = new Date().format('yyyy-MM-dd') + def rows = [] + for (t in tiers) for (a in abis) rows << "${t},${a},${fetched[("${t}|${a}").toString()]},${today}".toString() + if (!csv.parentFile.exists()) csv.parentFile.mkdirs() + csv.text = '# tier,abi,bytes,fetched_date -- AUTO-GENERATED by the refreshRootfsSizes Gradle task; do not edit by hand\n' + rows.join('\n') + '\n' + println ">> [rootfs sizes] updated ${rows.size()} entries in ${csv.name} (${today})." + } +} + +preBuild.dependsOn refreshRootfsSizes diff --git a/controller/app/src/main/assets/rootfs_sizes.csv b/controller/app/src/main/assets/rootfs_sizes.csv new file mode 100644 index 00000000..9a9cd4bd --- /dev/null +++ b/controller/app/src/main/assets/rootfs_sizes.csv @@ -0,0 +1,7 @@ +# tier,abi,bytes,fetched_date -- AUTO-GENERATED by the refreshRootfsSizes Gradle task; do not edit by hand +basic,arm64-v8a,1219422532,2026-06-17 +standard,arm64-v8a,1428970336,2026-06-17 +full,arm64-v8a,2926676923,2026-06-17 +basic,armeabi-v7a,1220401364,2026-06-17 +standard,armeabi-v7a,1429892132,2026-06-17 +full,armeabi-v7a,2917715443,2026-06-17 diff --git a/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java b/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java index 786e5573..41979c53 100644 --- a/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java +++ b/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java @@ -172,7 +172,7 @@ public static void calculateProjectedSize(Context context, Tier tier, boolean pu * that accepts a pre-resolved {@code osSizeGb} from RootfsViewModel. */ public static void calculateProjectedSize(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, PlanResultListener listener) { - new Thread(() -> computeProjection(context, tier, pullCompanionData, langCode, overrideVariant, resolveOsSizeGb(tier), listener)).start(); + new Thread(() -> computeProjection(context, tier, pullCompanionData, langCode, overrideVariant, resolveOsSizeGb(context, tier), listener)).start(); } /** @@ -280,8 +280,8 @@ public void onError(String error) { *

Performs a (cached) network call; must run off the main thread — it does, * because every caller is inside {@link #calculateProjectedSize}'s worker thread. */ - private static double resolveOsSizeGb(Tier tier) { - RootfsCatalog catalog = new RootfsCatalog(); + private static double resolveOsSizeGb(Context context, Tier tier) { + RootfsCatalog catalog = new RootfsCatalog(context); RootfsRepository repository = new RootfsRepositoryImpl(new RootfsRemoteDataSource(), catalog); GetRootfsSizeUseCase useCase = new GetRootfsSizeUseCase(repository); diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsCatalog.java b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsCatalog.java index 754c5f34..307afb40 100644 --- a/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsCatalog.java +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsCatalog.java @@ -1,59 +1,103 @@ package org.iiab.controller.rootfs.data; +import android.content.Context; import android.os.Build; +import android.util.Log; import org.iiab.controller.rootfs.domain.RootfsAbi; import org.iiab.controller.rootfs.domain.RootfsTier; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; /** - * Data-layer catalog: maps a tier+abi to its Deploy-server URL and provides the - * hardcoded fallback sizes. Single source of truth for both. + * Data-layer catalog: maps a tier+abi to its Deploy-server URL and provides the offline + * fallback size. * - *

Fallback sizes are in BYTES, captured from the {@code latest_*.meta4} - * Metalink pointers on 2026-06-17. They are only used when the live lookup fails - * (offline or server error). Update them whenever the published images change - * substantially. + *

The fallback size is read from {@code assets/rootfs_sizes.csv}, which the + * {@code refreshRootfsSizes} Gradle task regenerates from the {@code latest_*.meta4} pointers at + * package time — so the "last known" values are captured automatically, never hand-maintained. + * The hardcoded constants below remain ONLY as an emergency net (missing/corrupt CSV) so the + * lookup can never return 0. Values in bytes, captured 2026-06-17 (matches the seed CSV). */ public class RootfsCatalog { + private static final String TAG = "RootfsCatalog"; + private static final String CSV_ASSET = "rootfs_sizes.csv"; private static final String BASE_URL = "https://iiab.switnet.org/android/rootfs/"; - // arm64-v8a fallbacks + // Emergency-net fallbacks (used only if the CSV is missing/unreadable). private static final long FALLBACK_BASIC_ARM64 = 1_219_422_532L; // 1.14 GiB private static final long FALLBACK_STANDARD_ARM64 = 1_428_970_336L; // 1.33 GiB private static final long FALLBACK_FULL_ARM64 = 2_926_676_923L; // 2.73 GiB - - // armeabi-v7a fallbacks private static final long FALLBACK_BASIC_ARMV7 = 1_220_401_364L; // 1.14 GiB private static final long FALLBACK_STANDARD_ARMV7 = 1_429_892_132L; // 1.33 GiB private static final long FALLBACK_FULL_ARMV7 = 2_917_715_443L; // 2.72 GiB + /** Parsed CSV, loaded once per process. Empty map => use the emergency-net constants. */ + private static volatile Map csvSizes; + + /** No-arg: emergency-net only (no CSV). Kept for callers without a Context. */ + public RootfsCatalog() { } + + /** Context-aware: loads the packaged CSV so fallbackBytes() returns the last-known size. */ + public RootfsCatalog(Context context) { ensureCsvLoaded(context); } + + private static void ensureCsvLoaded(Context context) { + if (csvSizes != null || context == null) return; + synchronized (RootfsCatalog.class) { + if (csvSizes != null) return; + Map m = new HashMap<>(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(context.getAssets().open(CSV_ASSET)))) { + String line; + while ((line = r.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] p = line.split(","); + if (p.length >= 3) { + try { + m.put(key(p[0].trim(), p[1].trim()), Long.parseLong(p[2].trim())); + } catch (NumberFormatException ignore) { /* skip malformed row */ } + } + } + } catch (Exception e) { + Log.w(TAG, "rootfs_sizes.csv not read (" + e.getMessage() + "); using emergency-net sizes"); + } + csvSizes = m; + } + } + + private static String key(String tier, String abi) { + return tier.toLowerCase(Locale.US) + "|" + abi.toLowerCase(Locale.US); + } + /** Builds the stable Metalink URL, e.g. {@code .../latest_basic_arm64-v8a.meta4}. */ public String metaUrl(RootfsTier tier, RootfsAbi abi) { return BASE_URL + "latest_" + tier.name().toLowerCase(Locale.US) + "_" + abi.id() + ".meta4"; } - /** Known-good fallback size in bytes for a tier+abi. */ + /** Last-known fallback size in bytes for a tier+abi: CSV first, constants as the net. */ public long fallbackBytes(RootfsTier tier, RootfsAbi abi) { + Map csv = csvSizes; + if (csv != null) { + Long v = csv.get(key(tier.name(), abi.id())); + if (v != null && v > 0) return v; + } if (abi == RootfsAbi.ARMEABI_V7A) { switch (tier) { - case BASIC: - return FALLBACK_BASIC_ARMV7; - case STANDARD: - return FALLBACK_STANDARD_ARMV7; - case FULL: - return FALLBACK_FULL_ARMV7; + case BASIC: return FALLBACK_BASIC_ARMV7; + case STANDARD: return FALLBACK_STANDARD_ARMV7; + case FULL: return FALLBACK_FULL_ARMV7; } } else { switch (tier) { - case BASIC: - return FALLBACK_BASIC_ARM64; - case STANDARD: - return FALLBACK_STANDARD_ARM64; - case FULL: - return FALLBACK_FULL_ARM64; + case BASIC: return FALLBACK_BASIC_ARM64; + case STANDARD: return FALLBACK_STANDARD_ARM64; + case FULL: return FALLBACK_FULL_ARM64; } } return FALLBACK_BASIC_ARM64;