Skip to content

Commit cdfdca3

Browse files
Merge pull request #4 from appdevforall/chore/phase0-guardrails
chore(controller): Phase 0 tech-debt guardrails — tests + CI gate
2 parents b4fb2d1 + 0ede7fc commit cdfdca3

8 files changed

Lines changed: 303 additions & 15 deletions

File tree

‎.github/workflows/android-sanity-check.yml‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,16 @@ jobs:
3131
- name: Grant Execute Permission to Gradlew
3232
run: chmod +x gradlew
3333

34-
# - name: Run Android Lint (Syntax & Rules Check)
35-
# run: ./gradlew lintDebug
34+
# Phase 0 safety net: unit tests are a BLOCKING gate.
35+
- name: Run Unit Tests
36+
run: ./gradlew testDebugUnitTest --stacktrace
37+
38+
# Lint runs for visibility only and never blocks the job (continue-on-error).
39+
# Scoped to :app so the vendored termux-core/upstream lint backlog does not fail CI.
40+
# Once the :app lint backlog is triaged, set abortOnError=true and drop continue-on-error.
41+
- name: Run Android Lint (reporting, non-blocking)
42+
continue-on-error: true
43+
run: ./gradlew :app:lintDebug
3644

3745
- name: Compile Test (Assemble Debug)
3846
run: ./gradlew assembleDebug --stacktrace

‎.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# IDE / editor
2+
.idea/
3+
*.iml
4+
*.iws
5+
*.ipr
6+
7+
# OS
8+
.DS_Store
9+
Thumbs.db

‎controller/app/build.gradle‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ android {
105105
abortOnError false
106106
}
107107

108+
testOptions {
109+
unitTests {
110+
// Let JVM unit tests stub Android framework calls (e.g. android.util.Log)
111+
// so framework-free logic can be tested without an emulator.
112+
returnDefaultValues = true
113+
}
114+
}
115+
108116
dependenciesInfo {
109117
includeInApk = false
110118
includeInBundle = false
@@ -149,6 +157,8 @@ dependencies {
149157

150158
// Testing
151159
testImplementation 'junit:junit:4.13.2'
160+
// Real org.json so SyncHandshakeHelper JSON logic can run in JVM unit tests
161+
testImplementation 'org.json:json:20231013'
152162
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
153163
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
154164
}

‎controller/app/src/main/java/org/iiab/controller/DashboardFragment.java‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -567,12 +567,7 @@ private boolean pingUrl(String urlStr) {
567567

568568
// Extracts the numbers (in kB) from the lines of /proc/meminfo
569569
private long parseMemLine(String line) {
570-
try {
571-
String[] parts = line.split("\\s+");
572-
return Long.parseLong(parts[1]);
573-
} catch (Exception e) {
574-
return 0;
575-
}
570+
return SystemStatsUtil.parseMemLine(line);
576571
}
577572

578573
// --- METHODS FOR OBTAINING IPs ---
@@ -719,13 +714,7 @@ private String getTermuxArch() {
719714
}
720715

721716
private String getDebianArch(String androidArch) {
722-
if (androidArch == null || androidArch.equals("N/A")) return "N/A";
723-
String lower = androidArch.toLowerCase();
724-
725-
if (lower.contains("arm64") || lower.contains("aarch64")) return "arm64";
726-
if (lower.contains("armeabi") || lower.contains("armv7")) return "armhf";
727-
728-
return lower;
717+
return SystemStatsUtil.getDebianArch(androidArch);
729718
}
730719

731720
// Converter from DP to actual screen pixels
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.iiab.controller;
2+
3+
/**
4+
* Framework-free helpers for parsing system statistics.
5+
*
6+
* <p>These functions were extracted from {@code DashboardFragment} so they can be
7+
* unit-tested on the plain JVM (no Android dependencies, no emulator). Keep this
8+
* class free of any {@code android.*} imports.
9+
*/
10+
public final class SystemStatsUtil {
11+
12+
private SystemStatsUtil() {
13+
// Utility class — no instances.
14+
}
15+
16+
/**
17+
* Parses a single {@code /proc/meminfo} line and returns the numeric value (in kB).
18+
*
19+
* <p>A meminfo line looks like {@code "MemTotal: 8127200 kB"}. The value is the
20+
* second whitespace-separated token. Returns {@code 0} for any malformed or null input
21+
* rather than throwing, preserving the original defensive behavior.
22+
*
23+
* @param line a line from {@code /proc/meminfo}, e.g. {@code "MemAvailable: 123456 kB"}
24+
* @return the parsed value in kB, or {@code 0} if the line cannot be parsed
25+
*/
26+
public static long parseMemLine(String line) {
27+
if (line == null) {
28+
return 0;
29+
}
30+
try {
31+
String[] parts = line.trim().split("\\s+");
32+
return Long.parseLong(parts[1]);
33+
} catch (Exception e) {
34+
return 0;
35+
}
36+
}
37+
38+
/**
39+
* Maps an Android/Termux architecture string to the matching Debian architecture name.
40+
*
41+
* @param androidArch the Android architecture (e.g. {@code "aarch64"}, {@code "armv7l"})
42+
* @return the Debian architecture ({@code "arm64"}, {@code "armhf"}), {@code "N/A"} when
43+
* unknown/empty, or the lower-cased input when no mapping applies
44+
*/
45+
public static String getDebianArch(String androidArch) {
46+
if (androidArch == null || androidArch.equals("N/A")) {
47+
return "N/A";
48+
}
49+
String lower = androidArch.toLowerCase();
50+
51+
if (lower.contains("arm64") || lower.contains("aarch64")) {
52+
return "arm64";
53+
}
54+
if (lower.contains("armeabi") || lower.contains("armv7")) {
55+
return "armhf";
56+
}
57+
58+
return lower;
59+
}
60+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package org.iiab.controller;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertNotNull;
5+
import static org.junit.Assert.assertNull;
6+
import static org.junit.Assert.assertTrue;
7+
8+
import org.junit.Test;
9+
10+
/**
11+
* Unit tests for the pure (framework-free) parts of {@link SyncHandshakeHelper}:
12+
* password generation and the QR payload create/parse round-trip.
13+
*
14+
* <p>Relies on {@code testOptions.unitTests.returnDefaultValues = true} (so
15+
* {@code android.util.Log} calls no-op) and the real {@code org.json} test dependency.
16+
*/
17+
public class SyncHandshakeHelperTest {
18+
19+
// --- generateSecurePassword ---
20+
21+
@Test
22+
public void generateSecurePassword_hasExpectedLength() {
23+
assertEquals(12, SyncHandshakeHelper.generateSecurePassword().length());
24+
}
25+
26+
@Test
27+
public void generateSecurePassword_usesOnlyAlphanumericChars() {
28+
String pwd = SyncHandshakeHelper.generateSecurePassword();
29+
assertTrue("password should be alphanumeric: " + pwd, pwd.matches("[A-Za-z0-9]+"));
30+
}
31+
32+
@Test
33+
public void generateSecurePassword_isNotConstant() {
34+
// Extremely unlikely to collide; guards against a degenerate generator.
35+
assertTrue(!SyncHandshakeHelper.generateSecurePassword()
36+
.equals(SyncHandshakeHelper.generateSecurePassword()));
37+
}
38+
39+
// --- createPayload / parsePayload round-trip ---
40+
41+
@Test
42+
public void payload_roundTripsAllFields() {
43+
String payload = SyncHandshakeHelper.createPayload(
44+
"192.168.1.50", 8730, "iiab_peer", "s3cretPass", true, 64);
45+
46+
SyncHandshakeHelper.SyncCredentials creds = SyncHandshakeHelper.parsePayload(payload);
47+
48+
assertNotNull(creds);
49+
assertEquals("192.168.1.50", creds.ip);
50+
assertEquals(8730, creds.port);
51+
assertEquals("iiab_peer", creds.user);
52+
assertEquals("s3cretPass", creds.pass);
53+
assertTrue(creds.hasRootfs);
54+
assertEquals(64, creds.archBits);
55+
}
56+
57+
@Test
58+
public void parsePayload_returnsNullForNonIiabJson() {
59+
assertNull(SyncHandshakeHelper.parsePayload("{\"app\":\"some_other_app\"}"));
60+
}
61+
62+
@Test
63+
public void parsePayload_returnsNullForMalformedJson() {
64+
assertNull(SyncHandshakeHelper.parsePayload("this is not json"));
65+
}
66+
67+
@Test
68+
public void parsePayload_returnsNullForNull() {
69+
assertNull(SyncHandshakeHelper.parsePayload(null));
70+
}
71+
72+
@Test
73+
public void parsePayload_defaultsHasRootfsTrueForLegacyPayload() {
74+
// Legacy payloads without "has_rootfs" should default to true.
75+
String legacy = "{\"app\":\"iiab_sync\",\"ip\":\"10.0.0.1\",\"port\":8730,"
76+
+ "\"user\":\"u\",\"pass\":\"p\"}";
77+
SyncHandshakeHelper.SyncCredentials creds = SyncHandshakeHelper.parsePayload(legacy);
78+
assertNotNull(creds);
79+
assertTrue(creds.hasRootfs);
80+
assertEquals(0, creds.archBits);
81+
}
82+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package org.iiab.controller;
2+
3+
import static org.junit.Assert.assertEquals;
4+
5+
import org.junit.Test;
6+
7+
/**
8+
* Unit tests for {@link SystemStatsUtil}. These run on the plain JVM — no emulator.
9+
* This is the first safety-net test added in Phase 0 of the tech-debt remediation plan.
10+
*/
11+
public class SystemStatsUtilTest {
12+
13+
// --- parseMemLine ---
14+
15+
@Test
16+
public void parseMemLine_parsesStandardMeminfoLine() {
17+
assertEquals(8127200L, SystemStatsUtil.parseMemLine("MemTotal: 8127200 kB"));
18+
}
19+
20+
@Test
21+
public void parseMemLine_parsesAvailableLine() {
22+
assertEquals(123456L, SystemStatsUtil.parseMemLine("MemAvailable: 123456 kB"));
23+
}
24+
25+
@Test
26+
public void parseMemLine_handlesLeadingAndTrailingWhitespace() {
27+
assertEquals(42L, SystemStatsUtil.parseMemLine(" SwapFree: 42 kB "));
28+
}
29+
30+
@Test
31+
public void parseMemLine_returnsZeroForNull() {
32+
assertEquals(0L, SystemStatsUtil.parseMemLine(null));
33+
}
34+
35+
@Test
36+
public void parseMemLine_returnsZeroForEmpty() {
37+
assertEquals(0L, SystemStatsUtil.parseMemLine(""));
38+
}
39+
40+
@Test
41+
public void parseMemLine_returnsZeroWhenSecondTokenNotNumeric() {
42+
assertEquals(0L, SystemStatsUtil.parseMemLine("MemTotal: notANumber kB"));
43+
}
44+
45+
@Test
46+
public void parseMemLine_returnsZeroWhenNoSecondToken() {
47+
assertEquals(0L, SystemStatsUtil.parseMemLine("MemTotal:"));
48+
}
49+
50+
// --- getDebianArch ---
51+
52+
@Test
53+
public void getDebianArch_mapsAarch64ToArm64() {
54+
assertEquals("arm64", SystemStatsUtil.getDebianArch("aarch64"));
55+
}
56+
57+
@Test
58+
public void getDebianArch_mapsArm64VariantToArm64() {
59+
assertEquals("arm64", SystemStatsUtil.getDebianArch("ARM64-v8a"));
60+
}
61+
62+
@Test
63+
public void getDebianArch_mapsArmeabiToArmhf() {
64+
assertEquals("armhf", SystemStatsUtil.getDebianArch("armeabi-v7a"));
65+
}
66+
67+
@Test
68+
public void getDebianArch_mapsArmv7ToArmhf() {
69+
assertEquals("armhf", SystemStatsUtil.getDebianArch("armv7l"));
70+
}
71+
72+
@Test
73+
public void getDebianArch_returnsNaForNull() {
74+
assertEquals("N/A", SystemStatsUtil.getDebianArch(null));
75+
}
76+
77+
@Test
78+
public void getDebianArch_returnsNaForNaSentinel() {
79+
assertEquals("N/A", SystemStatsUtil.getDebianArch("N/A"));
80+
}
81+
82+
@Test
83+
public void getDebianArch_lowercasesUnknownArch() {
84+
assertEquals("x86_64", SystemStatsUtil.getDebianArch("X86_64"));
85+
}
86+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Termux Fork — Delta Analysis (iiab changes vs upstream)
2+
3+
> Scope: technical debt **only in what iiab modified** in the vendored Termux fork at `controller/termux-core/termux-source` (submodule → `github.com/iiab/termux-app`). Upstream code is out of scope. Date: 2026-06-16.
4+
5+
## 1. What iiab actually changed
6+
7+
The fork's HEAD (`f8f36614`) sits **8 commits** above upstream base `30ebb2de`, all authored by `Ark74`. The **net delta is a single method** — `ExtraKeysView.loadIIABDefaultKeys()`, +25 lines in:
8+
9+
```
10+
termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java
11+
```
12+
13+
It builds a hardcoded extra-keys layout and calls the existing public `reload(ExtraKeysInfo, float)`. It is called once, from `controller/app/.../MainActivity.java:2036`, right before wiring the extra-keys click listener.
14+
15+
The 8 commits are: a 44-line feature commit, a "decouple" commit that removed 24 lines, and six small "fix: yet another change pt2…pt5" tweaks that net to the final 25-line method.
16+
17+
## 2. Findings (scoped to the delta)
18+
19+
Scoring: **Priority = (Impact + Risk) × (6 − Effort)**, each 1–5.
20+
21+
| ID | Location | Category | Issue | Imp | Risk | Eff | Prio |
22+
|----|----------|----------|-------|----|----|----|----|
23+
| K1 | `ExtraKeysView.java:682–706` | Architecture / fork maintenance | The change lives **inside an upstream file** but uses only public APIs (`reload`, public `ExtraKeysInfo` ctor, public `ExtraKeyDisplayMap`). The same result is achievable entirely from the controller app — so the fork need not modify upstream source at all. Every upstream sync will now conflict on this file. | 4 | 3 | 2 | 28 |
24+
| K2 | 8 commits `e6c7b88d..f8f36614` | Documentation / process | Unreviewable history: five commits named `fix: yet another change pt2…pt5` with no body, netting 25 lines. Can't bisect, cherry-pick, or review intent. | 3 | 2 | 1 | 25 |
25+
| K3 | `ExtraKeysView.java:688–692` | Code | Keyboard layout is a **hardcoded inline pseudo-JSON string** in Java; the comment "we match the same Termux keys" admits manual drift. Duplicates Termux's normal properties-driven layout and the controller's own ESC/TAB/… key-handling switch (`MainActivity.java:~2040+`). | 2 | 2 | 2 | 16 |
26+
| K4 | `ExtraKeysView.java:702–704` | Code | Broad `catch (Exception e)` logs and continues; if layout construction fails the user silently gets **no extra keys** with no fallback to upstream defaults. | 2 | 2 | 1 | 20 |
27+
| K5 | `ExtraKeysView.java:686` (no test) | Test | The layout string's validity (parses into a valid `ExtraKeysInfo`) is untested. Once the literal moves into the app as a constant, it becomes a trivial JVM unit test. | 2 | 2 | 2 | 16 |
28+
| K6 | `ExtraKeysView.java:682–686, 703` | Code (cosmetic) | Malformed Javadoc (`/**` at column 0 while body is indented), decorative `===` banner comments, and fully-qualified `android.util.Log` instead of an import. The inline-FQN/no-import-change is actually a reasonable merge-conflict-minimization tactic — only matters once the code relocates. | 1 | 1 | 1 | 10 |
29+
30+
## 3. Top recommendation — relocate the change out of upstream (K1)
31+
32+
This single move resolves the core maintainability problem and directly supports the submodule/build work just completed.
33+
34+
1. Move the body of `loadIIABDefaultKeys()` into the controller app — e.g. a small helper that builds the `ExtraKeysInfo` (layout as a named constant or `R.string`/resource, addressing **K3**) and calls `extraKeysView.reload(iiabKeysInfo, 0f)` directly. `MainActivity.java:2036` already holds the `extraKeysView` reference, so the call site barely changes.
35+
2. Revert `ExtraKeysView.java` to its upstream contents and **pin the submodule to a clean upstream tag**. The fork becomes a pristine mirror → upstream upgrades stop conflicting.
36+
3. Add a fallback (K4): if the custom layout fails to build, fall back to Termux's default keys rather than showing none.
37+
4. Squash the 8 commits into one well-described commit before any further sharing (**K2**).
38+
5. Add the layout-validity unit test once the constant lives in the app (**K5**), fitting the Phase 0 safety-net pattern.
39+
40+
Net effect: **zero iiab modifications to vendored upstream**, a single-source-of-truth layout shared with the controller's key handling, and a fork that tracks upstream cleanly.
41+
42+
## 4. Note
43+
44+
The earlier `controller/app` analysis (`TECH_DEBT_PLAN.md`) is unaffected by the submodule now being present — those 34 files are unchanged. The submodule only matters for building (dependency resolution), which is handled on-device via Android Studio and in CI.

0 commit comments

Comments
 (0)