diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..79847f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +# Normalize line endings to LF for text/source files so diffs stay clean +# across Windows/macOS/Linux contributors. Binary assets are left untouched. +* text=auto eol=lf + +*.java text eol=lf +*.kt text eol=lf +*.gradle text eol=lf +*.xml text eol=lf +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.pro text eol=lf +*.properties text eol=lf + +# Binary assets (never touch line endings) +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.gif binary +*.ico binary +*.ttf binary +*.otf binary +*.so binary +*.jar binary +*.aar binary +*.keystore binary +*.zip binary +*.tar.gz binary +*.meta4 binary diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4efa391 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md — Project guardrails for iiab-android + +## Language + +All **code, comments and documentation** are written in **English**, to +standardize across contributors and users worldwide. + +Conversations / interactions with a contributor may happen in their **native +language** (e.g. Spanish). Reason in the contributor's language if helpful, but +every committed artifact ships in English. + +--- + +## Architecture: layered design is mandatory for new code + +The app started as a POC and has **no layering** today (UI, business and data +logic are mixed in large fragments/activities). We are migrating to a layered +(Clean Architecture) design: **Presentation → Domain ← Data**. + +Golden rule — dependency direction always points **inward**: + +- **Presentation** (Fragments/Activities, `ViewModel`, view state, formatting) + depends on Domain. +- **Domain** (entities, use cases, repository **interfaces**) depends on + **nothing**. No `android.*`, no `androidx.*`, no `java.net`, no HTTP, no JSON + framework. It must be unit-testable on a plain JVM. +- **Data** (repository **implementations**, remote/local data sources, DTOs, + catalogs, caches) depends on Domain and implements its interfaces. + +Hard requirements for any new or migrated code: + +1. **Do not** put Android or networking dependencies in the Domain layer. +2. New features live in their own feature package, split by layer + (`/domain`, `/data`, `/presentation`). +3. Business rules (validation, fallbacks, "what counts as valid") belong in + **use cases**, not in the UI or the data source. +4. The UI observes state from a `ViewModel`; it does not fetch or format data + itself. +5. No DI framework is required yet — wire dependencies by hand (constructors / + a small factory). Introducing Hilt/Dagger is a separate, explicit decision + (write an ADR first). + +--- + +## Strangler-fig migration policy (do not stop feature work) + +We do **not** freeze development for a big-bang rewrite. We strangle the legacy +incrementally: + +- **New code** is written in the layered architecture from day one. +- **Legacy code** is migrated **when we touch it** — the boy-scout rule: leave + the code better than you found it. Each fix/feature that touches a god class + should peel a small, well-defined slice into the new structure. +- Prefer **small seams**: connect new layered code to the legacy through one or + two narrow call sites rather than rewriting a whole screen at once. +- A specific module **may** be frozen briefly while it is being migrated (to + avoid changes landing on top of a half-done migration). This is different from + a global freeze. +- Before migrating old code, confirm it is in scope for the current task. No + unsolicited mass refactors. + +--- + +## Design map (keep this updated as we advance) + +> Update this section whenever a slice is migrated, a layer is added, or a god +> class shrinks. It is the living picture of how far the layering has spread. + +**Reference slice (DONE) — rootfs size (`org.iiab.controller.rootfs`)** +First feature built across all three layers; use it as the copy-paste template. + +- `domain/` — `Rootfs` (entity), `RootfsTier`, `RootfsAbi`, + `RootfsRepository` (port), `GetRootfsSizeUseCase` (validation + fallback rule). + Pure JVM, unit-tested (`src/test/.../rootfs/domain`, `.../util`). +- `data/` — `RootfsRemoteDataSource` (HTTP read of the `latest_*.meta4` + ``, in-memory cache, returns `-1` on failure), `RootfsCatalog` (URL + 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. + +**Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat: +god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable +state on public/static fields, hand-rolled `HttpURLConnection` calls duplicated +across classes, inline size formatting. + +See `ROOTFS_SIZE_PILOT_ANALYSIS.md` (repo root) for the detailed change map and +live-size data behind the reference slice. + +--- + +## Tech-debt watch list (controller) — opportunistic targets + +Evident debt noticed while building the pilot. Chip away at these **only when +you are already in the file** (boy-scout), and record progress in the design map: + +- **God classes:** `DeployFragment` (~2.7k LOC) and `MainActivity` mix UI, IO, + process control and networking. Extract cohesive slices into feature packages. +- **Shared mutable state:** public/`static` fields used as cross-class state + (e.g. download flags). Prefer encapsulated state in a `ViewModel`. +- **Duplicated networking:** `HttpURLConnection` is reimplemented in + `InstallationPlanner`, `DeployFragment`, `MainActivity`. Consolidate behind + data sources / a small HTTP helper as features migrate. +- **Inline formatting:** byte/size strings are formatted ad hoc in several + 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. diff --git a/ROOTFS_SIZE_PILOT_ANALYSIS.md b/ROOTFS_SIZE_PILOT_ANALYSIS.md new file mode 100644 index 0000000..489b196 --- /dev/null +++ b/ROOTFS_SIZE_PILOT_ANALYSIS.md @@ -0,0 +1,247 @@ +# Rootfs-Size Pilot — Analysis & Change Map + +> Companion to the refactor context document. This file is the result of the +> "analyze the requested changes" pass and is meant to run **in parallel** with +> the phased refactor (phases 0, 1, 2, 3, K…) without blocking feature work. +> +> Goal of the pilot slice: replace the **hardcoded** rootfs OS sizes with the +> **live** sizes published on the Deploy server, with a safe **fallback** to +> hardcoded values when there is no network access — built through the three +> Clean Architecture layers as the reference implementation. + +--- + +## 1. Live sizes on the Deploy server (ground truth) + +Source: the stable `latest__.meta4` Metalink pointers under +`https://iiab.switnet.org/android/rootfs/`. The `.meta4` files carry the exact +size in **bytes** (`` element) and the canonical download ``, and +they do **not** embed the version/hash in the name — so they are the right +target for both the size lookup and the download. + +Verified on 2026-06-17: + +| Tier | ABI `arm64-v8a` | ABI `armeabi-v7a` | +|------|-----------------|-------------------| +| `basic` | 1,219,422,532 B — 1.14 GiB / 1.22 GB | 1,220,401,364 B — 1.14 GiB / 1.22 GB | +| `standard` | 1,428,970,336 B — 1.33 GiB / 1.43 GB | 1,429,892,132 B — 1.33 GiB / 1.43 GB | +| `full` | 2,926,676,923 B — 2.73 GiB / 2.93 GB | 2,917,715,443 B — 2.72 GiB / 2.92 GB | + +> Latest builds at verification time: `iiab-oa_2026.158_*_arm64-v8a` and +> `iiab-oa_2026.159_*_armeabi-v7a`. Sizes drift between builds — that is exactly +> why they must be fetched live rather than hardcoded. + +### Naming note: "medium" → `standard` + +The reference document uses the label **medium**, but the server (and the +existing app code) uses **`standard`**. The pilot keeps the enum +`Tier.{BASIC, STANDARD, FULL}` and maps the user-facing "medium" wording to +`standard` so it matches the real Deploy artifacts. + +--- + +## 2. Current state: where the hardcoded sizes live + +The values are hardcoded in **`InstallationPlanner.java`** (lines 25–27): + +```java +private static final double OS_BASIC_GB = 1.0; +private static final double OS_STANDARD_GB = 1.2; +private static final double OS_FULL_GB = 2.7; +``` + +They flow through `calculateProjectedSize(...)` (a `switch (tier)`, +lines 152–162) into `StorageProjection.osSize`, and are rendered in +**`DeployFragment.java`** `recalculateProjection()`: + +```java +double pOs = (selectedTier == null) ? 0.0 : projection.osSize; // line 716 +txtLegendIiab.setText(String.format(Locale.US, "%.1fG", pOs)); // line 729 +// also folded into the gauge total at line 811 +``` + +### Accuracy gap (why this matters) + +The displayed values are stale and round-tripped through a single decimal: + +| Tier | Shown today | Live (GiB) | Off by | +|------|-------------|------------|--------| +| basic | `1.0G` | 1.14 | ~0.14 | +| standard | `1.2G` | 1.33 | ~0.13 | +| full | `2.7G` | 2.73 | ~0.03 | + +`basic` and `standard` are understated by ~12–14%. + +### The server URL is already built in the app + +`DeployFragment.java` (lines 983–988) already constructs exactly the URL the +data source needs, for the download flow: + +```java +String arch = getTermuxArch(); // line 983 +String archSuffix = (arch.contains("arm") && !arch.contains("64")) + ? "armeabi-v7a" : "arm64-v8a"; // line 984 +Tier safeTier = (selectedTier != null) ? selectedTier : Tier.BASIC; +String tierString = safeTier.name().toLowerCase(Locale.US); +String directUrl = "https://iiab.switnet.org/android/rootfs/latest_" + + tierString + "_" + archSuffix + ".meta4"; // line 987 +``` + +Arch detection lives in `getTermuxArch()` (lines 2645–2662; falls back to +`Build.SUPPORTED_ABIS[0]`). The RemoteDataSource should reuse this exact +URL convention and arch mapping for **both** ABIs. + +### Reusable networking entry points (no new HTTP library needed) + +- `InstallationPlanner.getOrFetchCatalog` (lines 74–147): the canonical + `HttpURLConnection` + 8 s timeout + disk cache + `Handler(mainLooper)` pattern. +- `DeployFragment.pingUrl` / `checkInternetAccess` (lines 2619 / 2674) and the + existing `HEAD` requests at lines 2627 / 2683 — already do + `conn.setRequestMethod("HEAD")`; ideal template for `Content-Length`. +- `Aria2Manager` (`--follow-metalink=mem`) already consumes the `.meta4` URL for + the actual download. + +--- + +## 3. Project / architecture readiness + +| Item | Value | +|------|-------| +| Language | **Pure Java** — no Kotlin plugin, no `.kt` files in our modules | +| AGP / Gradle | 8.4.1 / 8.8 | +| Java | 17 | +| compileSdk / minSdk / targetSdk | 34 / 24 / **28** (intentional, required for proot W^X) | +| Layering | **None** — flat package `org.iiab.controller`, no ViewModel / Repository / UseCase / DI | +| HTTP client | None — hand-rolled `HttpURLConnection` (no OkHttp/Retrofit) | +| Lifecycle (ViewModel/LiveData) | **Not present** | +| Byte-formatting helper | **Missing** — formatting is inlined ad hoc | +| Tests | JUnit 4 + `unitTests.returnDefaultValues = true`; two JVM-only tests of pure static methods; `androidTest` empty; no Mockito | + +**Implication:** this is a *greenfield* slice — it establishes the layering +pattern rather than refactoring an existing one. That is precisely the intended +role of the pilot. + +### Recommendation: write the pilot in **Java** + +Match the existing codebase. Introducing Kotlin would add the Kotlin plugin + +stdlib (+ coroutines for idiomatic value) and conflate a *language migration* +with an *architecture pilot*. Clean Architecture is fully expressible in Java 17, +keeps reviewers focused on the layer boundaries, and lets the all-Java team adopt +the pattern with no language ramp. A Kotlin migration, if desired, should be its +own explicit ADR later (it interops cleanly with this slice). + +--- + +## 4. Proposed pilot slice (layer mapping → real code) + +New sub-package under `controller/app/src/main/java/org/iiab/controller/rootfs/`: + +``` +org.iiab.controller.rootfs +├── domain (pure JVM — NO Android, NO HTTP imports) +│ ├── Rootfs.java // entity: tier, abi, url, sizeBytes +│ ├── RootfsRepository.java // port (interface) the domain owns +│ └── GetRootfsSizeUseCase.java // business rules: reject 0 / negative / absurd +├── data (implementation details) +│ ├── RootfsCatalog.java // tier+abi -> latest_*.meta4 URL + fallback bytes +│ ├── RootfsRemoteDataSource.java// HttpURLConnection: HEAD Content-Length OR meta4 +│ └── RootfsRepositoryImpl.java // implements RootfsRepository; live-then-fallback; DTO->entity +└── presentation + └── RootfsViewModel.java // exposes Loading / Success / Error state + +org.iiab.controller.util +└── ByteFormatter.java // long bytes -> "1.3 GiB" (pure, unit-testable) +``` + +Dependency direction (enforced): `presentation → domain ← data`. `domain` imports +nothing from Android or `java.net`, so `GetRootfsSizeUseCase` and `Rootfs` are +unit-testable on the JVM with the existing JUnit setup (mirrors +`SystemStatsUtilTest`). + +### Layer responsibilities + +**Data** +- `RootfsRemoteDataSource`: build `latest__.meta4`; preferred path is + a `HEAD` on the resolved `.tar.gz` reading `getContentLengthLong()`, or read the + `.meta4` and parse `(\d+)` (exact bytes — avoids hardcoding the + version in the URL). 8 s timeouts, background thread. +- `RootfsCatalog`: single source for the URL convention **and** the hardcoded + fallback byte values (see §5). Detects ABI via `Build.SUPPORTED_ABIS` / + `getTermuxArch()` and selects `arm64-v8a` vs `armeabi-v7a`. +- `RootfsRepositoryImpl`: try live, on any failure/offline return fallback; + map raw bytes (DTO) → `Rootfs` entity. + +**Domain** +- `GetRootfsSizeUseCase`: validation rules — reject `0`, negative, or absurd + sizes (e.g. < 100 MB or > 10 GB), in which case the fallback is authoritative. +- `RootfsRepository`: `Rootfs getSize(Tier tier, Abi abi)` contract; no leakage + of HTTP/Android types. + +**Presentation** +- `RootfsViewModel`: calls the use case off the main thread; emits + `Loading → Success(Rootfs) | Error(fallback)`; formats via `ByteFormatter`. + +--- + +## 5. Fallback constants (ready to use) + +Replace the three `double … _GB` constants with per-ABI **byte** fallbacks +seeded from today's live values. Bytes (not rounded GB) keep the fallback exact +and let the UI choose GiB vs GB formatting consistently. + +```java +// Fallback sizes in BYTES, captured from latest_*.meta4 on 2026-06-17. +// Used only when the live HEAD/meta4 lookup fails (offline or server error). +// arm64-v8a +static final long FALLBACK_BASIC_ARM64 = 1_219_422_532L; // 1.14 GiB +static final long FALLBACK_STANDARD_ARM64 = 1_428_970_336L; // 1.33 GiB +static final long FALLBACK_FULL_ARM64 = 2_926_676_923L; // 2.73 GiB +// armeabi-v7a +static final long FALLBACK_BASIC_ARMV7 = 1_220_401_364L; // 1.14 GiB +static final long FALLBACK_STANDARD_ARMV7 = 1_429_892_132L; // 1.33 GiB +static final long FALLBACK_FULL_ARMV7 = 2_917_715_443L; // 2.72 GiB +``` + +> Display note: the rest of the app formats storage with binary units +> (`G` = GiB, dividing by 1024³). Convert bytes → GiB for the legend +> (`txtLegendIiab`) and gauge so the new value is consistent with free-space math. + +--- + +## 6. Ordered change map (when implementing) + +1. **`build.gradle` (app):** add `androidx.lifecycle:lifecycle-viewmodel:2.8.7` + and `:lifecycle-livedata:2.8.7` (Java artifacts, no Kotlin). Optional: + `testImplementation 'org.mockito:mockito-core:5.12.0'`. No HTTP lib needed. +2. **Create the `rootfs/` package** with the classes in §4. Domain first + (pure, test it), then data, then presentation. +3. **`ByteFormatter`** util + JVM unit test (matches `SystemStatsUtilTest` style). +4. **Wire into `InstallationPlanner`:** demote `OS_*_GB` to fallback; have + `calculateProjectedSize(...)` (already on its own `Thread`, line 150) obtain + the OS size from the repository (live-then-fallback) using the tier + ABI. + Pass `archSuffix` from the two call sites (`DeployFragment` lines 706 / 1032), + which already compute it. +5. **Display:** no markup change required — once `projection.osSize` is live, + `DeployFragment` line 729 (`txtLegendIiab`) and the gauge total (line 811) + update automatically. Optional `(live)` / `(offline)` indicator only if wanted. +6. **Gate offline:** consult `checkInternetAccess()` (line 2674) to skip the live + call and go straight to fallback without a timeout wait. +7. **Validate against the real Deploy server** for both ABIs and offline mode. + +### Tests +- Domain: `GetRootfsSizeUseCaseTest` with a fake `RootfsRepository` — accepts + valid sizes, rejects 0 / negative / absurd, returns fallback on repo failure. +- Util: `ByteFormatterTest` — boundaries (B/KiB/MiB/GiB), rounding. +- Data: `RootfsRepositoryImplTest` — live success maps DTO→entity; failure → + fallback bytes per ABI. + +--- + +## 7. Parallelism with the phased refactor + +This slice is self-contained inside the new `rootfs/` package and touches the +legacy God classes only at two seams (`InstallationPlanner` OS-size source and +the two `DeployFragment` call sites). It can proceed alongside phases 0/1/2/3/K +without freezing other features — consistent with the strangler-fig strategy and +the boy-scout rule. It also doubles as the reference implementation future slices +copy. diff --git a/controller/app/build.gradle b/controller/app/build.gradle index 6f50c6e..9a3121d 100644 --- a/controller/app/build.gradle +++ b/controller/app/build.gradle @@ -144,6 +144,11 @@ dependencies { implementation 'androidx.biometric:biometric:1.1.0' implementation 'androidx.webkit:webkit:1.12.0' + // Presentation layer (Clean Architecture pilot): ViewModel + LiveData. + // Java artifacts — no Kotlin required. + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.8.7' + implementation 'androidx.lifecycle:lifecycle-livedata:2.8.7' + // IIAB Tools implementation 'com.google.zxing:core:3.5.2' implementation 'com.journeyapps:zxing-android-embedded:4.3.0' 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 c884bf5..2068576 100644 --- a/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java +++ b/controller/app/src/main/java/org/iiab/controller/InstallationPlanner.java @@ -5,6 +5,15 @@ import android.os.Looper; import android.util.Log; +import org.iiab.controller.rootfs.data.RootfsCatalog; +import org.iiab.controller.rootfs.data.RootfsRemoteDataSource; +import org.iiab.controller.rootfs.data.RootfsRepositoryImpl; +import org.iiab.controller.rootfs.domain.GetRootfsSizeUseCase; +import org.iiab.controller.rootfs.domain.Rootfs; +import org.iiab.controller.rootfs.domain.RootfsRepository; +import org.iiab.controller.rootfs.domain.RootfsTier; +import org.iiab.controller.util.ByteFormatter; + import org.json.JSONObject; import java.io.BufferedReader; @@ -22,9 +31,9 @@ public class InstallationPlanner { private static final String KIWIX_URL = "https://download.kiwix.org/zim/wikipedia/"; private static final String CACHE_FILE_NAME = "kiwix_catalog.json"; - private static final double OS_BASIC_GB = 1.0; - private static final double OS_STANDARD_GB = 1.2; - private static final double OS_FULL_GB = 2.7; + // OS rootfs sizes are no longer hardcoded here. They are resolved live (with a + // hardcoded fallback) through the layered "rootfs" slice — see resolveOsSizeGb() + // and org.iiab.controller.rootfs.* . Fallback byte values live in RootfsCatalog. private static final double MAPS_BASIC_GB = 0.2; private static final double MAPS_STANDARD_GB = 11.0; @@ -148,18 +157,11 @@ public static void getOrFetchCatalog(Context context, CacheListener listener) { public static void calculateProjectedSize(Context context, Tier tier, boolean pullCompanionData, String langCode, String overrideVariant, PlanResultListener listener) { new Thread(() -> { - double os = 0.0, maps = 0.0; - switch (tier) { - case BASIC: - os = OS_BASIC_GB; - break; - case STANDARD: - os = OS_STANDARD_GB; - break; - case FULL: - os = OS_FULL_GB; - break; - } + // 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); + double maps = 0.0; if (!pullCompanionData) { StorageProjection res = new StorageProjection(os, 0, 0, "N/A", null); @@ -248,4 +250,34 @@ public void onError(String error) { }); }).start(); } + + /** + * Resolves the OS rootfs size for a tier, in GiB, using the layered rootfs + * slice: live size from the Deploy server when reachable, hardcoded fallback + * otherwise. The ABI is detected from the device. + * + *

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(); + RootfsRepository repository = + new RootfsRepositoryImpl(new RootfsRemoteDataSource(), catalog); + GetRootfsSizeUseCase useCase = new GetRootfsSizeUseCase(repository); + Rootfs rootfs = useCase.execute(toDomainTier(tier), catalog.detectAbi()); + return ByteFormatter.toGiB(rootfs.sizeBytes()); + } + + /** Maps the legacy {@link Tier} to the domain {@link RootfsTier}. */ + private static RootfsTier toDomainTier(Tier tier) { + switch (tier) { + case STANDARD: + return RootfsTier.STANDARD; + case FULL: + return RootfsTier.FULL; + case BASIC: + default: + return RootfsTier.BASIC; + } + } } \ No newline at end of file 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 new file mode 100644 index 0000000..754c5f3 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsCatalog.java @@ -0,0 +1,77 @@ +package org.iiab.controller.rootfs.data; + +import android.os.Build; + +import org.iiab.controller.rootfs.domain.RootfsAbi; +import org.iiab.controller.rootfs.domain.RootfsTier; + +import java.util.Locale; + +/** + * Data-layer catalog: maps a tier+abi to its Deploy-server URL and provides the + * hardcoded fallback sizes. Single source of truth for both. + * + *

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. + */ +public class RootfsCatalog { + + private static final String BASE_URL = "https://iiab.switnet.org/android/rootfs/"; + + // arm64-v8a fallbacks + 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 + + /** 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. */ + public long fallbackBytes(RootfsTier tier, RootfsAbi abi) { + 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; + } + } else { + switch (tier) { + case BASIC: + return FALLBACK_BASIC_ARM64; + case STANDARD: + return FALLBACK_STANDARD_ARM64; + case FULL: + return FALLBACK_FULL_ARM64; + } + } + return FALLBACK_BASIC_ARM64; + } + + /** + * Detects the device ABI for rootfs selection. Prefers 64-bit when available, + * otherwise treats the device as 32-bit ARM. + */ + public RootfsAbi detectAbi() { + String[] abis = Build.SUPPORTED_ABIS; + if (abis != null) { + for (String abi : abis) { + if (RootfsAbi.ARM64_V8A.id().equals(abi)) { + return RootfsAbi.ARM64_V8A; + } + } + } + return RootfsAbi.ARMEABI_V7A; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsRemoteDataSource.java b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsRemoteDataSource.java new file mode 100644 index 0000000..e4f6e20 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsRemoteDataSource.java @@ -0,0 +1,87 @@ +package org.iiab.controller.rootfs.data; + +import android.util.Log; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Data source that reads the exact rootfs size from the Deploy server. + * + *

It fetches the {@code latest_*.meta4} Metalink file and reads its + * {@code } element (bytes). The {@code .meta4} is the stable pointer that + * does not embed the build version/hash in its name, so it survives new builds. + * + *

Successful results are cached in memory for the process lifetime so that + * re-calculating the storage projection (e.g. when the user switches tiers) does + * not hit the network repeatedly. Failures are not cached, so connectivity can + * recover on a later attempt. + * + *

This is the only place in the slice that touches {@link HttpURLConnection}. + * It must be called off the main thread. + */ +public class RootfsRemoteDataSource { + + private static final String TAG = "RootfsRemoteDataSource"; + private static final int TIMEOUT_MS = 6000; + private static final Pattern SIZE_PATTERN = Pattern.compile("(\\d+)"); + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** + * Reads the size in bytes from a Metalink URL. + * + * @return the size in bytes, or {@code -1} on any failure (offline, timeout, + * HTTP error or missing/invalid {@code }). + */ + public long fetchSizeBytes(String metaUrl) { + Long cached = CACHE.get(metaUrl); + if (cached != null) { + return cached; + } + + HttpURLConnection conn = null; + try { + URL url = new URL(metaUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestProperty("User-Agent", "Mozilla/5.0"); + conn.setConnectTimeout(TIMEOUT_MS); + conn.setReadTimeout(TIMEOUT_MS); + + int code = conn.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "Unexpected HTTP " + code + " for " + metaUrl); + return -1; + } + + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + Matcher m = SIZE_PATTERN.matcher(line); + if (m.find()) { + long bytes = Long.parseLong(m.group(1)); + if (bytes > 0) { + CACHE.put(metaUrl, bytes); + } + return bytes; + } + } + } + Log.w(TAG, "No element in " + metaUrl); + return -1; + } catch (Exception e) { + Log.w(TAG, "Live rootfs size fetch failed: " + e.getMessage()); + return -1; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsRepositoryImpl.java b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsRepositoryImpl.java new file mode 100644 index 0000000..ab8b870 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/data/RootfsRepositoryImpl.java @@ -0,0 +1,37 @@ +package org.iiab.controller.rootfs.data; + +import org.iiab.controller.rootfs.domain.Rootfs; +import org.iiab.controller.rootfs.domain.RootfsAbi; +import org.iiab.controller.rootfs.domain.RootfsRepository; +import org.iiab.controller.rootfs.domain.RootfsTier; + +/** + * Data-layer implementation of the domain {@link RootfsRepository} port. + * + *

Maps the raw size (bytes) from {@link RootfsRemoteDataSource} into the + * {@link Rootfs} domain entity, and exposes the hardcoded fallback from + * {@link RootfsCatalog}. The decision of when to use the fallback lives + * in the domain use case, not here. + */ +public class RootfsRepositoryImpl implements RootfsRepository { + + private final RootfsRemoteDataSource remote; + private final RootfsCatalog catalog; + + public RootfsRepositoryImpl(RootfsRemoteDataSource remote, RootfsCatalog catalog) { + this.remote = remote; + this.catalog = catalog; + } + + @Override + public Rootfs fetchLive(RootfsTier tier, RootfsAbi abi) { + String url = catalog.metaUrl(tier, abi); + long bytes = remote.fetchSizeBytes(url); + return new Rootfs(tier, abi, url, bytes, bytes > 0); + } + + @Override + public Rootfs fallback(RootfsTier tier, RootfsAbi abi) { + return new Rootfs(tier, abi, catalog.metaUrl(tier, abi), catalog.fallbackBytes(tier, abi), false); + } +} 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 new file mode 100644 index 0000000..99086af --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCase.java @@ -0,0 +1,43 @@ +package org.iiab.controller.rootfs.domain; + +/** + * Business rules for resolving a rootfs size. + * + *

Policy: prefer the live size, but only if it is plausible. A rootfs image + * is never zero, negative, smaller than {@value #MIN_PLAUSIBLE_BYTES} bytes, nor + * larger than {@value #MAX_PLAUSIBLE_BYTES} bytes. When the live value is missing + * or implausible, fall back to the known-good value. + * + *

Pure domain logic: no Android and no networking dependencies, so it is + * fully unit-testable on the JVM. + */ +public final class GetRootfsSizeUseCase { + + /** 100 MiB — anything smaller is treated as a bogus/partial response. */ + static final long MIN_PLAUSIBLE_BYTES = 100L * 1024 * 1024; + /** 10 GiB — anything larger is treated as absurd for a rootfs image. */ + static final long MAX_PLAUSIBLE_BYTES = 10L * 1024 * 1024 * 1024; + + private final RootfsRepository repository; + + public GetRootfsSizeUseCase(RootfsRepository repository) { + this.repository = repository; + } + + /** + * Resolves the size for a tier+abi, applying validation and fallback rules. + * 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 repository.fallback(tier, abi); + } + + /** Accepts only sizes within the plausible range; rejects zero, negative and absurd values. */ + static boolean isPlausible(long bytes) { + return bytes >= MIN_PLAUSIBLE_BYTES && bytes <= MAX_PLAUSIBLE_BYTES; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/domain/Rootfs.java b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/Rootfs.java new file mode 100644 index 0000000..a9812b6 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/Rootfs.java @@ -0,0 +1,54 @@ +package org.iiab.controller.rootfs.domain; + +/** + * Domain entity describing a downloadable rootfs image and its size. + * + *

Immutable value object. {@code sizeBytes} is the size in bytes; a value + * {@code <= 0} means "unknown / not available". {@code live} indicates whether + * the size was obtained from the network ({@code true}) or from a hardcoded + * fallback ({@code false}). + * + *

Pure domain type: no Android and no networking dependencies. + */ +public final class Rootfs { + + private final RootfsTier tier; + private final RootfsAbi abi; + private final String url; + private final long sizeBytes; + private final boolean live; + + public Rootfs(RootfsTier tier, RootfsAbi abi, String url, long sizeBytes, boolean live) { + this.tier = tier; + this.abi = abi; + this.url = url; + this.sizeBytes = sizeBytes; + this.live = live; + } + + public RootfsTier tier() { + return tier; + } + + public RootfsAbi abi() { + return abi; + } + + public String url() { + return url; + } + + public long sizeBytes() { + return sizeBytes; + } + + /** {@code true} if the size came from the network; {@code false} if it is a fallback. */ + public boolean isLive() { + return live; + } + + @Override + public String toString() { + return "Rootfs{" + tier + ", " + abi + ", sizeBytes=" + sizeBytes + ", live=" + live + '}'; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsAbi.java b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsAbi.java new file mode 100644 index 0000000..66bfc6b --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsAbi.java @@ -0,0 +1,25 @@ +package org.iiab.controller.rootfs.domain; + +/** + * Application Binary Interfaces the rootfs is published for. + * + *

The {@link #id()} matches the suffix used on the Deploy server filenames + * (e.g. {@code latest_basic_arm64-v8a.meta4}). + * + *

Pure domain type: no Android and no networking dependencies. + */ +public enum RootfsAbi { + ARM64_V8A("arm64-v8a"), + ARMEABI_V7A("armeabi-v7a"); + + private final String id; + + RootfsAbi(String id) { + this.id = id; + } + + /** Server-side filename suffix for this ABI. */ + public String id() { + return id; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsRepository.java b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsRepository.java new file mode 100644 index 0000000..54e0c1c --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsRepository.java @@ -0,0 +1,28 @@ +package org.iiab.controller.rootfs.domain; + +/** + * Domain port for obtaining rootfs size information. + * + *

This is the abstraction the domain owns; the Data layer provides the + * implementation. The domain never learns how sizes are fetched. + * + *

Implementations must never throw: {@link #fetchLive} returns an entity + * with {@code sizeBytes <= 0} when the network is unavailable, and + * {@link #fallback} always returns a known-good value. + */ +public interface RootfsRepository { + + /** + * Attempts to read the live size from the Deploy server. + * + * @return a {@link Rootfs} whose {@code sizeBytes} is the live value, or + * {@code <= 0} (and {@code live == false}) if it could not be read. + */ + Rootfs fetchLive(RootfsTier tier, RootfsAbi abi); + + /** + * Returns a hardcoded, known-good size for offline / failure scenarios. + * The concrete values live in the Data layer; the domain only knows they exist. + */ + Rootfs fallback(RootfsTier tier, RootfsAbi abi); +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsTier.java b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsTier.java new file mode 100644 index 0000000..23210d3 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/domain/RootfsTier.java @@ -0,0 +1,16 @@ +package org.iiab.controller.rootfs.domain; + +/** + * The deployment tiers a rootfs image is published for. + * + *

Note on naming: the product copy sometimes calls the middle tier "medium", + * but the Deploy server and this codebase use {@code STANDARD}. Keep this enum + * authoritative and map any "medium" wording to {@link #STANDARD}. + * + *

Pure domain type: no Android and no networking dependencies. + */ +public enum RootfsTier { + BASIC, + STANDARD, + FULL +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsUiState.java b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsUiState.java new file mode 100644 index 0000000..d6f5a61 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsUiState.java @@ -0,0 +1,41 @@ +package org.iiab.controller.rootfs.presentation; + +import org.iiab.controller.rootfs.domain.Rootfs; + +/** + * Immutable UI state for the rootfs-size feature, exposed by {@link RootfsViewModel}. + * + *

Presentation-layer type. {@code label} is already formatted for display + * (e.g. "1.3 GiB"); {@code live} lets the UI distinguish a live value from a + * fallback (e.g. show an "(offline)" hint). + */ +public final class RootfsUiState { + + public enum Status {LOADING, SUCCESS, ERROR} + + public final Status status; + public final String label; + public final boolean live; + public final Rootfs rootfs; + public final String error; + + private RootfsUiState(Status status, String label, boolean live, Rootfs rootfs, String error) { + this.status = status; + this.label = label; + this.live = live; + this.rootfs = rootfs; + this.error = error; + } + + public static RootfsUiState loading() { + return new RootfsUiState(Status.LOADING, "…", false, null, null); + } + + public static RootfsUiState success(Rootfs rootfs, String label) { + return new RootfsUiState(Status.SUCCESS, label, rootfs.isLive(), rootfs, null); + } + + public static RootfsUiState error(String message) { + return new RootfsUiState(Status.ERROR, "—", false, null, message); + } +} 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 new file mode 100644 index 0000000..e8c1b41 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModel.java @@ -0,0 +1,58 @@ +package org.iiab.controller.rootfs.presentation; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.iiab.controller.rootfs.domain.GetRootfsSizeUseCase; +import org.iiab.controller.rootfs.domain.Rootfs; +import org.iiab.controller.rootfs.domain.RootfsAbi; +import org.iiab.controller.rootfs.domain.RootfsTier; +import org.iiab.controller.util.ByteFormatter; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Presentation-layer ViewModel for the rootfs-size feature. + * + *

Calls the domain {@link GetRootfsSizeUseCase} off the main thread and + * exposes a {@link RootfsUiState} stream. This is the reference for how UI in + * the migrated architecture should consume the Domain layer; screens observe + * {@link #state()} instead of formatting or fetching sizes themselves. + * + *

Depends only on Domain abstractions (plus a formatting util) — never on the + * Data layer directly. + */ +public class RootfsViewModel extends ViewModel { + + private final GetRootfsSizeUseCase getRootfsSize; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final MutableLiveData state = new MutableLiveData<>(RootfsUiState.loading()); + + public RootfsViewModel(GetRootfsSizeUseCase getRootfsSize) { + this.getRootfsSize = getRootfsSize; + } + + public LiveData state() { + return state; + } + + /** Loads the size for a tier+abi; posts LOADING then SUCCESS/ERROR. */ + public void load(RootfsTier tier, RootfsAbi abi) { + state.postValue(RootfsUiState.loading()); + executor.execute(() -> { + try { + Rootfs rootfs = getRootfsSize.execute(tier, abi); + state.postValue(RootfsUiState.success(rootfs, ByteFormatter.toHuman(rootfs.sizeBytes()))); + } catch (Exception e) { + state.postValue(RootfsUiState.error(e.getMessage())); + } + }); + } + + @Override + protected void onCleared() { + executor.shutdownNow(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModelFactory.java b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModelFactory.java new file mode 100644 index 0000000..7cc148d --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/rootfs/presentation/RootfsViewModelFactory.java @@ -0,0 +1,34 @@ +package org.iiab.controller.rootfs.presentation; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.iiab.controller.rootfs.data.RootfsCatalog; +import org.iiab.controller.rootfs.data.RootfsRemoteDataSource; +import org.iiab.controller.rootfs.data.RootfsRepositoryImpl; +import org.iiab.controller.rootfs.domain.GetRootfsSizeUseCase; +import org.iiab.controller.rootfs.domain.RootfsRepository; + +/** + * Manual dependency wiring for {@link RootfsViewModel}. + * + *

A pilot does not need a DI framework: this factory composes + * Data -> Domain -> Presentation by hand. Introducing Hilt/Dagger, if ever, is a + * separate, explicit decision. + */ +public class RootfsViewModelFactory implements ViewModelProvider.Factory { + + @NonNull + @Override + @SuppressWarnings("unchecked") + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(RootfsViewModel.class)) { + RootfsRepository repository = + new RootfsRepositoryImpl(new RootfsRemoteDataSource(), new RootfsCatalog()); + GetRootfsSizeUseCase useCase = new GetRootfsSizeUseCase(repository); + return (T) new RootfsViewModel(useCase); + } + throw new IllegalArgumentException("Unknown ViewModel class: " + modelClass.getName()); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/util/ByteFormatter.java b/controller/app/src/main/java/org/iiab/controller/util/ByteFormatter.java new file mode 100644 index 0000000..845739f --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/util/ByteFormatter.java @@ -0,0 +1,41 @@ +package org.iiab.controller.util; + +import java.util.Locale; + +/** + * Stateless helper for turning byte counts into human-readable text. + * + *

Uses binary units (GiB/MiB/KiB), consistent with how the app reports + * device free space. Pure JVM logic — unit-testable without Android. + */ +public final class ByteFormatter { + + private static final long KIB = 1024L; + private static final long MIB = KIB * 1024L; + private static final long GIB = MIB * 1024L; + + private ByteFormatter() { + } + + /** Bytes as a fractional number of GiB (e.g. 1_428_970_336 -> 1.331...). */ + public static double toGiB(long bytes) { + return bytes / (double) GIB; + } + + /** Human-readable string such as "1.3 GiB", "512 MiB" or "0 B". */ + public static String toHuman(long bytes) { + if (bytes <= 0) { + return "0 B"; + } + if (bytes >= GIB) { + return String.format(Locale.US, "%.1f GiB", bytes / (double) GIB); + } + if (bytes >= MIB) { + return String.format(Locale.US, "%.0f MiB", bytes / (double) MIB); + } + if (bytes >= KIB) { + return String.format(Locale.US, "%.0f KiB", bytes / (double) KIB); + } + return bytes + " B"; + } +} 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 new file mode 100644 index 0000000..92825c7 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/rootfs/domain/GetRootfsSizeUseCaseTest.java @@ -0,0 +1,76 @@ +package org.iiab.controller.rootfs.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Pure-JVM unit tests for the domain rules. No Android, no network — uses a + * hand-written fake repository (no mocking framework needed). + */ +public class GetRootfsSizeUseCaseTest { + + private static final long LIVE_OK = 1_428_970_336L; // ~1.33 GiB, plausible + private static final long FALLBACK = 1_219_422_532L; // ~1.14 GiB + + /** Configurable fake of the domain port. */ + private static final class FakeRepository implements RootfsRepository { + private final long liveBytes; // <= 0 means "unavailable" + private final boolean liveFlag; + FakeRepository(long liveBytes, boolean liveFlag) { + this.liveBytes = liveBytes; + this.liveFlag = liveFlag; + } + @Override + public Rootfs fetchLive(RootfsTier tier, RootfsAbi abi) { + return new Rootfs(tier, abi, "url", liveBytes, liveFlag); + } + @Override + public Rootfs fallback(RootfsTier tier, RootfsAbi abi) { + return new Rootfs(tier, abi, "url", FALLBACK, false); + } + } + + private Rootfs run(FakeRepository repo) { + return new GetRootfsSizeUseCase(repo) + .execute(RootfsTier.STANDARD, RootfsAbi.ARM64_V8A); + } + + @Test + public void usesLiveWhenPlausible() { + Rootfs r = run(new FakeRepository(LIVE_OK, true)); + assertEquals(LIVE_OK, r.sizeBytes()); + assertTrue(r.isLive()); + } + + @Test + public void fallsBackWhenLiveUnavailable() { + Rootfs r = run(new FakeRepository(-1, false)); + assertEquals(FALLBACK, r.sizeBytes()); + assertFalse(r.isLive()); + } + + @Test + public void rejectsZeroAndNegative() { + assertEquals(FALLBACK, run(new FakeRepository(0, true)).sizeBytes()); + assertEquals(FALLBACK, run(new FakeRepository(-100, true)).sizeBytes()); + } + + @Test + public void rejectsAbsurdlySmallOrLarge() { + long tooSmall = 50L * 1024 * 1024; // 50 MiB + long tooLarge = 11L * 1024 * 1024 * 1024; // 11 GiB + assertEquals(FALLBACK, run(new FakeRepository(tooSmall, true)).sizeBytes()); + assertEquals(FALLBACK, run(new FakeRepository(tooLarge, true)).sizeBytes()); + } + + @Test + public void plausibilityBoundsAreInclusive() { + assertTrue(GetRootfsSizeUseCase.isPlausible(GetRootfsSizeUseCase.MIN_PLAUSIBLE_BYTES)); + assertTrue(GetRootfsSizeUseCase.isPlausible(GetRootfsSizeUseCase.MAX_PLAUSIBLE_BYTES)); + assertFalse(GetRootfsSizeUseCase.isPlausible(GetRootfsSizeUseCase.MIN_PLAUSIBLE_BYTES - 1)); + assertFalse(GetRootfsSizeUseCase.isPlausible(GetRootfsSizeUseCase.MAX_PLAUSIBLE_BYTES + 1)); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/util/ByteFormatterTest.java b/controller/app/src/test/java/org/iiab/controller/util/ByteFormatterTest.java new file mode 100644 index 0000000..cbbae10 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/util/ByteFormatterTest.java @@ -0,0 +1,37 @@ +package org.iiab.controller.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Pure-JVM unit tests for {@link ByteFormatter}. */ +public class ByteFormatterTest { + + private static final long KIB = 1024L; + private static final long MIB = KIB * 1024L; + private static final long GIB = MIB * 1024L; + + @Test + public void toGiBConvertsExactly() { + assertEquals(1.0, ByteFormatter.toGiB(GIB), 1e-9); + assertEquals(1.331, ByteFormatter.toGiB(1_428_970_336L), 1e-3); + } + + @Test + public void humanReadableUnits() { + assertEquals("0 B", ByteFormatter.toHuman(0)); + assertEquals("0 B", ByteFormatter.toHuman(-5)); + assertEquals("512 B", ByteFormatter.toHuman(512)); + assertEquals("1 KiB", ByteFormatter.toHuman(KIB)); + assertEquals("1 MiB", ByteFormatter.toHuman(MIB)); + assertEquals("1.0 GiB", ByteFormatter.toHuman(GIB)); + } + + @Test + public void rootfsBasicReadsAsExpected() { + // 1,219,422,532 bytes -> ~1.14 GiB + String s = ByteFormatter.toHuman(1_219_422_532L); + assertTrue(s, s.equals("1.1 GiB")); + } +}