diff --git a/CLAUDE.md b/CLAUDE.md index a8f1ef6..53a2752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,19 @@ First feature built across all three layers; use it as the copy-paste template. (which only needs the resolved companion-data filename). Removing it once the install flow stops depending on it is a future strangler step. +**Slice (DONE) — device architecture (`org.iiab.controller.deviceinfo`)** +Carved out while fixing the dashboard "device architecture" field (it showed the +app's ABI, so a 32-bit app on a 64-bit phone reported 32-bit). + +- `domain/` — `DeviceAbiProvider` (port) + `GetDeviceArchUseCase` (rule: + prefer the device's primary 64-bit ABI, else 32-bit, else generic). Pure JVM, + unit-tested (`GetDeviceArchUseCaseTest`). +- `data/` — `BuildDeviceAbiProvider` reads `Build.SUPPORTED_*_ABIS` (device-level, + not the app process), so it reports the real hardware arch. +- **Legacy seam:** `DashboardFragment` resolves the device-panel arch through this + use case; `getTermuxArch()` (app/content ABI) is unchanged for modules, termux + and debian arch. + **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 diff --git a/controller/app/src/main/java/org/iiab/controller/DashboardFragment.java b/controller/app/src/main/java/org/iiab/controller/DashboardFragment.java index 980bf5c..17fa200 100644 --- a/controller/app/src/main/java/org/iiab/controller/DashboardFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DashboardFragment.java @@ -27,6 +27,9 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import org.iiab.controller.deviceinfo.data.BuildDeviceAbiProvider; +import org.iiab.controller.deviceinfo.domain.GetDeviceArchUseCase; + import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -220,9 +223,14 @@ private void updateSystemStats() { int sdkVersion = android.os.Build.VERSION.SDK_INT; txtAndroidVersion.setText(getString(R.string.dash_android_version_value, "v" + androidRelease, String.valueOf(sdkVersion))); - // --- FETCH AND DISPLAY HOST ARCHITECTURE --- + // --- FETCH AND DISPLAY HOST (DEVICE) ARCHITECTURE --- + // This must be the REAL device arch, not the app's ABI: a 32-bit app can + // run on a 64-bit device (used for testing the 32-bit path), and the + // device panel must still report 64-bit. App/content arch keeps using + // getTermuxArch() elsewhere (modules, termux, debian). if (txtHostArch != null) { - txtHostArch.setText(getTermuxArch()); + String deviceArch = new GetDeviceArchUseCase(new BuildDeviceAbiProvider()).execute(); + txtHostArch.setText(deviceArch); } // --- CALCULATE SERVER UPTIME --- diff --git a/controller/app/src/main/java/org/iiab/controller/deviceinfo/data/BuildDeviceAbiProvider.java b/controller/app/src/main/java/org/iiab/controller/deviceinfo/data/BuildDeviceAbiProvider.java new file mode 100644 index 0000000..affe0d2 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deviceinfo/data/BuildDeviceAbiProvider.java @@ -0,0 +1,32 @@ +package org.iiab.controller.deviceinfo.data; + +import android.os.Build; + +import org.iiab.controller.deviceinfo.domain.DeviceAbiProvider; + +/** + * Data-layer {@link DeviceAbiProvider} backed by {@link android.os.Build}. + * + *

{@code Build.SUPPORTED_*_ABIS} are populated from device system properties + * (the {@code ro.product.cpu.abilist*} family) at runtime init, so they reflect + * the hardware's capabilities even when the current process is 32-bit. That is + * exactly what lets us report the real device architecture for a 32-bit app + * running on a 64-bit device. + */ +public final class BuildDeviceAbiProvider implements DeviceAbiProvider { + + @Override + public String[] supported64BitAbis() { + return Build.SUPPORTED_64_BIT_ABIS; + } + + @Override + public String[] supported32BitAbis() { + return Build.SUPPORTED_32_BIT_ABIS; + } + + @Override + public String[] allSupportedAbis() { + return Build.SUPPORTED_ABIS; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/deviceinfo/domain/DeviceAbiProvider.java b/controller/app/src/main/java/org/iiab/controller/deviceinfo/domain/DeviceAbiProvider.java new file mode 100644 index 0000000..2e96f67 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deviceinfo/domain/DeviceAbiProvider.java @@ -0,0 +1,23 @@ +package org.iiab.controller.deviceinfo.domain; + +/** + * Domain port exposing the ABIs the physical device supports. + * + *

These lists are device-level (what the hardware/OS can run), independent of + * the current app's own ABI. The Data layer implements this by reading + * {@code android.os.Build}; the domain never touches Android types. + * + *

Each method returns the ABI identifiers (e.g. {@code "arm64-v8a"}, + * {@code "armeabi-v7a"}) most-preferred first, or an empty array if none. + */ +public interface DeviceAbiProvider { + + /** 64-bit ABIs the device supports (empty on a 32-bit-only device). */ + String[] supported64BitAbis(); + + /** 32-bit ABIs the device supports. */ + String[] supported32BitAbis(); + + /** All ABIs the device supports, most-preferred first. */ + String[] allSupportedAbis(); +} diff --git a/controller/app/src/main/java/org/iiab/controller/deviceinfo/domain/GetDeviceArchUseCase.java b/controller/app/src/main/java/org/iiab/controller/deviceinfo/domain/GetDeviceArchUseCase.java new file mode 100644 index 0000000..81ba1ea --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deviceinfo/domain/GetDeviceArchUseCase.java @@ -0,0 +1,55 @@ +package org.iiab.controller.deviceinfo.domain; + +/** + * Resolves the real device CPU architecture, independent of the running + * app's own ABI. + * + *

Why this exists: a 32-bit build of the app can be installed on a 64-bit + * device (a common way to test the 32-bit path without 32-bit hardware). In that + * case the app's own ABI is 32-bit, but the device is still 64-bit. Device-facing + * UI (the dashboard "device architecture") must report the hardware truth, so we + * derive it from the device's supported-ABI lists rather than the app's + * {@code nativeLibraryDir}. + * + *

Rule: prefer the primary 64-bit ABI when the device supports any; otherwise + * the primary 32-bit ABI; otherwise the first of the generic list. Pure domain + * logic — no Android dependencies, fully unit-testable on the JVM. + */ +public final class GetDeviceArchUseCase { + + private final DeviceAbiProvider provider; + + public GetDeviceArchUseCase(DeviceAbiProvider provider) { + this.provider = provider; + } + + /** Returns the device's primary ABI (e.g. {@code "arm64-v8a"}), or {@code "unknown"}. */ + public String execute() { + String abi = first(provider.supported64BitAbis()); + if (abi != null) { + return abi; + } + abi = first(provider.supported32BitAbis()); + if (abi != null) { + return abi; + } + abi = first(provider.allSupportedAbis()); + if (abi != null) { + return abi; + } + return "unknown"; + } + + /** First non-empty entry of an ABI array, or {@code null}. */ + private static String first(String[] abis) { + if (abis == null) { + return null; + } + for (String abi : abis) { + if (abi != null && !abi.isEmpty()) { + return abi; + } + } + return null; + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/deviceinfo/domain/GetDeviceArchUseCaseTest.java b/controller/app/src/test/java/org/iiab/controller/deviceinfo/domain/GetDeviceArchUseCaseTest.java new file mode 100644 index 0000000..21d68dc --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/deviceinfo/domain/GetDeviceArchUseCaseTest.java @@ -0,0 +1,63 @@ +package org.iiab.controller.deviceinfo.domain; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Pure-JVM tests for the device-arch rule. No Android — uses a fake provider. + */ +public class GetDeviceArchUseCaseTest { + + private static final class FakeProvider implements DeviceAbiProvider { + private final String[] a64; + private final String[] a32; + private final String[] all; + FakeProvider(String[] a64, String[] a32, String[] all) { + this.a64 = a64; + this.a32 = a32; + this.all = all; + } + @Override public String[] supported64BitAbis() { return a64; } + @Override public String[] supported32BitAbis() { return a32; } + @Override public String[] allSupportedAbis() { return all; } + } + + private static String run(String[] a64, String[] a32, String[] all) { + return new GetDeviceArchUseCase(new FakeProvider(a64, a32, all)).execute(); + } + + @Test + public void reports64BitDeviceEvenWhenAppIs32Bit() { + // The bug scenario: a 32-bit app on a 64-bit device. The device IS arm64, + // so the device panel must report arm64-v8a, not the app's 32-bit ABI. + assertEquals("arm64-v8a", run( + new String[]{"arm64-v8a"}, + new String[]{"armeabi-v7a", "armeabi"}, + new String[]{"arm64-v8a", "armeabi-v7a", "armeabi"})); + } + + @Test + public void reports32BitOnlyDevice() { + assertEquals("armeabi-v7a", run( + new String[]{}, + new String[]{"armeabi-v7a", "armeabi"}, + new String[]{"armeabi-v7a", "armeabi"})); + } + + @Test + public void fallsBackToGenericList() { + assertEquals("x86", run(new String[]{}, new String[]{}, new String[]{"x86"})); + } + + @Test + public void unknownWhenNothingReported() { + assertEquals("unknown", run(new String[]{}, new String[]{}, new String[]{})); + assertEquals("unknown", run(null, null, null)); + } + + @Test + public void skipsEmptyLeadingEntries() { + assertEquals("arm64-v8a", run(new String[]{"", "arm64-v8a"}, null, null)); + } +} diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md index c23b2e8..573aa79 100644 --- a/controller/docs/TECH_DEBT_PLAN.md +++ b/controller/docs/TECH_DEBT_PLAN.md @@ -26,6 +26,10 @@ _Last updated: 2026-06-17. Tracks remediation work against the findings below. I - **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. +**Slice — Device architecture (`org.iiab.controller.deviceinfo`): DONE** (refactor-by-feature while fixing a dashboard bug) +- Bug: the dashboard "device architecture" field reported the *app's* ABI (via `nativeLibraryDir`), so a 32-bit build on a 64-bit device wrongly showed 32-bit (we install the 32-bit app on 64-bit hardware to test the 32-bit path). +- Fix: new layered slice — `domain` (`DeviceAbiProvider` port + `GetDeviceArchUseCase`, prefer-64-bit rule, pure JVM) and `data` (`BuildDeviceAbiProvider` reading device-level `Build.SUPPORTED_*_ABIS`). `DashboardFragment` now shows the real device arch; `getTermuxArch()` stays for app/content arch (modules, termux, debian). Unit test `GetDeviceArchUseCaseTest` covers the 32-bit-app-on-64-bit-device case. + **Phases 1–4: NOT STARTED.** Next: Phase 1 security — **D2**, **D6**, **S1**, **S3**, **M4**, **D12**. ## 1. Executive summary