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
20 changes: 14 additions & 6 deletions .github/workflows/android-sanity-check.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
name: Android Sanity Check (CI)

on:
# One run per PR (pull_request) and one post-merge on the integration branches.
# Previously push:["**"] also ran on every feature-branch push, so an open PR
# got two redundant concurrent runs. Feature branches are covered by their PR.
push:
branches: [ "**" ]
branches: [ "main", "development" ]
pull_request:
branches: [ "main", "development" ]

# Supersede in-flight runs: when a newer commit lands on the same branch/PR, cancel
# the older run so a fixup doesn't leave a zombie ~50-min lint job running.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check-syntax:
name: Lint & Compile Check
Expand Down Expand Up @@ -35,11 +44,10 @@ jobs:
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest --stacktrace

# Lint runs for visibility only and never blocks the job (continue-on-error).
# Scoped to :app so the vendored termux-core/upstream lint backlog does not fail CI.
# Once the :app lint backlog is triaged, set abortOnError=true and drop continue-on-error.
- name: Run Android Lint (reporting, non-blocking)
continue-on-error: true
# Lint is a BLOCKING gate for :app. The existing backlog is grandfathered via the
# committed app/lint-baseline.xml; any NEW lint error fails CI. Scoped to :app so
# the vendored termux-core/upstream backlog does not block.
- name: Run Android Lint (:app, blocking)
run: ./gradlew :app:lintDebug

- name: Compile Test (Assemble Debug)
Expand Down
10 changes: 9 additions & 1 deletion controller/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,15 @@ android {

lintOptions {
checkReleaseBuilds false
abortOnError false
// Phase 0: lint is a hard gate for :app. The current backlog is grandfathered
// via the committed baseline below, so existing issues don't block the build,
// but any NEW lint error fails it. Shrink the baseline as the backlog is fixed.
abortOnError true
baseline file("lint-baseline.xml")
// Missing translations in secondary locales (fr/hi/pt/ru) should not block
// the build — en/es are primary and translations land incrementally. Still
// reported as a warning so they stay visible.
warning 'MissingTranslation'
}

testOptions {
Expand Down
5,133 changes: 5,133 additions & 0 deletions controller/app/lint-baseline.xml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
import com.google.android.material.snackbar.Snackbar;

import org.iiab.controller.util.LocalVarsYamlParser;
import org.iiab.controller.rootfs.domain.RootfsAbi;
import org.iiab.controller.rootfs.domain.RootfsTier;
import org.iiab.controller.rootfs.presentation.RootfsUiState;
Expand Down Expand Up @@ -1629,24 +1630,10 @@ private void fetchLocalVarsFromPRoot() {
}

private JSONObject parseYamlToJson(String yaml) {
JSONObject json = new JSONObject();
String[] lines = yaml.split("\n");
for (String line : lines) {
if (line.contains(":") && !line.trim().startsWith("#")) {
String[] parts = line.split(":", 2);
String key = parts[0].trim();
String val = parts[1].trim().toLowerCase();

if (key.endsWith("_install") || key.endsWith("_enabled")) {
try {
boolean isTrue = val.equals("true") || val.equals("yes") || val.equals("1");
json.put(key, isTrue);
} catch (Exception ignored) {
}
}
}
}
return json;
// Delegates to the pure, unit-tested util (extracted from this god class).
// The naive split-on-':' behavior is unchanged; replacing it with a real
// YAML parser is still tracked as tech-debt D14.
return LocalVarsYamlParser.parseToJson(yaml);
}

private void verifyInstallationState(JSONObject jsonVars) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.iiab.controller.util;

import org.json.JSONObject;

import java.util.Locale;

/**
* Minimal reader for the slice of {@code local_vars.yml} the app cares about:
* top-level {@code <module>_install} / {@code <module>_enabled} boolean flags.
*
* <p>Pure logic (no Android, no I/O) so it is JVM-unit-testable. It is
* intentionally <strong>not</strong> a general YAML parser — it splits on the
* first {@code :} and does not handle nesting, quoting, block scalars or inline
* comments. That limitation is tracked as tech-debt <strong>D14</strong>; this
* extraction is the first step (isolate + test) toward replacing it with a real
* YAML library.
*/
public final class LocalVarsYamlParser {

private LocalVarsYamlParser() {
}

/**
* Parses {@code _install}/{@code _enabled} flags into a {@link JSONObject} of
* key → boolean. A value counts as {@code true} when it is {@code true},
* {@code yes} or {@code 1} (case-insensitive). Lines that are blank, comments
* ({@code #}) or unrelated keys are ignored. Never returns {@code null}.
*/
public static JSONObject parseToJson(String yaml) {
JSONObject json = new JSONObject();
if (yaml == null) {
return json;
}
for (String line : yaml.split("\n")) {
if (!line.contains(":") || line.trim().startsWith("#")) {
continue;
}
String[] parts = line.split(":", 2);
String key = parts[0].trim();
String val = parts[1].trim().toLowerCase(Locale.ROOT);
if (key.endsWith("_install") || key.endsWith("_enabled")) {
boolean isTrue = val.equals("true") || val.equals("yes") || val.equals("1");
try {
json.put(key, isTrue);
} catch (Exception ignored) {
// JSONObject.put only throws on a null key, which cannot happen here.
}
}
}
return json;
}
}
4 changes: 4 additions & 0 deletions controller/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,8 @@
<string name="sync_app_arch_label">Arch. de l\'App : %1$d-bit</string>
<string name="sync_msg_arch_compatible">Matériel compatible. Démarrage de la connexion...</string>

<string name="install_btn_no_connection">Pas de connexion</string>
<string name="install_msg_no_connection">Pas de connexion Internet — connectez-vous pour télécharger.</string>
<string name="install_offline_estimated">Tailles estimées (hors ligne)</string>
<string name="rsync_error_invalid_credentials">Identifiants de synchronisation invalides ou non sécurisés. Veuillez scanner un QR code de synchronisation IIAB valide.</string>
</resources>
4 changes: 4 additions & 0 deletions controller/app/src/main/res/values-hi/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,8 @@
<string name="sync_app_arch_label">App Arch: %1$d-बिट</string>
<string name="sync_msg_arch_compatible">हार्डवेयर संगत है। कनेक्शन शुरू हो रहा है...</string>

<string name="install_btn_no_connection">कोई कनेक्शन नहीं</string>
<string name="install_msg_no_connection">इंटरनेट कनेक्शन नहीं — डाउनलोड करने के लिए कनेक्ट करें।</string>
<string name="install_offline_estimated">अनुमानित आकार (ऑफ़लाइन)</string>
<string name="rsync_error_invalid_credentials">अमान्य या असुरक्षित सिंक क्रेडेंशियल। कृपया एक मान्य IIAB सिंक QR कोड स्कैन करें।</string>
</resources>
4 changes: 4 additions & 0 deletions controller/app/src/main/res/values-pt/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -499,4 +499,8 @@
<string name="sync_app_arch_label">Arq. do App: %1$d-bit</string>
<string name="sync_msg_arch_compatible">Hardware compatível. Iniciando conexão...</string>

<string name="install_btn_no_connection">Sem conexão</string>
<string name="install_msg_no_connection">Sem conexão à internet — conecte-se para baixar.</string>
<string name="install_offline_estimated">Tamanhos estimados (offline)</string>
<string name="rsync_error_invalid_credentials">Credenciais de sincronização inválidas ou inseguras. Por favor, escaneie um QR code de sincronização IIAB válido.</string>
</resources>
4 changes: 4 additions & 0 deletions controller/app/src/main/res/values-ru-rRU/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -496,4 +496,8 @@
<string name="sync_app_arch_label">Архитектура App: %1$d-бит</string>
<string name="sync_msg_arch_compatible">Оборудование совместимо. Запуск подключения...</string>

<string name="install_btn_no_connection">Нет соединения</string>
<string name="install_msg_no_connection">Нет подключения к интернету — подключитесь для загрузки.</string>
<string name="install_offline_estimated">Оценочные размеры (офлайн)</string>
<string name="rsync_error_invalid_credentials">Недействительные или небезопасные учётные данные синхронизации. Отсканируйте действительный QR-код синхронизации IIAB.</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.iiab.controller.util;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import org.json.JSONObject;
import org.junit.Test;

/** Pure-JVM tests for the local_vars.yml flag reader. */
public class LocalVarsYamlParserTest {

@Test
public void readsInstallAndEnabledTrueVariants() {
JSONObject j = LocalVarsYamlParser.parseToJson(
"kiwix_install: True\n" +
"kiwix_enabled: yes\n" +
"kolibri_install: 1\n");
assertTrue(j.optBoolean("kiwix_install"));
assertTrue(j.optBoolean("kiwix_enabled"));
assertTrue(j.optBoolean("kolibri_install"));
}

@Test
public void readsFalseValues() {
JSONObject j = LocalVarsYamlParser.parseToJson("kiwix_install: False\nkiwix_enabled: no\n");
assertFalse(j.optBoolean("kiwix_install", true));
assertFalse(j.optBoolean("kiwix_enabled", true));
}

@Test
public void ignoresCommentsBlanksAndUnrelatedKeys() {
JSONObject j = LocalVarsYamlParser.parseToJson(
"# kiwix_install: True\n" +
"\n" +
"some_other_key: True\n" +
"version: 3.2\n");
assertFalse(j.has("kiwix_install")); // commented out
assertFalse(j.has("some_other_key")); // not an _install/_enabled flag
assertFalse(j.has("version"));
}

@Test
public void handlesValueContainingColon() {
// split(":", 2) keeps everything after the first colon as the value.
JSONObject j = LocalVarsYamlParser.parseToJson("note_enabled: true # at 10:30\n");
// value is "true # at 10:30" -> not exactly "true", so it reads false.
assertFalse(j.optBoolean("note_enabled", true));
}

@Test
public void handlesNullAndEmpty() {
assertFalse(LocalVarsYamlParser.parseToJson(null).keys().hasNext());
assertFalse(LocalVarsYamlParser.parseToJson("").keys().hasNext());
}
}
7 changes: 4 additions & 3 deletions controller/docs/TECH_DEBT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ _Last updated: 2026-06-17. Tracks remediation work against the findings below. I

**Phase 0 — Guardrails: DONE** (PR `chore/phase0-guardrails`, merged as #4)
- Extracted `SystemStatsUtil` and added the first JVM unit tests (`SystemStatsUtilTest`, `SyncHandshakeHelperTest`); added unit-test infra (`returnDefaultValues` + real `org.json`). Addresses **M10**.
- CI gate: blocking `testDebugUnitTest`; `:app:lintDebug` runs non-blocking (scoped to `:app`). Addresses **M11**.
- CI gate: blocking `testDebugUnitTest`, blocking `:app:lintDebug` (grandfathered via a committed `lint-baseline.xml`), and the `assembleDebug` compile gate. Addresses **M11**.
- Added a root `.gitignore` and `FORK_DELTA_ANALYSIS.md`.
- Remaining: flip lint `abortOnError=true` once the `:app` lint backlog is triaged; broaden tests to more pure functions (`LogManager.getFormattedSize`, `InstallationPlanner` sizing, the YAML parser).
- **Hardening (DONE):** lint is now a hard gate for `:app` — `abortOnError true` + committed `lint-baseline.xml`, and CI dropped `continue-on-error`. Existing backlog grandfathered; new lint errors fail the build. (Generate/commit the baseline once: `./gradlew :app:lintDebug`.)
- **Tests broadened (DONE):** extracted the `local_vars.yml` reader to a pure, unit-tested `util/LocalVarsYamlParser` (`LocalVarsYamlParserTest`) — the "YAML parser" item and first step on tech-debt **D14**. Notes on the other two named targets: `LogManager.getFormattedSize` is Android-coupled (`Context` + string resources) and its byte→human formatting is already covered by the tested `util/ByteFormatter`; `InstallationPlanner` OS sizing moved into the rootfs domain (covered by `GetRootfsSizeUseCaseTest` / `ByteFormatterTest`), so no separate pure sizing remains there to test.

**K1 — Fork delta (Termux ExtraKeys): DONE** (PR `feat/k1-extrakeys-in-app`, merged as #5; details in `FORK_DELTA_ANALYSIS.md`)
- **K1**: `loadIIABDefaultKeys()` moved out of upstream `ExtraKeysView` into app `IIABExtraKeys` (public APIs only). DONE.
- **K3**: layout is now a single-source-of-truth constant. DONE.
- **K4**: falls back to a minimal layout if the default fails to load. DONE.
- **K5**: unit test validating the layout grid (`IIABExtraKeysTest`). DONE.
- Remaining: point the submodule to `appdevforall/termux-app` at clean upstream and commit the pointer.
- Submodule now points to `appdevforall/termux-app` with a committed SHA (`30ebb2d`, v0.117-436); pointer is in place. **K2** (squash the 8 messy K1 commits) is superseded — that history already merged to `main` via #5, so rewriting it is not worth it. **K6** is cosmetic, upstream-only (priority 10).

**Reference slice — Rootfs live sizes (Clean Architecture pilot): DONE** (PR #6, merged)
- First feature built across all three layers (`org.iiab.controller.rootfs.{domain,data,presentation}` + `util/ByteFormatter`); serves as the copy-paste template for future slices. See root `CLAUDE.md` (design map) and `ROOTFS_SIZE_PILOT_ANALYSIS.md`.
Expand Down
4 changes: 4 additions & 0 deletions controller/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
android.enableJetifier=true
android.useAndroidX=true
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC
# Build cache + parallel modules: lets unchanged tasks (incl. lint analysis) be
# reused across runs and builds independent modules concurrently. Safe defaults.
org.gradle.caching=true
org.gradle.parallel=true
Loading