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.
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.*, noandroidx.*, nojava.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:
- Do not put Android or networking dependencies in the Domain layer.
- New features live in their own feature package, split by layer
(
<feature>/domain,<feature>/data,<feature>/presentation). - Business rules (validation, fallbacks, "what counts as valid") belong in use cases, not in the UI or the data source.
- The UI observes state from a
ViewModel; it does not fetch or format data itself. - 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).
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.
- Refactor-by-feature is the default: every time we add or change a feature,
we land it in the layered structure and peel the legacy code it touches into
that structure in the same change. Feature work and refactoring advance together,
in step with the phases in
controller/docs/TECH_DEBT_PLAN.md— never as a separate "we'll clean it up later" task.
The server-lifecycle tangle (ADR-5343: ~17 tickets in ~8 weeks, each adding a state to compensate for one missing owner) is the failure mode these rules prevent. Before adding code:
- Before adding a state, flag, or guard, prove it is not a duplicate truth. Does this fact already have an owner/source in the code? If you are re-deriving a value that lives in another layer, or adding a flag to paper over a race, STOP — that is a design signal, not a coding one. Raise it (a PR comment or an ADR); do not patch it locally.
- One source per fact. If two places answer the same question ("is the server up?", "is an install running?"), the fix is to unify the source, not add a third.
- N tickets orbiting one file/concern is a redesign signal, not another patch. If a file
accumulates many ticket references on the same concern —
K2GO-XXXX, orADFA-XXXXin history predating the Sep 2026 project move (checkgit log/git blame) — raise an ADR before adding patch N+1. - Prefer removing over adding. A change that introduces a distinct state must justify in the PR why it is not a duplicate truth or a race compensator.
- The code-review second pass is a standing pre-merge gate, not reactive. On every PR, beyond the form pass, run the two depth questions: does this add a second place that knows the same thing? does the structural fix have a lifecycle (who sets it, who clears it, what if the process dies)? When a fact is missing (not a case), the fix is usually a design change — surface it.
We often have more than one feature in flight at once on this single repo. To keep two layered migrations from colliding, follow these rules:
- One feature = one package = one branch/PR. Each feature owns
org.iiab.controller.<feature>/{domain,data,presentation}. Code inside a feature package is private to that feature, so two features in different packages almost never produce merge conflicts. Keep new code there, not in shared classes. - Know the conflict hotspots: the files many features must touch: the god
classes (
install/presentation/InstallService,redesign/CloneFragment,redesign/SetupProgressActivity,redesign/LibraryActivity),InstallationPlanner, anything inutil/,build.gradle,AndroidManifest.xml, andres/values/strings.xml. Edits to these must be additive and minimal: add a new overload/method/string rather than changing an existing signature; don't reformat or reorder surrounding code; keep the diff as small as the change allows. - One migrator per legacy class at a time. Two branches must not both carve up the same god class in parallel. If feature B needs a class that feature A is actively migrating, B coordinates with A or waits — use the brief module freeze from the strangler policy, and record who is migrating what in the Design map below (it doubles as the coordination ledger).
- Shared contracts land first. If two features need a common domain type or port, define and merge that small interface on its own first, then both features build against it. Don't duplicate it on two branches.
- One place for strings; no per-feature string files. All UI strings end in
strings.xml(with all locale values). Do NOT createres/values/strings_<feature>.xmlfiles. A scattered per-feature file is more dangerous than a single tracker: to find a string you must first know which feature owns it, and the last feature added is exactly the one you do not know to look in -- so strings get lost. The ONLY external string file isstrings_untranslated.xml: the single, always-known WIP tracker for strings that are user-facing but not yet translated (see the l10n conventions). Park a WIP string there while you work, then migrate it intostrings.xml(all locale values) before the PR merges. A PR closes fully integrated, sostrings_untranslated.xmlshould be near-empty when the next PR opens -- never a growing pile. Edits tostrings.xmlstay additive and append-only (never reorder existing keys) to keep parallel-branch collisions trivial to resolve; discoverability wins over collision-avoidance. - Wire dependencies by hand, per feature. Each feature has its own
…ViewModelFactory/ small factory. There is no shared DI graph for everyone to edit (introducing Hilt/Dagger is a separate ADR), so composition roots don't become a contention point. - Integrate often. Keep branches short-lived; rebase on
mainfrequently and merge small.mainmoves under you — small, frequent integration keeps conflicts tiny. Don't sit on a long-lived branch that touches a hotspot. - No new shared mutable static state. It couples features logically even when
the files don't conflict; prefer state encapsulated in a
ViewModel.
Every branch, commit, and PR in Knowledge to Go development is tied to its Jira issue
(K2GO-XXXX) so the work shows up automatically in that issue's Development panel.
Put the key in all three:
- Branch:
<type>/K2GO-XXXX-<short-kebab-description>—<type>is the change kind (feat,fix,chore,docs,style,refactor). e.g.chore/K2GO-150-footer-powered-by-iiab - Commit: start the subject with the key, then the conventional-commit form —
K2GO-XXXX <type>(<scope>): <description>. e.g.K2GO-150 chore(branding): footer adds "powered by IIAB" - PR title: same as the commit subject (
K2GO-XXXX <type>(<scope>): <description>).
The key in the branch/commit/PR is what links the work back to the issue — keep it exact
(uppercase project key, hyphen, number). Uppercase is required: Jira does not recognise
k2go-123. For GitHub — unlike Bitbucket — a PR is linked by its title or source branch
name; a key in the commit message alone will not attach the PR. One issue can own several
branches/PRs.
Project key history: this project's issues moved from ADFA to K2GO on 1 Sep 2026.
Old ADFA-XXXX keys in existing branches, commits and PR bodies still resolve — Jira keeps
a permanent redirect — so historical references were deliberately left alone and must not be
rewritten. Use K2GO-XXXX for all new work. Note that the ADFA project still exists and
owns the work that did not move; its key must never be renamed or reused, or every redirect
breaks.
Exception (keep Jira low-noise): trivial, APK-neutral changes — typos, minor wording,
routine lint-baseline refresh — may ship as docs:/chore: commits without a per-edit
issue. Reference-worthy docs (plan refreshes, runbooks, ADRs) ride the PR of the work they
describe, or a single standing "Docs maintenance" issue — never a new per-edit ticket.
Rule of thumb: if a change does not add traceability, audit, coordination, or visibility,
it does not get a ticket.
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 thelatest_*.meta4<size>, in-memory cache, returns-1on failure),RootfsCatalog(URL building + hardcoded fallback bytes + ABI detection),RootfsRepositoryImpl.presentation/—RootfsViewModel+RootfsUiState+RootfsViewModelFactory.util/ByteFormatter— shared, pure byte→human formatting.- Presentation wired (DONE):
DeployFragment's projection UI now consumesRootfsViewModeldirectly — it observes the use-case result (live-then-fallback) and feeds the resolved OS size into the projection.InstallationPlannerexposes acalculateProjectedSize(..., osSizeGb, ...)overload for this UI path, so OS-size resolution lives in the presentation layer rather than the planner. - Legacy seam (retained):
InstallationPlanner.resolveOsSizeGb()still backs the oldercalculateProjectedSize(...)overload used by the non-UI install flow (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/—BuildDeviceAbiProviderreadsBuild.SUPPORTED_*_ABIS(device-level, not the app process), so it reports the real hardware arch.- Legacy seam:
DashboardFragmentresolves the device-panel arch through this use case;getTermuxArch()(app/content ABI) is unchanged for modules, termux and debian arch.
Slice (DONE) — sync credential validation (org.iiab.controller.sync.domain)
First Phase-1 security slice (refactor-by-feature while closing tech-debt S1).
The peer-to-peer sync handshake carries host/port/user/pass in a scanned QR code;
those values were interpolated into rsyncd.conf and a rsync:// URL with no
validation, allowing config-directive/URL injection.
domain/—SyncCredentialValidator(pure JVM, no Android): the single rule for "what is a safe sync credential" — strict charsets for user/host, range check for port, control-char-free password, and anisSafeConfigValueguard forrsyncd.confvalues. Unit-tested (sync/domain/SyncCredentialValidatorTest).- Legacy seam:
SyncHandshakeHelper.parsePayload()now validates at the QR parse boundary (returnsnull-> existing "invalid QR" toast), andRsyncManagerre-checks defensively before writing the daemon config / building the client URL. Reusable by the remaining injection fixes (S4, D2) as they migrate.
Slice (DONE) — deploy module-name allowlist (org.iiab.controller.deploy.domain)
Phase-1 security slice closing tech-debt D2 (command injection). The install
queue interpolates a module name into a sed/echo/runrole command run as root
in the container; the name comes from the fixed ModuleRegistry catalog but
round-trips through SharedPreferences.
domain/—ModuleName(pure JVM):isAllowed(name, knownKeys)= the name is a known catalog key AND matches[a-z0-9_-](no shell metacharacters). Unit-tested (deploy/domain/ModuleNameTest).ModuleRegistry.validYamlKeys()is the single allowlist source (derived fromMASTER_ROSTER).- Legacy seam:
DeployFragment.processNextInQueue()validates the popped module and fails closed (logs + skips) before building the command. The command structure is unchanged for legitimate modules.
Slice (DONE) — archive extraction guard (org.iiab.controller.deploy.domain)
Phase-1 security slice closing tech-debt D11 (tar path-traversal). An imported
backup is untrusted; TarExtractor extracted without validating member names.
domain/—ArchiveEntry.escapesRoot(name)(pure JVM): rejects absolute paths and..climbing above the destination root. Unit-tested (ArchiveEntryTest).- Legacy seam:
TarExtractorpre-lists entries (tar -t) and fails closed before extracting if any member escapes; the backup-creationsh -cpipe was single-quoted. Applies to all extractions.
Slice (DONE) — ADB command guard (org.iiab.controller.adb.domain)
Phase-1 security slice closing tech-debt S4 (arbitrary on-device shell via ADB).
domain/—AdbShellCommand.isSafe(command)(pure JVM): rejects shell metacharacters / control chars so only a single self-contained command runs. Unit-tested (AdbShellCommandTest).- Legacy seam:
IIABAdbManager.executeCommandvalidates and fails closed before opening theshell:stream.
Slice (IN PROGRESS) — OTA self-updater (org.iiab.controller.update)
Phase-1 security + functional redesign of the in-app updater (tech-debt F15).
-
domain/—UpdateCheck(is the server build newer?) +CertDigests.sameSigner(pure signer-set comparison). Unit-tested. -
data/ApkVerifier— the downloaded APK must be signed by the same certificate as the running app (public certs only); rejects MITM/tampered or non-APK downloads before install. -
Legacy seam:
MainActivitystages the APK privately, checks the download status, verifies the signature, handles the install permission, and registers the completion receiver NOT_EXPORTED. (PR A.) Presentation/progress UX = PR B. Slice (DONE) — backup import/restore validation (org.iiab.controller.deploy) Enforces the ABI-separation policy + rootfs sanity at the two untrusted gates. -
domain/—ElfClass(32/64 from an ELF header) +RootfsArchive(structural rootfs check + probe-binary picker). Pure, unit-tested. -
data/RootfsArchiveValidator— lists the tar, checks structure, probes one internal binary's ELF class vs the app ABI (Process.is64Bit()). -
Seams: import (
DeployFragment.importBackupSafelyrejects + deletes) and restore (TarExtractor.startExtraction(..., validateRootfs=true, ...)reusing the D11 listing). Hard-block on a definite wrong arch / non-rootfs. -
Identity manifest (soft): reads
installed-rootfs/iiab/.iiab-rootfs.json(docs/ROOTFS_MANIFEST.md) when present (authoritativekind+arch); when absent, a non-blocking "manifest not found" alert + the ELF/structure fallback. -
Pending: the integrity
iiab-tree-sha256-v1check (Result.CORRUPT, mirrorstools/rootfs-builder/iiab_tree_hash.py), the in-app backup-writer emitting both members, and the arbitrary-file attack-vector analysis.
Legacy (NOT yet layered): the redesign retired the original god classes
MainActivity and DeployFragment (they no longer exist; DONE-slice seams above
that still name them are pre-redesign records). The current god-tier classes,
largest first, are:
install/presentation/InstallService(~2.1k LOC): the install orchestrator and the worst offender, mixing proot/process control, networking, persistence, JSON and threading in one service.redesign/SetupProgressActivity(~1.6k LOC): the "finishing setup" screen; it fuses the progress UI with provisioning orchestration (server-up detection, install drain, Kolibri seeding, OperationDispatcher) and threading, andimplements ServerController.Host.redesign/CloneFragment(~1.6k LOC): a single-screen UI god-fragment.- Second tier:
redesign/LibraryActivity(~1.2k),TerminalController(~1.0k),redesign/FqrController(~0.9k),redesign/LibraryHomeFragment(~0.85k),redesign/SetupLibraryActivity(~0.76k). Shared debt across these: public/static cross-class state, hand-rolledHttpURLConnectionin several classes, and inline size formatting.
See ROOTFS_SIZE_PILOT_ANALYSIS.md (repo root) for the detailed change map and
live-size data behind the reference slice.
The app supports light and dark via DayNight (toggle in MainActivity), but most
screens historically hardcoded dark-tuned hex, so the light theme is broken wherever
that happens. We are migrating to one semantic colour-token system, screen by screen
(refactor-by-feature).
Rules for any new or migrated UI:
- No hardcoded colours. No
#RRGGBBin layouts and noColor.parseColor("#…")/Color.WHITEetc. in code. Reference a semantic token instead:@color/<token>in XML,ContextCompat.getColor(ctx, R.color.<token>)in code. - Every token has a light value in
values/colors.xmland a dark twin invalues-night/colors.xml. If you add a token, add both. - Use the semantic families: surfaces (
surface_background,surface_card,surface_section), text (text_primary,text_secondary,text_disabled,text_on_accent),accent/accent_muted, status (status_success/warning/danger/info),divider_line, and data-viz (chart_storage/ram/swap/os/maps/wiki/track). Pick by role, not by look. - Legitimately fixed colours stay fixed: a QR must be black/white to scan, so
qr_foreground/qr_backgroundare mode-independent (no-nightoverride). - Verify both modes on a device before merge.
Legacy tokens (dash_*, white, black, section_*, btn_*) are retired as screens
migrate. Reference migration: the Dashboard (fragment_dashboard + DashboardFragment).
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:
install/presentation/InstallService(~2.1k LOC, the worst: proot + networking + persistence + JSON + threading),redesign/SetupProgressActivity(~1.6k, UI + provisioning + server lifecycle), andredesign/CloneFragment(~1.6k, UI) mix UI, IO, process control and networking. The oldMainActivity/DeployFragmentwere retired by the redesign. Extract cohesive slices into feature packages. - Shared mutable state: public/
staticfields used as cross-class state (e.g. download flags). Prefer encapsulated state in aViewModel. - Duplicated networking:
HttpURLConnectionis still hand-rolled across ~27 classes:InstallationPlanner,Aria2Manager, severalredesign/*fragments (e.g.LibraryHomeFragment,GetMoreHubFragment,ModuleHubFragment), and the per-area*/data/*Clientclasses (Dashboard / Books / Kiwix / Maps / Kolibri / Catalog / Forgejo). 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 (DONE for the rootfs path):
DeployFragmentnow keeps ahasInternetflag (fromcheckInternetAccess()); when offline it skips the live fetch (use-caseattemptLive=false) to avoid the timeout stall, disables the install button ("No connection"), and shows an "Estimated sizes (offline)" caption. Apply the same pattern to other live-network paths as they migrate.