Skip to content

Commit 2546d5a

Browse files
Merge pull request #7 from appdevforall/feat/deploy-projection-rootfs-viewmodel
feat(controller): rootfs projection via RootfsViewModel + offline gating
2 parents da89959 + bd99c83 commit 2546d5a

10 files changed

Lines changed: 238 additions & 25 deletions

File tree

‎CLAUDE.md‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,50 @@ incrementally:
5858
a global freeze.
5959
- Before migrating old code, confirm it is in scope for the current task. No
6060
unsolicited mass refactors.
61+
- **Refactor-by-feature is the default:** every time we add or change a feature,
62+
we land it in the layered structure *and* peel the legacy code it touches into
63+
that structure in the same change. Feature work and refactoring advance together,
64+
in step with the phases in `controller/docs/TECH_DEBT_PLAN.md` — never as a
65+
separate "we'll clean it up later" task.
66+
67+
---
68+
69+
## Working in parallel (coordinating features on one repo)
70+
71+
We often have more than one feature in flight at once on this single repo. To keep
72+
two layered migrations from colliding, follow these rules:
73+
74+
- **One feature = one package = one branch/PR.** Each feature owns
75+
`org.iiab.controller.<feature>/{domain,data,presentation}`. Code inside a feature
76+
package is private to that feature, so two features in different packages almost
77+
never produce merge conflicts. Keep new code there, not in shared classes.
78+
- **Know the conflict hotspots** — the files many features must touch: the legacy
79+
god classes (`MainActivity`, `DeployFragment`), `InstallationPlanner`, anything
80+
in `util/`, `build.gradle`, `AndroidManifest.xml`, and `res/values/strings.xml`.
81+
Edits to these must be **additive and minimal**: add a new overload/method/string
82+
rather than changing an existing signature; don't reformat or reorder surrounding
83+
code; keep the diff as small as the change allows.
84+
- **One migrator per legacy class at a time.** Two branches must not both carve up
85+
the same god class in parallel. If feature B needs a class that feature A is
86+
actively migrating, B coordinates with A or waits — use the brief module freeze
87+
from the strangler policy, and record who is migrating what in the Design map
88+
below (it doubles as the coordination ledger).
89+
- **Shared contracts land first.** If two features need a common domain type or
90+
port, define and merge that small interface on its own first, then both features
91+
build against it. Don't duplicate it on two branches.
92+
- **Per-feature resource files** to avoid `strings.xml` collisions: a feature may
93+
add its own `res/values/strings_<feature>.xml` (Android merges all `<resources>`
94+
files) instead of everyone editing the one shared `strings.xml`. Append, never
95+
reorder existing keys.
96+
- **Wire dependencies by hand, per feature.** Each feature has its own
97+
`…ViewModelFactory` / small factory. There is no shared DI graph for everyone to
98+
edit (introducing Hilt/Dagger is a separate ADR), so composition roots don't
99+
become a contention point.
100+
- **Integrate often.** Keep branches short-lived; rebase on `main` frequently and
101+
merge small. `main` moves under you — small, frequent integration keeps conflicts
102+
tiny. Don't sit on a long-lived branch that touches a hotspot.
103+
- **No new shared mutable static state.** It couples features logically even when
104+
the files don't conflict; prefer state encapsulated in a `ViewModel`.
61105

62106
---
63107

@@ -77,10 +121,15 @@ First feature built across all three layers; use it as the copy-paste template.
77121
building + hardcoded fallback bytes + ABI detection), `RootfsRepositoryImpl`.
78122
- `presentation/` — `RootfsViewModel` + `RootfsUiState` + `RootfsViewModelFactory`.
79123
- `util/ByteFormatter` — shared, pure byte→human formatting.
80-
- **Legacy seam:** `InstallationPlanner.resolveOsSizeGb()` routes the OS size
81-
through the use case (live-then-fallback) instead of the old hardcoded
82-
`OS_*_GB` constants. Migrating `DeployFragment`'s projection UI to consume
83-
`RootfsViewModel` directly is the next strangler step for this area.
124+
- **Presentation wired (DONE):** `DeployFragment`'s projection UI now consumes
125+
`RootfsViewModel` directly — it observes the use-case result (live-then-fallback)
126+
and feeds the resolved OS size into the projection. `InstallationPlanner`
127+
exposes a `calculateProjectedSize(..., osSizeGb, ...)` overload for this UI path,
128+
so OS-size resolution lives in the presentation layer rather than the planner.
129+
- **Legacy seam (retained):** `InstallationPlanner.resolveOsSizeGb()` still backs
130+
the older `calculateProjectedSize(...)` overload used by the non-UI install flow
131+
(which only needs the resolved companion-data filename). Removing it once the
132+
install flow stops depending on it is a future strangler step.
84133

85134
**Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat:
86135
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
108157
places. Route them through `util/ByteFormatter`.
109158
- **Thin tests:** only pure static logic is covered. Every migrated slice must
110159
add JVM unit tests for its domain/use-case layer (no emulator needed).
111-
- **Connectivity gating:** live network calls on the projection path can stall
112-
up to the timeout when offline; prefer an explicit connectivity check before
113-
the live attempt as this path is migrated.
160+
- **Connectivity gating (DONE for the rootfs path):** `DeployFragment` now keeps a
161+
`hasInternet` flag (from `checkInternetAccess()`); when offline it skips the live
162+
fetch (use-case `attemptLive=false`) to avoid the timeout stall, disables the
163+
install button ("No connection"), and shows an "Estimated sizes (offline)"
164+
caption. Apply the same pattern to other live-network paths as they migrate.

‎controller/app/src/main/java/org/iiab/controller/DeployFragment.java‎

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import androidx.core.app.RemoteInput;
4040
import androidx.core.content.ContextCompat;
4141
import androidx.fragment.app.Fragment;
42+
import androidx.lifecycle.ViewModelProvider;
4243

4344
import com.github.mikephil.charting.components.XAxis;
4445
import com.github.mikephil.charting.components.YAxis;
@@ -48,6 +49,13 @@
4849
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
4950
import com.google.android.material.snackbar.Snackbar;
5051

52+
import org.iiab.controller.rootfs.domain.RootfsAbi;
53+
import org.iiab.controller.rootfs.domain.RootfsTier;
54+
import org.iiab.controller.rootfs.presentation.RootfsUiState;
55+
import org.iiab.controller.rootfs.presentation.RootfsViewModel;
56+
import org.iiab.controller.rootfs.presentation.RootfsViewModelFactory;
57+
import org.iiab.controller.util.ByteFormatter;
58+
5159
import org.json.JSONObject;
5260

5361
import java.io.BufferedReader;
@@ -92,6 +100,7 @@ public class DeployFragment extends Fragment {
92100
// Planner UI
93101
private Button btnTierBasic, btnTierStandard, btnTierFull;
94102
private TextView txtLegendIiab, txtLegendMaps, txtLegendKiwix, txtLegendFree;
103+
private TextView txtOfflineEstimate;
95104
private CheckBox chkCompanionData;
96105
private MultiResourceGaugeView storageGauge;
97106
private android.widget.ImageButton btnKiwixSettings;
@@ -112,6 +121,10 @@ public class DeployFragment extends Fragment {
112121
private String overrideKiwixLang = null;
113122
private String overrideKiwixVariant = null;
114123
private InstallationPlanner.Tier selectedTier = null;
124+
// Presentation-layer source of the OS rootfs size (live, with offline fallback).
125+
private RootfsViewModel rootfsViewModel;
126+
// Last known connectivity, refreshed by checkInternetAccess() (every 3s via liveStatusRunnable).
127+
private volatile boolean hasInternet = true;
115128

116129
// Native Engine Variables
117130
private static Aria2Manager aria2Manager;
@@ -277,6 +290,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
277290
txtLegendMaps = view.findViewById(R.id.txt_legend_maps);
278291
txtLegendKiwix = view.findViewById(R.id.txt_legend_kiwix);
279292
txtLegendFree = view.findViewById(R.id.txt_legend_free);
293+
txtOfflineEstimate = view.findViewById(R.id.txt_offline_estimate);
280294

281295
// SAF Binding
282296
btnImportBackup = view.findViewById(R.id.btn_import_backup);
@@ -631,7 +645,7 @@ else if (isServerRunning)
631645

632646
} else {
633647
// FREE MODE: All off and ready to operate
634-
if (selectedTier == null || !isStorageSafe) btnFastInstall.setAlpha(0.4f);
648+
if (!hasInternet || selectedTier == null || !isStorageSafe) btnFastInstall.setAlpha(0.4f);
635649
else btnFastInstall.setAlpha(1.0f);
636650

637651
btnFastDelete.setAlpha(1.0f);
@@ -642,7 +656,13 @@ else if (isServerRunning)
642656

643657
btnFastInstall.setEnabled(true);
644658
btnFastInstall.setTextSize(14f);
645-
btnFastInstall.setText(isProotInstalled ? R.string.install_btn_reinstall : R.string.install_btn_install);
659+
if (!hasInternet) {
660+
// Offline: downloading is impossible. Signal it on the button itself;
661+
// the click listener shows a snackbar instead of starting a failing download.
662+
btnFastInstall.setText(R.string.install_btn_no_connection);
663+
} else {
664+
btnFastInstall.setText(isProotInstalled ? R.string.install_btn_reinstall : R.string.install_btn_install);
665+
}
646666

647667
// Unlock checkboxes
648668
for (CheckBox cb : newInstallCheckboxes) {
@@ -660,6 +680,13 @@ else if (isServerRunning)
660680
// =========================================================================================
661681

662682
private void setupPlannerListeners() {
683+
// Presentation layer: the projection UI consumes the OS rootfs size from
684+
// RootfsViewModel (live, with offline fallback) instead of having
685+
// InstallationPlanner resolve it. The observer completes each projection
686+
// once the size is resolved.
687+
rootfsViewModel = new ViewModelProvider(this, new RootfsViewModelFactory()).get(RootfsViewModel.class);
688+
rootfsViewModel.state().observe(getViewLifecycleOwner(), this::onRootfsSizeResolved);
689+
663690
btnTierBasic.setAlpha(0.5f);
664691
btnTierBasic.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor("#008000")));
665692
btnTierStandard.setAlpha(0.5f);
@@ -699,11 +726,41 @@ else if (v.getId() == R.id.btn_tier_standard)
699726
}
700727

701728
private void recalculateProjection() {
729+
InstallationPlanner.Tier evalTier = (selectedTier != null) ? selectedTier : InstallationPlanner.Tier.BASIC;
730+
// Ask the presentation layer for the OS rootfs size. onRootfsSizeResolved()
731+
// (registered as an observer in setupPlannerListeners) reacts and finishes
732+
// the projection with the resolved size.
733+
if (rootfsViewModel != null) {
734+
// When we already know we're offline, skip the live fetch (avoids the ~6s
735+
// network timeout) and go straight to the hardcoded fallback size.
736+
rootfsViewModel.load(toRootfsTier(evalTier), detectRootfsAbi(), hasInternet);
737+
}
738+
}
739+
740+
/**
741+
* Completes the storage projection once {@link RootfsViewModel} resolves the OS
742+
* size (live, or the offline fallback). The UI now consumes the size from the
743+
* presentation layer instead of having {@link InstallationPlanner} resolve it.
744+
*/
745+
private void onRootfsSizeResolved(RootfsUiState rootfsState) {
746+
if (!isAdded() || rootfsState == null) return;
747+
if (rootfsState.status == RootfsUiState.Status.LOADING) return;
748+
749+
final double osGiB = (rootfsState.rootfs != null)
750+
? ByteFormatter.toGiB(rootfsState.rootfs.sizeBytes())
751+
: 0.0;
752+
753+
// Show the "estimated (offline)" caption whenever the size is a fallback
754+
// (no live value), so the user knows the projection isn't server-confirmed.
755+
if (txtOfflineEstimate != null) {
756+
txtOfflineEstimate.setVisibility(rootfsState.live ? View.GONE : View.VISIBLE);
757+
}
758+
702759
android.content.SharedPreferences prefs = requireContext().getSharedPreferences(getString(R.string.pref_file_internal), Context.MODE_PRIVATE);
703760
String targetLang = (overrideKiwixLang != null) ? overrideKiwixLang : prefs.getString("selected_lang_minimal", "en");
704761
InstallationPlanner.Tier evalTier = (selectedTier != null) ? selectedTier : InstallationPlanner.Tier.BASIC;
705762

706-
InstallationPlanner.calculateProjectedSize(requireContext(), evalTier, chkCompanionData.isChecked(), targetLang, overrideKiwixVariant, new InstallationPlanner.PlanResultListener() {
763+
InstallationPlanner.calculateProjectedSize(requireContext(), evalTier, chkCompanionData.isChecked(), targetLang, overrideKiwixVariant, osGiB, new InstallationPlanner.PlanResultListener() {
707764
@Override
708765
public void onCalculated(InstallationPlanner.StorageProjection projection) {
709766
if (!isAdded()) return;
@@ -935,6 +992,13 @@ private void bindInstallButtonLogic(MainActivity mainAct, File debianRootfs, Fil
935992
return;
936993
}
937994

995+
// 1b. No internet: a fresh install requires downloading the rootfs. Block it
996+
// up front (but still allow cancelling an in-progress download below).
997+
if (!hasInternet && !isDownloadingRootfs) {
998+
Snackbar.make(v, R.string.install_msg_no_connection, Snackbar.LENGTH_LONG).show();
999+
return;
1000+
}
1001+
9381002
// 2. HIGH PRIORITY: If this button is working, we allow cancel
9391003
if (isDownloadingRootfs) {
9401004
new android.app.AlertDialog.Builder(requireContext())
@@ -2661,6 +2725,25 @@ private String getTermuxArch() {
26612725
return "unknown";
26622726
}
26632727

2728+
/** Maps the legacy planner tier to the domain {@link RootfsTier}. */
2729+
private RootfsTier toRootfsTier(InstallationPlanner.Tier tier) {
2730+
switch (tier) {
2731+
case STANDARD:
2732+
return RootfsTier.STANDARD;
2733+
case FULL:
2734+
return RootfsTier.FULL;
2735+
case BASIC:
2736+
default:
2737+
return RootfsTier.BASIC;
2738+
}
2739+
}
2740+
2741+
/** Detects the device ABI for rootfs selection, reusing {@link #getTermuxArch()}. */
2742+
private RootfsAbi detectRootfsAbi() {
2743+
String arch = getTermuxArch();
2744+
return (arch != null && arch.contains("64")) ? RootfsAbi.ARM64_V8A : RootfsAbi.ARMEABI_V7A;
2745+
}
2746+
26642747
public void openTermuxAppInfo() {
26652748
try {
26662749
android.content.Intent intent = new android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
@@ -2686,6 +2769,7 @@ private void checkInternetAccess() {
26862769
hasInternet = false;
26872770
}
26882771
final boolean isOnline = hasInternet;
2772+
DeployFragment.this.hasInternet = isOnline;
26892773
if (isAdded() && getActivity() != null) {
26902774
getActivity().runOnUiThread(() -> {
26912775
if (ledInternet != null) {

‎controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,33 @@ public static void getOrFetchCatalog(Context context, CacheListener listener) {
155155
}).start();
156156
}
157157

158+
/**
159+
* Projection-UI entry point. The OS rootfs size is resolved by the
160+
* presentation layer (RootfsViewModel) and passed in here, so this path no
161+
* longer touches the rootfs slice directly. Use this from screens that
162+
* observe RootfsViewModel.
163+
*/
164+
public static void calculateProjectedSize(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, double osSizeGb, PlanResultListener listener) {
165+
new Thread(() -> computeProjection(context, tier, pullCompanionData, langCode, overrideVariant, osSizeGb, listener)).start();
166+
}
167+
168+
/**
169+
* Legacy entry point that resolves the OS size internally through the layered
170+
* slice. Retained for non-UI callers (e.g. the install flow, which only needs
171+
* the resolved companion-data filename). New UI code should use the overload
172+
* that accepts a pre-resolved {@code osSizeGb} from RootfsViewModel.
173+
*/
158174
public static void calculateProjectedSize(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, PlanResultListener listener) {
159-
new Thread(() -> {
160-
// Live OS size (with offline fallback), resolved through the layered slice.
161-
// Safe to call synchronously here: calculateProjectedSize already runs
162-
// on a background Thread.
163-
double os = resolveOsSizeGb(tier);
175+
new Thread(() -> computeProjection(context, tier, pullCompanionData, langCode, overrideVariant, resolveOsSizeGb(tier), listener)).start();
176+
}
177+
178+
/**
179+
* Shared projection math (maps + Kiwix companion data) for a given OS size.
180+
* Must run off the main thread — it performs a cached network read for the
181+
* Kiwix catalog.
182+
*/
183+
private static void computeProjection(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, double os, PlanResultListener listener) {
184+
{
164185
double maps = 0.0;
165186

166187
if (!pullCompanionData) {
@@ -248,7 +269,7 @@ public void onError(String error) {
248269
listener.onError(error);
249270
}
250271
});
251-
}).start();
272+
}
252273
}
253274

254275
/**

‎controller/app/src/main/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCase.java‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,21 @@ public GetRootfsSizeUseCase(RootfsRepository repository) {
2929
* Never returns {@code null}.
3030
*/
3131
public Rootfs execute(RootfsTier tier, RootfsAbi abi) {
32-
Rootfs live = repository.fetchLive(tier, abi);
33-
if (live != null && live.isLive() && isPlausible(live.sizeBytes())) {
34-
return live;
32+
return execute(tier, abi, true);
33+
}
34+
35+
/**
36+
* Resolves the size for a tier+abi. When {@code attemptLive} is {@code false}
37+
* (e.g. the device is known to be offline), the live lookup is skipped and the
38+
* fallback is returned directly — avoiding a pointless network timeout.
39+
* Never returns {@code null}.
40+
*/
41+
public Rootfs execute(RootfsTier tier, RootfsAbi abi, boolean attemptLive) {
42+
if (attemptLive) {
43+
Rootfs live = repository.fetchLive(tier, abi);
44+
if (live != null && live.isLive() && isPlausible(live.sizeBytes())) {
45+
return live;
46+
}
3547
}
3648
return repository.fallback(tier, abi);
3749
}

‎controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModel.java‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,21 @@ public LiveData<RootfsUiState> state() {
3838
return state;
3939
}
4040

41-
/** Loads the size for a tier+abi; posts LOADING then SUCCESS/ERROR. */
41+
/** Loads the size for a tier+abi (attempting the live fetch first). */
4242
public void load(RootfsTier tier, RootfsAbi abi) {
43+
load(tier, abi, true);
44+
}
45+
46+
/**
47+
* Loads the size for a tier+abi; posts LOADING then SUCCESS/ERROR. When
48+
* {@code attemptLive} is {@code false} (device offline) the live fetch is
49+
* skipped and the hardcoded fallback is used directly.
50+
*/
51+
public void load(RootfsTier tier, RootfsAbi abi, boolean attemptLive) {
4352
state.postValue(RootfsUiState.loading());
4453
executor.execute(() -> {
4554
try {
46-
Rootfs rootfs = getRootfsSize.execute(tier, abi);
55+
Rootfs rootfs = getRootfsSize.execute(tier, abi, attemptLive);
4756
state.postValue(RootfsUiState.success(rootfs, ByteFormatter.toHuman(rootfs.sizeBytes())));
4857
} catch (Exception e) {
4958
state.postValue(RootfsUiState.error(e.getMessage()));

‎controller/app/src/main/res/layout/fragment_deploy.xml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,17 @@
485485
</LinearLayout>
486486
</LinearLayout>
487487

488+
<TextView
489+
android:id="@+id/txt_offline_estimate"
490+
android:layout_width="match_parent"
491+
android:layout_height="wrap_content"
492+
android:text="@string/install_offline_estimated"
493+
android:textColor="@color/dash_text_secondary"
494+
android:textSize="12sp"
495+
android:gravity="center"
496+
android:layout_marginBottom="8dp"
497+
android:visibility="gone" />
498+
488499
<LinearLayout
489500
android:layout_width="match_parent"
490501
android:layout_height="wrap_content"

0 commit comments

Comments
 (0)