diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java new file mode 100644 index 00000000..2c815c21 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java @@ -0,0 +1,139 @@ +/* + * ============================================================================ + * Name : ChannelId.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain rule: what counts as a usable Kolibri channel or node + * identifier, and how to normalise one. Pure JVM (ADFA-4954). + * ============================================================================ + */ +package org.iiab.controller.kolibri.domain; + +/** + * Guard for Kolibri channel and content-node identifiers. + * + *
Kolibri identifies channels and nodes by a UUID rendered as 32 lowercase + * hex characters without dashes. Two different things get confused with it and + * both fail in ways that are hard to diagnose: + * + *
Beyond correctness this is a safety boundary, the same one + * {@link org.iiab.controller.deploy.domain.ModuleName} draws for module names: + * an identifier reaching this class ends up in a request the box acts on, so + * anything not explicitly allowed is rejected. Fail-closed. + * + *
No {@code android.*} and no HTTP, so it is unit-testable on a plain JVM. + */ +public final class ChannelId { + + /** A Kolibri UUID is exactly 32 hex characters once the dashes are gone. */ + private static final int LENGTH = 32; + + private ChannelId() { + // Static utility; not instantiable. + } + + /** + * Canonical form of {@code raw}: trimmed, dashes removed, lowercased — or + * {@code null} if the result is not a valid identifier. + * + *
Returning {@code null} rather than throwing keeps the common + * "filter out what is not usable" path free of exception handling; callers + * that need to fail loudly should check and raise their own error. + */ + public static String normalise(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(trimmed.length()); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (c == '-') { + continue; + } + // Uppercase hex is a legitimate way to write a UUID; fold it. + if (c >= 'A' && c <= 'F') { + c = (char) (c - 'A' + 'a'); + } + sb.append(c); + } + + String candidate = sb.toString(); + return isCanonical(candidate) ? candidate : null; + } + + /** + * True if {@code raw} can be normalised into a usable identifier. Accepts the + * dashed and uppercase spellings; rejects tokens, empty input and anything + * that is not hex. + */ + public static boolean isValid(String raw) { + return normalise(raw) != null; + } + + /** + * True if {@code value} is already in canonical form: exactly 32 + * lowercase hex characters. Used to assert on values that should have been + * normalised earlier rather than normalising them again silently. + */ + public static boolean isCanonical(String value) { + if (value == null || value.length() != LENGTH) { + return false; + } + for (int i = 0; i < LENGTH; i++) { + char c = value.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + if (!hex) { + return false; + } + } + return true; + } + + /** + * True if {@code raw} looks like a Studio channel token rather than + * an id: ten characters of {@code [a-z0-9]}, optionally split by a dash into + * two groups of five. + * + *
Only used to tell the user why their input was rejected — "that + * is a token, resolve it first" beats "invalid identifier". It deliberately + * does not accept tokens as ids. + */ + public static boolean looksLikeToken(String raw) { + if (raw == null) { + return false; + } + String s = raw.trim().toLowerCase(); + if (s.length() == 11 && s.charAt(5) == '-') { + s = s.substring(0, 5) + s.substring(6); + } + if (s.length() != 10) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + } + // A 10-char all-hex string is ambiguous in principle, but it can never be + // a valid id (those are 32), so treating it as a token is the useful read. + return true; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java new file mode 100644 index 00000000..be26f322 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java @@ -0,0 +1,158 @@ +/* + * ============================================================================ + * Name : ChannelSelection.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain rule: what the user asked to seed from one Kolibri + * channel — the whole thing, or named subtrees. Pure JVM + * (ADFA-4954). + * ============================================================================ + */ +package org.iiab.controller.kolibri.domain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +/** + * One channel the device should seed, optionally narrowed to a set of subtrees. + * + *
The distinction this type exists to protect is a real trap in Kolibri's API: + * + *
Those two are one keystroke apart and fail silently, so the difference is + * encoded in the type rather than left to each call site: {@link #wholeChannel} + * builds the first, {@link #ofSubtrees} the second, and {@code ofSubtrees} + * refuses to produce an empty selection at all. + * + *
Instances are immutable and always canonical: ids are normalised on the way + * in, invalid ones rejected, duplicates dropped, insertion order kept so the + * request is stable and diffable. + * + *
No {@code android.*} and no HTTP, so it is unit-testable on a plain JVM.
+ */
+public final class ChannelSelection {
+
+ private final String channelId;
+ private final List Only worth it for a partial selection: without it the topics the user did
+ * not pick have no artwork and the browsing screen looks broken. A whole
+ * channel already brings them, so asking again is pure extra download.
+ */
+ public boolean wantsAllThumbnails() {
+ return !isWholeChannel();
+ }
+
+ @Override
+ public String toString() {
+ return isWholeChannel()
+ ? "ChannelSelection{" + channelId + ", whole channel}"
+ : "ChannelSelection{" + channelId + ", " + nodeIds.size() + " subtree(s)}";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ChannelSelection)) {
+ return false;
+ }
+ ChannelSelection other = (ChannelSelection) o;
+ return channelId.equals(other.channelId) && nodeIds.equals(other.nodeIds);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * channelId.hashCode() + nodeIds.hashCode();
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java
new file mode 100644
index 00000000..3853ff97
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java
@@ -0,0 +1,209 @@
+/*
+ * ============================================================================
+ * Name : SeedPlan.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : Domain rule: the set of Kolibri channels queued for seeding and
+ * whether it fits on the device. Pure JVM (ADFA-4954).
+ * ============================================================================
+ */
+package org.iiab.controller.kolibri.domain;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * What the device has been asked to seed, as one unit.
+ *
+ * Holds the ordered selections plus each one's published size so the app can
+ * answer "does this fit?" before committing to a download. That question
+ * has to be answered here rather than left to Kolibri: Kolibri subtracts a
+ * configurable cushion ({@code MINIMUM_DISK_SPACE}, 250 MB by default) when it
+ * computes free space, so it can report zero while the filesystem still has room,
+ * and it only finds out mid-transfer.
+ *
+ * Sizes are the published size of the whole channel, which is what the
+ * offline catalog asset carries. For a partial selection that is an
+ * over-estimate — see {@link #isEstimateExact()} — so a plan that does not fit is
+ * a warning, not a verdict. The exact figure for a subtree needs the box:
+ * {@code POST /k2go-api/kolibri/estimate}.
+ *
+ * Immutable. No {@code android.*}, so it is unit-testable on a plain JVM.
+ */
+public final class SeedPlan {
+
+ /**
+ * Head-room kept free on top of the estimate, as a percentage. Content is not
+ * the only thing writing to the device while an import runs.
+ */
+ public static final int DEFAULT_MARGIN_PERCENT = 110;
+
+ private final List Later entries for a channel already present replace the earlier one
+ * rather than queueing it twice: importing the same channel concurrently only
+ * creates contention on the same SQLite file. Order of first appearance is
+ * kept.
+ *
+ * @param selections what to seed; null or empty yields {@link #empty()}
+ * @param bytesByChannel published size per channel id; a channel missing from
+ * the map, or mapped to a non-positive value, counts as
+ * unknown and makes the total inexact
+ */
+ public static SeedPlan of(List A partial selection makes the total an over-estimate, because the catalog
+ * only carries whole-channel sizes. Callers should present an inexact total as
+ * an upper bound, and should not refuse to start on the strength of it alone.
+ */
+ public boolean isEstimateExact() {
+ if (selections.isEmpty()) {
+ return true;
+ }
+ if (channelsWithUnknownSize() > 0) {
+ return false;
+ }
+ for (ChannelSelection s : selections) {
+ if (!s.isWholeChannel()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Bytes required including the default head-room. */
+ public long requiredBytes() {
+ return requiredBytes(DEFAULT_MARGIN_PERCENT);
+ }
+
+ /**
+ * Bytes required including {@code marginPercent} head-room, e.g. 110 for ten
+ * per cent extra. A margin below 100 is treated as 100: never plan to use more
+ * of the disk than the content actually needs.
+ */
+ public long requiredBytes(int marginPercent) {
+ int margin = Math.max(100, marginPercent);
+ return estimatedBytes() / 100L * margin;
+ }
+
+ /**
+ * Whether the plan fits in {@code freeBytes}.
+ *
+ * {@code null} means "cannot tell" — either the free space is unknown or
+ * the estimate is not exact — and is deliberately different from
+ * {@link Boolean#FALSE}. A caller that treats "cannot tell" as "does not fit"
+ * would refuse to seed a small subtree of a large channel, which is exactly
+ * the case a phone needs most.
+ */
+ public Boolean fitsIn(Long freeBytes) {
+ return fitsIn(freeBytes, DEFAULT_MARGIN_PERCENT);
+ }
+
+ /** @see #fitsIn(Long) */
+ public Boolean fitsIn(Long freeBytes, int marginPercent) {
+ if (isEmpty()) {
+ return Boolean.TRUE;
+ }
+ if (freeBytes == null || freeBytes < 0L || !isEstimateExact()) {
+ return null;
+ }
+ return freeBytes >= requiredBytes(marginPercent) ? Boolean.TRUE : Boolean.FALSE;
+ }
+
+ @Override
+ public String toString() {
+ return "SeedPlan{" + selections.size() + " channel(s), "
+ + estimatedBytes() + " bytes"
+ + (isEstimateExact() ? "" : ", inexact") + "}";
+ }
+}
diff --git a/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java
new file mode 100644
index 00000000..fd83807b
--- /dev/null
+++ b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java
@@ -0,0 +1,106 @@
+package org.iiab.controller.kolibri.domain;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ChannelId} — the guard that stops a channel token, a
+ * dashed UUID or plain junk from reaching a request as if it were an id.
+ * Pure JVM, no Android dependencies.
+ */
+public class ChannelIdTest {
+
+ /** African Storybook Library, as Kolibri stores it. */
+ private static final String CANONICAL = "f9d3e0e46ea25789bbed672ff6a399ed";
+
+ @Test
+ public void acceptsACanonicalId() {
+ assertEquals(CANONICAL, ChannelId.normalise(CANONICAL));
+ assertTrue(ChannelId.isValid(CANONICAL));
+ assertTrue(ChannelId.isCanonical(CANONICAL));
+ }
+
+ @Test
+ public void stripsDashesFromAUuidCopiedFromAUrl() {
+ assertEquals(CANONICAL, ChannelId.normalise("f9d3e0e4-6ea2-5789-bbed-672ff6a399ed"));
+ }
+
+ @Test
+ public void foldsUppercaseHex() {
+ assertEquals(CANONICAL, ChannelId.normalise(CANONICAL.toUpperCase()));
+ }
+
+ @Test
+ public void trimsSurroundingWhitespace() {
+ assertEquals(CANONICAL, ChannelId.normalise(" " + CANONICAL + "\n"));
+ }
+
+ @Test
+ public void rejectsAChannelTokenBecauseItIsNotAnId() {
+ // The trap this class exists for: a token passed as an id ends in a 404
+ // from the downloader with no useful message. It has to be resolved first.
+ assertNull(ChannelId.normalise("bisan-sukod"));
+ assertNull(ChannelId.normalise("bisansukod"));
+ assertFalse(ChannelId.isValid("bisan-sukod"));
+ }
+
+ @Test
+ public void rejectsWrongLength() {
+ assertNull(ChannelId.normalise(CANONICAL.substring(0, 31)));
+ assertNull(ChannelId.normalise(CANONICAL + "a"));
+ }
+
+ @Test
+ public void rejectsNonHexEvenAtTheRightLength() {
+ assertNull(ChannelId.normalise("z9d3e0e46ea25789bbed672ff6a399ed"));
+ // 32 chars of dashes collapse to nothing, not to a valid id.
+ assertNull(ChannelId.normalise("--------------------------------"));
+ }
+
+ @Test
+ public void rejectsNullAndEmpty() {
+ assertNull(ChannelId.normalise(null));
+ assertNull(ChannelId.normalise(""));
+ assertNull(ChannelId.normalise(" "));
+ assertFalse(ChannelId.isValid(null));
+ }
+
+ @Test
+ public void rejectsInjectionAttempts() {
+ // An id reaches a request the box acts on, so anything with structure in
+ // it is rejected outright rather than escaped downstream.
+ assertNull(ChannelId.normalise(CANONICAL + "; rm -rf /"));
+ assertNull(ChannelId.normalise("../../etc/passwd"));
+ assertNull(ChannelId.normalise(CANONICAL.substring(0, 30) + "$("));
+ }
+
+ @Test
+ public void isCanonicalDoesNotNormaliseSilently() {
+ // isCanonical asserts the value is already clean; it must not accept the
+ // dashed or uppercase spellings that normalise() would happily convert.
+ assertFalse(ChannelId.isCanonical("f9d3e0e4-6ea2-5789-bbed-672ff6a399ed"));
+ assertFalse(ChannelId.isCanonical(CANONICAL.toUpperCase()));
+ assertFalse(ChannelId.isCanonical(null));
+ }
+
+ @Test
+ public void recognisesTokensSoTheErrorCanSayWhy() {
+ assertTrue(ChannelId.looksLikeToken("bisan-sukod"));
+ assertTrue(ChannelId.looksLikeToken("bisansukod"));
+ assertTrue(ChannelId.looksLikeToken("BISAN-SUKOD"));
+ assertTrue(ChannelId.looksLikeToken(" bisansukod "));
+ }
+
+ @Test
+ public void doesNotMistakeAnIdForAToken() {
+ assertFalse(ChannelId.looksLikeToken(CANONICAL));
+ assertFalse(ChannelId.looksLikeToken(null));
+ assertFalse(ChannelId.looksLikeToken("too-short"));
+ assertFalse(ChannelId.looksLikeToken("has spaces"));
+ assertFalse(ChannelId.looksLikeToken("bisan_sukod"));
+ }
+}
diff --git a/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java
new file mode 100644
index 00000000..79b6392a
--- /dev/null
+++ b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java
@@ -0,0 +1,164 @@
+package org.iiab.controller.kolibri.domain;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ChannelSelection} — the type that keeps "the whole
+ * channel" and "nothing at all" from collapsing into each other, which in
+ * Kolibri's API is the difference between omitting node_ids and sending an empty
+ * list. Pure JVM, no Android dependencies.
+ */
+public class ChannelSelectionTest {
+
+ private static final String CHANNEL = "f9d3e0e46ea25789bbed672ff6a399ed";
+ private static final String NODE_A = "6277aa0c44235435acdc8a9ed98f466b";
+ private static final String NODE_B = "aaaa1111bbbb2222cccc3333dddd4444";
+
+ @Test
+ public void wholeChannelHasNoNodeIds() {
+ ChannelSelection s = ChannelSelection.wholeChannel(CHANNEL);
+ assertEquals(CHANNEL, s.channelId());
+ assertTrue(s.isWholeChannel());
+ assertTrue(s.nodeIds().isEmpty());
+ }
+
+ @Test
+ public void subtreeSelectionKeepsTheNodes() {
+ ChannelSelection s = ChannelSelection.ofSubtrees(CHANNEL, Arrays.asList(NODE_A, NODE_B));
+ assertFalse(s.isWholeChannel());
+ assertEquals(Arrays.asList(NODE_A, NODE_B), s.nodeIds());
+ }
+
+ @Test
+ public void anEmptySubtreeListIsRejectedRatherThanDownloadingNothing() {
+ // Kolibri would accept node_ids: [] and finish successfully having
+ // transferred zero bytes. That silent no-op is what this rejects.
+ try {
+ ChannelSelection.ofSubtrees(CHANNEL, Collections.