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
65 changes: 58 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<feature>/{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_<feature>.xml` (Android merges all `<resources>`
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`.

---

Expand All @@ -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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -248,7 +269,7 @@ public void onError(String error) {
listener.onError(error);
}
});
}).start();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,21 @@ public LiveData<RootfsUiState> 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()));
Expand Down
11 changes: 11 additions & 0 deletions controller/app/src/main/res/layout/fragment_deploy.xml
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,17 @@
</LinearLayout>
</LinearLayout>

<TextView
android:id="@+id/txt_offline_estimate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/install_offline_estimated"
android:textColor="@color/dash_text_secondary"
android:textSize="12sp"
android:gravity="center"
android:layout_marginBottom="8dp"
android:visibility="gone" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down
Loading
Loading