From 9f7b61814295e3e3b47f697c7c463a69a6aad1fb Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Wed, 17 Jun 2026 22:57:24 -0600 Subject: [PATCH 1/2] refactor(controller): extract computeProjection + add osSizeGb overload Prepare InstallationPlanner so the projection UI can supply the OS rootfs size from the presentation layer. - Extract the projection math (maps + Kiwix) into a private computeProjection(). - Add a calculateProjectedSize(..., osSizeGb, ...) overload for the UI path. - Keep the legacy overload (resolveOsSizeGb) for the non-UI install flow, which only needs the resolved companion-data filename. --- .../iiab/controller/InstallationPlanner.java | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) 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 2068576..786e557 100644 --- a/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java +++ b/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java @@ -155,12 +155,33 @@ public static void getOrFetchCatalog(Context context, CacheListener listener) { }).start(); } + /** + * Projection-UI entry point. The OS rootfs size is resolved by the + * presentation layer (RootfsViewModel) and passed in here, so this path no + * longer touches the rootfs slice directly. Use this from screens that + * observe RootfsViewModel. + */ + public static void calculateProjectedSize(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, double osSizeGb, PlanResultListener listener) { + new Thread(() -> computeProjection(context, tier, pullCompanionData, langCode, overrideVariant, osSizeGb, listener)).start(); + } + + /** + * Legacy entry point that resolves the OS size internally through the layered + * slice. Retained for non-UI callers (e.g. the install flow, which only needs + * the resolved companion-data filename). New UI code should use the overload + * 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(() -> { - // Live OS size (with offline fallback), resolved through the layered slice. - // Safe to call synchronously here: calculateProjectedSize already runs - // on a background Thread. - double os = resolveOsSizeGb(tier); + new Thread(() -> computeProjection(context, tier, pullCompanionData, langCode, overrideVariant, resolveOsSizeGb(tier), listener)).start(); + } + + /** + * Shared projection math (maps + Kiwix companion data) for a given OS size. + * Must run off the main thread — it performs a cached network read for the + * Kiwix catalog. + */ + private static void computeProjection(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, double os, PlanResultListener listener) { + { double maps = 0.0; if (!pullCompanionData) { @@ -248,7 +269,7 @@ public void onError(String error) { listener.onError(error); } }); - }).start(); + } } /** From bd99c83bfb8ee7b86b284e563fe68c1179e4c119 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Wed, 17 Jun 2026 22:58:12 -0600 Subject: [PATCH 2/2] feat(controller): consume RootfsViewModel in projection + offline gating Continue the rootfs Clean Architecture slice (#6). The install storage projection now resolves the OS size through the presentation layer, and the install screen handles the offline state. The radial gauge is left untouched. - DeployFragment observes RootfsViewModel and feeds the resolved OS size (live, with offline fallback) into the projection instead of going through InstallationPlanner.resolveOsSizeGb(). - Offline: disable the install button ("No connection") with a snackbar on tap, show an "Estimated sizes (offline)" caption when the size is a fallback, and skip the live fetch via a new attemptLive flag on GetRootfsSizeUseCase / RootfsViewModel (avoids the ~6s timeout). Addresses the connectivity-gating tech-debt item. - Add strings (en + es), a unit test (skipsLiveWhenOffline), and update CLAUDE.md (design map + new "Working in parallel" section) and controller/docs/TECH_DEBT_PLAN.md. --- CLAUDE.md | 65 ++++++++++++-- .../org/iiab/controller/DeployFragment.java | 90 ++++++++++++++++++- .../rootfs/domain/GetRootfsSizeUseCase.java | 18 +++- .../rootfs/presentation/RootfsViewModel.java | 13 ++- .../src/main/res/layout/fragment_deploy.xml | 11 +++ .../app/src/main/res/values-es/strings.xml | 3 + .../app/src/main/res/values/strings.xml | 3 + .../domain/GetRootfsSizeUseCaseTest.java | 12 +++ controller/docs/TECH_DEBT_PLAN.md | 15 +++- 9 files changed, 211 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4efa391..a8f1ef6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,50 @@ incrementally: a global freeze. - Before migrating old code, confirm it is in scope for the current task. No unsolicited mass refactors. +- **Refactor-by-feature is the default:** every time we add or change a feature, + we land it in the layered structure *and* peel the legacy code it touches into + that structure in the same change. Feature work and refactoring advance together, + in step with the phases in `controller/docs/TECH_DEBT_PLAN.md` — never as a + separate "we'll clean it up later" task. + +--- + +## Working in parallel (coordinating features on one repo) + +We often have more than one feature in flight at once on this single repo. To keep +two layered migrations from colliding, follow these rules: + +- **One feature = one package = one branch/PR.** Each feature owns + `org.iiab.controller./{domain,data,presentation}`. Code inside a feature + package is private to that feature, so two features in different packages almost + never produce merge conflicts. Keep new code there, not in shared classes. +- **Know the conflict hotspots** — the files many features must touch: the legacy + god classes (`MainActivity`, `DeployFragment`), `InstallationPlanner`, anything + in `util/`, `build.gradle`, `AndroidManifest.xml`, and `res/values/strings.xml`. + Edits to these must be **additive and minimal**: add a new overload/method/string + rather than changing an existing signature; don't reformat or reorder surrounding + code; keep the diff as small as the change allows. +- **One migrator per legacy class at a time.** Two branches must not both carve up + the same god class in parallel. If feature B needs a class that feature A is + actively migrating, B coordinates with A or waits — use the brief module freeze + from the strangler policy, and record who is migrating what in the Design map + below (it doubles as the coordination ledger). +- **Shared contracts land first.** If two features need a common domain type or + port, define and merge that small interface on its own first, then both features + build against it. Don't duplicate it on two branches. +- **Per-feature resource files** to avoid `strings.xml` collisions: a feature may + add its own `res/values/strings_.xml` (Android merges all `` + files) instead of everyone editing the one shared `strings.xml`. Append, never + reorder existing keys. +- **Wire dependencies by hand, per feature.** Each feature has its own + `…ViewModelFactory` / small factory. There is no shared DI graph for everyone to + edit (introducing Hilt/Dagger is a separate ADR), so composition roots don't + become a contention point. +- **Integrate often.** Keep branches short-lived; rebase on `main` frequently and + merge small. `main` moves under you — small, frequent integration keeps conflicts + tiny. Don't sit on a long-lived branch that touches a hotspot. +- **No new shared mutable static state.** It couples features logically even when + the files don't conflict; prefer state encapsulated in a `ViewModel`. --- @@ -77,10 +121,15 @@ First feature built across all three layers; use it as the copy-paste template. building + hardcoded fallback bytes + ABI detection), `RootfsRepositoryImpl`. - `presentation/` — `RootfsViewModel` + `RootfsUiState` + `RootfsViewModelFactory`. - `util/ByteFormatter` — shared, pure byte→human formatting. -- **Legacy seam:** `InstallationPlanner.resolveOsSizeGb()` routes the OS size - through the use case (live-then-fallback) instead of the old hardcoded - `OS_*_GB` constants. Migrating `DeployFragment`'s projection UI to consume - `RootfsViewModel` directly is the next strangler step for this area. +- **Presentation wired (DONE):** `DeployFragment`'s projection UI now consumes + `RootfsViewModel` directly — it observes the use-case result (live-then-fallback) + and feeds the resolved OS size into the projection. `InstallationPlanner` + exposes a `calculateProjectedSize(..., osSizeGb, ...)` overload for this UI path, + so OS-size resolution lives in the presentation layer rather than the planner. +- **Legacy seam (retained):** `InstallationPlanner.resolveOsSizeGb()` still backs + the older `calculateProjectedSize(...)` overload used by the non-UI install flow + (which only needs the resolved companion-data filename). Removing it once the + install flow stops depending on it is a future strangler step. **Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat: god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable @@ -108,6 +157,8 @@ you are already in the file** (boy-scout), and record progress in the design map places. Route them through `util/ByteFormatter`. - **Thin tests:** only pure static logic is covered. Every migrated slice must add JVM unit tests for its domain/use-case layer (no emulator needed). -- **Connectivity gating:** live network calls on the projection path can stall - up to the timeout when offline; prefer an explicit connectivity check before - the live attempt as this path is migrated. +- **Connectivity gating (DONE for the rootfs path):** `DeployFragment` now keeps a + `hasInternet` flag (from `checkInternetAccess()`); when offline it skips the live + fetch (use-case `attemptLive=false`) to avoid the timeout stall, disables the + install button ("No connection"), and shows an "Estimated sizes (offline)" + caption. Apply the same pattern to other live-network paths as they migrate. diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 15019d5..25af525 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -39,6 +39,7 @@ import androidx.core.app.RemoteInput; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; import com.github.mikephil.charting.components.XAxis; import com.github.mikephil.charting.components.YAxis; @@ -48,6 +49,13 @@ import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; import com.google.android.material.snackbar.Snackbar; +import org.iiab.controller.rootfs.domain.RootfsAbi; +import org.iiab.controller.rootfs.domain.RootfsTier; +import org.iiab.controller.rootfs.presentation.RootfsUiState; +import org.iiab.controller.rootfs.presentation.RootfsViewModel; +import org.iiab.controller.rootfs.presentation.RootfsViewModelFactory; +import org.iiab.controller.util.ByteFormatter; + import org.json.JSONObject; import java.io.BufferedReader; @@ -92,6 +100,7 @@ public class DeployFragment extends Fragment { // Planner UI private Button btnTierBasic, btnTierStandard, btnTierFull; private TextView txtLegendIiab, txtLegendMaps, txtLegendKiwix, txtLegendFree; + private TextView txtOfflineEstimate; private CheckBox chkCompanionData; private MultiResourceGaugeView storageGauge; private android.widget.ImageButton btnKiwixSettings; @@ -112,6 +121,10 @@ public class DeployFragment extends Fragment { private String overrideKiwixLang = null; private String overrideKiwixVariant = null; private InstallationPlanner.Tier selectedTier = null; + // Presentation-layer source of the OS rootfs size (live, with offline fallback). + private RootfsViewModel rootfsViewModel; + // Last known connectivity, refreshed by checkInternetAccess() (every 3s via liveStatusRunnable). + private volatile boolean hasInternet = true; // Native Engine Variables private static Aria2Manager aria2Manager; @@ -277,6 +290,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat txtLegendMaps = view.findViewById(R.id.txt_legend_maps); txtLegendKiwix = view.findViewById(R.id.txt_legend_kiwix); txtLegendFree = view.findViewById(R.id.txt_legend_free); + txtOfflineEstimate = view.findViewById(R.id.txt_offline_estimate); // SAF Binding btnImportBackup = view.findViewById(R.id.btn_import_backup); @@ -631,7 +645,7 @@ else if (isServerRunning) } else { // FREE MODE: All off and ready to operate - if (selectedTier == null || !isStorageSafe) btnFastInstall.setAlpha(0.4f); + if (!hasInternet || selectedTier == null || !isStorageSafe) btnFastInstall.setAlpha(0.4f); else btnFastInstall.setAlpha(1.0f); btnFastDelete.setAlpha(1.0f); @@ -642,7 +656,13 @@ else if (isServerRunning) btnFastInstall.setEnabled(true); btnFastInstall.setTextSize(14f); - btnFastInstall.setText(isProotInstalled ? R.string.install_btn_reinstall : R.string.install_btn_install); + if (!hasInternet) { + // Offline: downloading is impossible. Signal it on the button itself; + // the click listener shows a snackbar instead of starting a failing download. + btnFastInstall.setText(R.string.install_btn_no_connection); + } else { + btnFastInstall.setText(isProotInstalled ? R.string.install_btn_reinstall : R.string.install_btn_install); + } // Unlock checkboxes for (CheckBox cb : newInstallCheckboxes) { @@ -660,6 +680,13 @@ else if (isServerRunning) // ========================================================================================= private void setupPlannerListeners() { + // Presentation layer: the projection UI consumes the OS rootfs size from + // RootfsViewModel (live, with offline fallback) instead of having + // InstallationPlanner resolve it. The observer completes each projection + // once the size is resolved. + rootfsViewModel = new ViewModelProvider(this, new RootfsViewModelFactory()).get(RootfsViewModel.class); + rootfsViewModel.state().observe(getViewLifecycleOwner(), this::onRootfsSizeResolved); + btnTierBasic.setAlpha(0.5f); btnTierBasic.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor("#008000"))); btnTierStandard.setAlpha(0.5f); @@ -699,11 +726,41 @@ else if (v.getId() == R.id.btn_tier_standard) } private void recalculateProjection() { + InstallationPlanner.Tier evalTier = (selectedTier != null) ? selectedTier : InstallationPlanner.Tier.BASIC; + // Ask the presentation layer for the OS rootfs size. onRootfsSizeResolved() + // (registered as an observer in setupPlannerListeners) reacts and finishes + // the projection with the resolved size. + if (rootfsViewModel != null) { + // When we already know we're offline, skip the live fetch (avoids the ~6s + // network timeout) and go straight to the hardcoded fallback size. + rootfsViewModel.load(toRootfsTier(evalTier), detectRootfsAbi(), hasInternet); + } + } + + /** + * Completes the storage projection once {@link RootfsViewModel} resolves the OS + * size (live, or the offline fallback). The UI now consumes the size from the + * presentation layer instead of having {@link InstallationPlanner} resolve it. + */ + private void onRootfsSizeResolved(RootfsUiState rootfsState) { + if (!isAdded() || rootfsState == null) return; + if (rootfsState.status == RootfsUiState.Status.LOADING) return; + + final double osGiB = (rootfsState.rootfs != null) + ? ByteFormatter.toGiB(rootfsState.rootfs.sizeBytes()) + : 0.0; + + // Show the "estimated (offline)" caption whenever the size is a fallback + // (no live value), so the user knows the projection isn't server-confirmed. + if (txtOfflineEstimate != null) { + txtOfflineEstimate.setVisibility(rootfsState.live ? View.GONE : View.VISIBLE); + } + android.content.SharedPreferences prefs = requireContext().getSharedPreferences(getString(R.string.pref_file_internal), Context.MODE_PRIVATE); String targetLang = (overrideKiwixLang != null) ? overrideKiwixLang : prefs.getString("selected_lang_minimal", "en"); InstallationPlanner.Tier evalTier = (selectedTier != null) ? selectedTier : InstallationPlanner.Tier.BASIC; - InstallationPlanner.calculateProjectedSize(requireContext(), evalTier, chkCompanionData.isChecked(), targetLang, overrideKiwixVariant, new InstallationPlanner.PlanResultListener() { + InstallationPlanner.calculateProjectedSize(requireContext(), evalTier, chkCompanionData.isChecked(), targetLang, overrideKiwixVariant, osGiB, new InstallationPlanner.PlanResultListener() { @Override public void onCalculated(InstallationPlanner.StorageProjection projection) { if (!isAdded()) return; @@ -935,6 +992,13 @@ private void bindInstallButtonLogic(MainActivity mainAct, File debianRootfs, Fil return; } + // 1b. No internet: a fresh install requires downloading the rootfs. Block it + // up front (but still allow cancelling an in-progress download below). + if (!hasInternet && !isDownloadingRootfs) { + Snackbar.make(v, R.string.install_msg_no_connection, Snackbar.LENGTH_LONG).show(); + return; + } + // 2. HIGH PRIORITY: If this button is working, we allow cancel if (isDownloadingRootfs) { new android.app.AlertDialog.Builder(requireContext()) @@ -2661,6 +2725,25 @@ private String getTermuxArch() { return "unknown"; } + /** Maps the legacy planner tier to the domain {@link RootfsTier}. */ + private RootfsTier toRootfsTier(InstallationPlanner.Tier tier) { + switch (tier) { + case STANDARD: + return RootfsTier.STANDARD; + case FULL: + return RootfsTier.FULL; + case BASIC: + default: + return RootfsTier.BASIC; + } + } + + /** Detects the device ABI for rootfs selection, reusing {@link #getTermuxArch()}. */ + private RootfsAbi detectRootfsAbi() { + String arch = getTermuxArch(); + return (arch != null && arch.contains("64")) ? RootfsAbi.ARM64_V8A : RootfsAbi.ARMEABI_V7A; + } + public void openTermuxAppInfo() { try { android.content.Intent intent = new android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); @@ -2686,6 +2769,7 @@ private void checkInternetAccess() { hasInternet = false; } final boolean isOnline = hasInternet; + DeployFragment.this.hasInternet = isOnline; if (isAdded() && getActivity() != null) { getActivity().runOnUiThread(() -> { if (ledInternet != null) { diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCase.java b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCase.java index 99086af..2d615b7 100644 --- a/controller/app/src/main/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCase.java +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCase.java @@ -29,9 +29,21 @@ public GetRootfsSizeUseCase(RootfsRepository repository) { * Never returns {@code null}. */ public Rootfs execute(RootfsTier tier, RootfsAbi abi) { - Rootfs live = repository.fetchLive(tier, abi); - if (live != null && live.isLive() && isPlausible(live.sizeBytes())) { - return live; + return execute(tier, abi, true); + } + + /** + * Resolves the size for a tier+abi. When {@code attemptLive} is {@code false} + * (e.g. the device is known to be offline), the live lookup is skipped and the + * fallback is returned directly — avoiding a pointless network timeout. + * Never returns {@code null}. + */ + public Rootfs execute(RootfsTier tier, RootfsAbi abi, boolean attemptLive) { + if (attemptLive) { + Rootfs live = repository.fetchLive(tier, abi); + if (live != null && live.isLive() && isPlausible(live.sizeBytes())) { + return live; + } } return repository.fallback(tier, abi); } diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModel.java b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModel.java index e8c1b41..cbda3f9 100644 --- a/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModel.java +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModel.java @@ -38,12 +38,21 @@ public LiveData state() { return state; } - /** Loads the size for a tier+abi; posts LOADING then SUCCESS/ERROR. */ + /** Loads the size for a tier+abi (attempting the live fetch first). */ public void load(RootfsTier tier, RootfsAbi abi) { + load(tier, abi, true); + } + + /** + * Loads the size for a tier+abi; posts LOADING then SUCCESS/ERROR. When + * {@code attemptLive} is {@code false} (device offline) the live fetch is + * skipped and the hardcoded fallback is used directly. + */ + public void load(RootfsTier tier, RootfsAbi abi, boolean attemptLive) { state.postValue(RootfsUiState.loading()); executor.execute(() -> { try { - Rootfs rootfs = getRootfsSize.execute(tier, abi); + Rootfs rootfs = getRootfsSize.execute(tier, abi, attemptLive); state.postValue(RootfsUiState.success(rootfs, ByteFormatter.toHuman(rootfs.sizeBytes()))); } catch (Exception e) { state.postValue(RootfsUiState.error(e.getMessage())); diff --git a/controller/app/src/main/res/layout/fragment_deploy.xml b/controller/app/src/main/res/layout/fragment_deploy.xml index 0338c01..5cd99bb 100644 --- a/controller/app/src/main/res/layout/fragment_deploy.xml +++ b/controller/app/src/main/res/layout/fragment_deploy.xml @@ -485,6 +485,17 @@ + + Instalar Reinstalar + Sin conexión + Sin conexión a internet — conéctate para descargar. + Tamaños estimados (sin conexión) Por favor, instale primero el sistema base Debian. Eliminar Iniciar instalación diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index 414019b..6cbe95d 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -225,6 +225,9 @@ Install Reinstall + No connection + No internet connection — connect to download. + Estimated sizes (offline) Please install the base Debian system first. Delete Launch installation diff --git a/controller/app/src/test/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCaseTest.java b/controller/app/src/test/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCaseTest.java index 92825c7..f19336c 100644 --- a/controller/app/src/test/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCaseTest.java +++ b/controller/app/src/test/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCaseTest.java @@ -19,12 +19,14 @@ public class GetRootfsSizeUseCaseTest { private static final class FakeRepository implements RootfsRepository { private final long liveBytes; // <= 0 means "unavailable" private final boolean liveFlag; + boolean fetchLiveCalled = false; FakeRepository(long liveBytes, boolean liveFlag) { this.liveBytes = liveBytes; this.liveFlag = liveFlag; } @Override public Rootfs fetchLive(RootfsTier tier, RootfsAbi abi) { + fetchLiveCalled = true; return new Rootfs(tier, abi, "url", liveBytes, liveFlag); } @Override @@ -45,6 +47,16 @@ public void usesLiveWhenPlausible() { assertTrue(r.isLive()); } + @Test + public void skipsLiveWhenOffline() { + FakeRepository repo = new FakeRepository(LIVE_OK, true); // live would be valid... + Rootfs r = new GetRootfsSizeUseCase(repo) + .execute(RootfsTier.STANDARD, RootfsAbi.ARM64_V8A, false); // ...but we're offline + assertEquals(FALLBACK, r.sizeBytes()); + assertFalse(r.isLive()); + assertFalse("live fetch must be skipped when offline", repo.fetchLiveCalled); + } + @Test public void fallsBackWhenLiveUnavailable() { Rootfs r = run(new FakeRepository(-1, false)); diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md index 3c5b1a3..c23b2e8 100644 --- a/controller/docs/TECH_DEBT_PLAN.md +++ b/controller/docs/TECH_DEBT_PLAN.md @@ -4,21 +4,28 @@ ## Progress log -_Last updated: 2026-06-16. Tracks remediation work against the findings below. IDs map to the register in this file (F/D/S/M) and to `FORK_DELTA_ANALYSIS.md` (K)._ +_Last updated: 2026-06-17. Tracks remediation work against the findings below. IDs map to the register in this file (F/D/S/M) and to `FORK_DELTA_ANALYSIS.md` (K)._ -**Phase 0 — Guardrails: DONE** (PR `chore/phase0-guardrails`) +**Phase 0 — Guardrails: DONE** (PR `chore/phase0-guardrails`, merged as #4) - Extracted `SystemStatsUtil` and added the first JVM unit tests (`SystemStatsUtilTest`, `SyncHandshakeHelperTest`); added unit-test infra (`returnDefaultValues` + real `org.json`). Addresses **M10**. - CI gate: blocking `testDebugUnitTest`; `:app:lintDebug` runs non-blocking (scoped to `:app`). Addresses **M11**. - Added a root `.gitignore` and `FORK_DELTA_ANALYSIS.md`. - Remaining: flip lint `abortOnError=true` once the `:app` lint backlog is triaged; broaden tests to more pure functions (`LogManager.getFormattedSize`, `InstallationPlanner` sizing, the YAML parser). -**K1 — Fork delta (Termux ExtraKeys): IN PROGRESS** (PR `feat/k1-extrakeys-in-app`; details in `FORK_DELTA_ANALYSIS.md`) -- **K1**: `loadIIABDefaultKeys()` moved out of upstream `ExtraKeysView` into app `IIABExtraKeys` (public APIs only). DONE (app side). +**K1 — Fork delta (Termux ExtraKeys): DONE** (PR `feat/k1-extrakeys-in-app`, merged as #5; details in `FORK_DELTA_ANALYSIS.md`) +- **K1**: `loadIIABDefaultKeys()` moved out of upstream `ExtraKeysView` into app `IIABExtraKeys` (public APIs only). DONE. - **K3**: layout is now a single-source-of-truth constant. DONE. - **K4**: falls back to a minimal layout if the default fails to load. DONE. - **K5**: unit test validating the layout grid (`IIABExtraKeysTest`). DONE. - Remaining: point the submodule to `appdevforall/termux-app` at clean upstream and commit the pointer. +**Reference slice — Rootfs live sizes (Clean Architecture pilot): DONE** (PR #6, merged) +- First feature built across all three layers (`org.iiab.controller.rootfs.{domain,data,presentation}` + `util/ByteFormatter`); serves as the copy-paste template for future slices. See root `CLAUDE.md` (design map) and `ROOTFS_SIZE_PILOT_ANALYSIS.md`. +- Replaced the hardcoded `OS_*_GB` constants with live sizes from the Deploy server (`latest_*.meta4`), with per-ABI byte fallbacks for offline/error. Domain is pure JVM and unit-tested (`GetRootfsSizeUseCaseTest`, `ByteFormatterTest`). +- **Strangler step (DONE):** `DeployFragment`'s projection UI now consumes `RootfsViewModel` directly (observes live/fallback OS size) instead of going through `InstallationPlanner.resolveOsSizeGb()`. `InstallationPlanner.calculateProjectedSize` gained a `osSizeGb` overload so the OS-size resolution leaves the planner; the legacy overload (still resolving internally) is retained only for the non-UI install flow. +- **Offline UX (DONE):** addresses the "connectivity gating" watch item. `checkInternetAccess()` now stores a `hasInternet` flag; `updateDynamicButtons()` disables the install button (label "No connection") and the click listener shows a snackbar instead of starting a doomed download; an "Estimated sizes (offline)" caption shows whenever the size is a fallback (`RootfsUiState.live == false`); and offline we skip the live fetch (new `attemptLive` flag on the use case / ViewModel) to avoid the ~6 s timeout. The gauge itself was intentionally left untouched. +- Remaining (optional): later remove the legacy `resolveOsSizeGb` path once the install flow no longer needs it. + **Phases 1–4: NOT STARTED.** Next: Phase 1 security — **D2**, **D6**, **S1**, **S3**, **M4**, **D12**. ## 1. Executive summary