Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions controller/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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_<tier>_<abi>.meta4 <size> 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 = ~/<size>(\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 <size> 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
7 changes: 7 additions & 0 deletions controller/app/src/main/assets/rootfs_sizes.csv
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -280,8 +280,8 @@ public void onError(String error) {
* <p>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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
* <p>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<String, Long> 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<String, Long> 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<String, Long> 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;
Expand Down