Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Kolibri identifies channels and nodes by a UUID rendered as <b>32 lowercase
* hex characters without dashes</b>. Two different things get confused with it and
* both fail in ways that are hard to diagnose:
*
* <ul>
* <li>A <b>dashed UUID</b> copied from a URL. Kolibri's own serializer
* normalises it, but our requests and any local bookkeeping should not
* depend on that, so {@link #normalise(String)} strips the dashes.</li>
* <li>A <b>channel token</b> — the ten-character pronounceable proquint shown
* in Studio as {@code xxxxx-xxxxx}. It is <em>not</em> an id. Passing one
* where an id is expected ends in a 404 from the downloader with no useful
* message, so {@link #isValid(String)} rejects it and the caller is
* expected to resolve it first via {@code /k2go-api/kolibri/resolve/:id}.</li>
* </ul>
*
* <p>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.
*
* <p>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.
*
* <p>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 <em>already</em> 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 <em>token</em> rather than
* an id: ten characters of {@code [a-z0-9]}, optionally split by a dash into
* two groups of five.
*
* <p>Only used to tell the user <em>why</em> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>The distinction this type exists to protect is a real trap in Kolibri's API:
*
* <ul>
* <li><b>No node ids</b> means <em>the whole channel</em>.</li>
* <li><b>An empty node id list</b> means <em>nothing at all</em>. Kolibri's
* importer treats {@code node_ids: []} as "select zero nodes", finishes
* successfully and transfers no bytes.</li>
* </ul>
*
* <p>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.
*
* <p>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.
*
* <p>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<String> nodeIds;

private ChannelSelection(String channelId, List<String> nodeIds) {
this.channelId = channelId;
this.nodeIds = nodeIds;
}

/**
* The entire channel.
*
* @throws IllegalArgumentException if the channel id is not usable
*/
public static ChannelSelection wholeChannel(String rawChannelId) {
return new ChannelSelection(requireChannelId(rawChannelId),
Collections.<String>emptyList());
}

/**
* The named subtrees of a channel. Each node id expands to its descendants on
* Kolibri's side, so a topic id pulls in everything under it.
*
* @throws IllegalArgumentException if the channel id is not usable, if
* {@code rawNodeIds} is null or empty, or if none of the node ids
* survive validation — an empty result would silently download
* nothing, so it is rejected here instead
*/
public static ChannelSelection ofSubtrees(String rawChannelId, List<String> rawNodeIds) {
String id = requireChannelId(rawChannelId);

if (rawNodeIds == null || rawNodeIds.isEmpty()) {
throw new IllegalArgumentException(
"empty subtree selection for channel " + id
+ "; use wholeChannel() to seed everything");
}

// LinkedHashSet: drop duplicates, keep the order the user picked.
LinkedHashSet<String> canonical = new LinkedHashSet<>();
for (String raw : rawNodeIds) {
String node = ChannelId.normalise(raw);
if (node != null) {
canonical.add(node);
}
}
if (canonical.isEmpty()) {
throw new IllegalArgumentException(
"no valid node id among " + rawNodeIds.size()
+ " entries for channel " + id);
}

return new ChannelSelection(id,
Collections.unmodifiableList(new ArrayList<>(canonical)));
}

private static String requireChannelId(String raw) {
String id = ChannelId.normalise(raw);
if (id != null) {
return id;
}
if (ChannelId.looksLikeToken(raw)) {
throw new IllegalArgumentException(
"'" + raw + "' is a channel token, not an id; resolve it first");
}
throw new IllegalArgumentException("invalid channel id: " + raw);
}

/** Canonical 32-hex channel id. */
public String channelId() {
return channelId;
}

/** Canonical node ids, empty when the whole channel was requested. Immutable. */
public List<String> nodeIds() {
return nodeIds;
}

/** True when nothing was narrowed down and the whole channel should come. */
public boolean isWholeChannel() {
return nodeIds.isEmpty();
}

/**
* Whether to ask Kolibri for every topic thumbnail in the channel.
*
* <p>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();
}
}
Loading
Loading