_enabled} boolean flags.
+ *
+ * Pure logic (no Android, no I/O) so it is JVM-unit-testable. It is
+ * intentionally not 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 D14; 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;
+ }
+}
diff --git a/controller/app/src/main/res/values-fr/strings.xml b/controller/app/src/main/res/values-fr/strings.xml
index bd2a698..7950569 100644
--- a/controller/app/src/main/res/values-fr/strings.xml
+++ b/controller/app/src/main/res/values-fr/strings.xml
@@ -497,4 +497,8 @@
Arch. de l\'App : %1$d-bit
Matériel compatible. Démarrage de la connexion...
+ Pas de connexion
+ Pas de connexion Internet — connectez-vous pour télécharger.
+ Tailles estimées (hors ligne)
+ Identifiants de synchronisation invalides ou non sécurisés. Veuillez scanner un QR code de synchronisation IIAB valide.
\ No newline at end of file
diff --git a/controller/app/src/main/res/values-hi/strings.xml b/controller/app/src/main/res/values-hi/strings.xml
index 64b6f0d..eb0f935 100644
--- a/controller/app/src/main/res/values-hi/strings.xml
+++ b/controller/app/src/main/res/values-hi/strings.xml
@@ -497,4 +497,8 @@
App Arch: %1$d-बिट
हार्डवेयर संगत है। कनेक्शन शुरू हो रहा है...
+ कोई कनेक्शन नहीं
+ इंटरनेट कनेक्शन नहीं — डाउनलोड करने के लिए कनेक्ट करें।
+ अनुमानित आकार (ऑफ़लाइन)
+ अमान्य या असुरक्षित सिंक क्रेडेंशियल। कृपया एक मान्य IIAB सिंक QR कोड स्कैन करें।
\ No newline at end of file
diff --git a/controller/app/src/main/res/values-pt/strings.xml b/controller/app/src/main/res/values-pt/strings.xml
index 1e88929..015cf9f 100644
--- a/controller/app/src/main/res/values-pt/strings.xml
+++ b/controller/app/src/main/res/values-pt/strings.xml
@@ -499,4 +499,8 @@
Arq. do App: %1$d-bit
Hardware compatível. Iniciando conexão...
+ Sem conexão
+ Sem conexão à internet — conecte-se para baixar.
+ Tamanhos estimados (offline)
+ Credenciais de sincronização inválidas ou inseguras. Por favor, escaneie um QR code de sincronização IIAB válido.
\ No newline at end of file
diff --git a/controller/app/src/main/res/values-ru-rRU/strings.xml b/controller/app/src/main/res/values-ru-rRU/strings.xml
index 92b9330..a2d39ee 100644
--- a/controller/app/src/main/res/values-ru-rRU/strings.xml
+++ b/controller/app/src/main/res/values-ru-rRU/strings.xml
@@ -496,4 +496,8 @@
Архитектура App: %1$d-бит
Оборудование совместимо. Запуск подключения...
+ Нет соединения
+ Нет подключения к интернету — подключитесь для загрузки.
+ Оценочные размеры (офлайн)
+ Недействительные или небезопасные учётные данные синхронизации. Отсканируйте действительный QR-код синхронизации IIAB.
\ No newline at end of file
diff --git a/controller/app/src/test/java/org/iiab/controller/util/LocalVarsYamlParserTest.java b/controller/app/src/test/java/org/iiab/controller/util/LocalVarsYamlParserTest.java
new file mode 100644
index 0000000..71cea66
--- /dev/null
+++ b/controller/app/src/test/java/org/iiab/controller/util/LocalVarsYamlParserTest.java
@@ -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());
+ }
+}
diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md
index 28b82f7..b89c209 100644
--- a/controller/docs/TECH_DEBT_PLAN.md
+++ b/controller/docs/TECH_DEBT_PLAN.md
@@ -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`.
diff --git a/controller/gradle.properties b/controller/gradle.properties
index d034c3b..2dd6b92 100644
--- a/controller/gradle.properties
+++ b/controller/gradle.properties
@@ -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