Skip to content

Commit 69b1f34

Browse files
ADFA-4954 feat(kolibri): channel catalog and Studio tree client
The wizard lets the user pick content before anything is installed, so there is no box to ask and the app reaches Studio itself. The catalog is generated at release time and shipped in the APK rather than fetched: Studio's channel endpoint returns 4.1 MB for 142 channels, 97% of it base64 thumbnails, with no way to opt out (?fields= comes back byte-identical). The generated asset is 83 KB. Only the topic tree is read live — it is per-channel and carries no images. Search and language filtering run locally. Adds: - domain: Channel, TopicNode, CatalogQuery, the CatalogRepository port and GetChannelCatalogUseCase - data: StudioCatalogMapper (pure JSON translation, so the field traps are testable), StudioTreeSource, BundledCatalogSource, CatalogRepositoryImpl - tools/build_kolibri_catalog.py and the refreshKolibriCatalog Gradle task, release-only and never failing the build, mirroring the Kiwix catalog pair - assets/kolibri_catalog.jsonl - ADR-4954, with the design and the options rejected along the way No dashboard or REST changes. The only shared file touched is build.gradle. No new user-facing strings.
1 parent b7fa5f5 commit 69b1f34

18 files changed

Lines changed: 2698 additions & 0 deletions

‎controller/app/build.gradle‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,28 @@ task refreshKiwixCatalog {
709709
tasks.matching { it.name == 'assembleRelease' || it.name == 'bundleRelease' }.configureEach {
710710
dependsOn refreshKiwixCatalog
711711
}
712+
713+
// ---- Kolibri channel catalog refresh (release-only) ---------------------------
714+
// Regenerates src/main/assets/kolibri_catalog.jsonl from Kolibri Studio's public API via
715+
// tools/build_kolibri_catalog.py. Bundled rather than fetched on-device because Studio's
716+
// channel endpoint is 97% base64 thumbnails with no way to opt out — 4.1 MB to use 119 KB
717+
// (ADR-4954 D1). Release-only, like the Kiwix catalog above, and never fails the build:
718+
// on any error the committed JSONL (last known) is kept.
719+
// Manual run: ./gradlew refreshKolibriCatalog
720+
task refreshKolibriCatalog {
721+
group = 'build setup'
722+
description = 'Regenerate assets/kolibri_catalog.jsonl from Kolibri Studio (release / manual)'
723+
doLast {
724+
try {
725+
def proc = ['python3', 'tools/build_kolibri_catalog.py'].execute(null, projectDir)
726+
proc.consumeProcessOutput(System.out, System.err)
727+
proc.waitFor()
728+
if (proc.exitValue() != 0) logger.warn(">> [kolibri catalog] generator exit ${proc.exitValue()}; kept existing JSONL.")
729+
} catch (Exception e) {
730+
logger.warn(">> [kolibri catalog] skipped (${e.message}); kept existing JSONL.")
731+
}
732+
}
733+
}
734+
tasks.matching { it.name == 'assembleRelease' || it.name == 'bundleRelease' }.configureEach {
735+
dependsOn refreshKolibriCatalog
736+
}

‎controller/app/src/main/assets/kolibri_catalog.jsonl‎

Lines changed: 143 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* ============================================================================
3+
* Name : BundledCatalogSource.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : Reads the Kolibri channel catalog shipped in the APK.
7+
* Blocking; call from an IO thread (ADFA-4954).
8+
* ============================================================================
9+
*/
10+
package org.iiab.controller.kolibri.data;
11+
12+
import android.content.Context;
13+
import android.util.Log;
14+
15+
import org.iiab.controller.kolibri.domain.Channel;
16+
import org.json.JSONObject;
17+
18+
import java.io.BufferedReader;
19+
import java.io.InputStream;
20+
import java.io.InputStreamReader;
21+
import java.nio.charset.StandardCharsets;
22+
import java.util.ArrayList;
23+
import java.util.Collections;
24+
import java.util.List;
25+
26+
/**
27+
* The channel catalog bundled with the app.
28+
*
29+
* <p>Format is JSON Lines, one object per line, for the reason ADR-4853 gave for
30+
* the Books catalog: channel names and descriptions carry commas, quotes and
31+
* accents in every script Kolibri publishes in, and a naive CSV split corrupts
32+
* rows. JSONL is delimiter-safe and streams line by line, so a malformed line
33+
* costs that line rather than the file.
34+
*
35+
* <p>The first line is a header object carrying {@code generated}; every
36+
* subsequent line is a channel. Parsed once and cached for the process, like
37+
* {@code KiwixCatalog}: the file does not change while the app runs.
38+
*
39+
* <p>Blocking. Never throws — a missing or unreadable asset yields an empty
40+
* catalog, because a picker with no rows is recoverable and a crash is not.
41+
*/
42+
public final class BundledCatalogSource {
43+
44+
private static final String TAG = "K2Go-Kolibri";
45+
private static final String ASSET = "kolibri_catalog.jsonl";
46+
47+
/** Marks the header line, so it is never mistaken for a channel. */
48+
private static final String HEADER_KEY = "catalog";
49+
50+
private static volatile List<Channel> cachedChannels;
51+
private static volatile String cachedGeneratedOn = "";
52+
53+
private final Context appContext;
54+
55+
public BundledCatalogSource(Context context) {
56+
this.appContext = context.getApplicationContext();
57+
}
58+
59+
/** Every channel in the asset, in file order. Never null. */
60+
public List<Channel> channels() {
61+
load();
62+
return cachedChannels;
63+
}
64+
65+
/** ISO-8601 date from the header line, or empty. */
66+
public String generatedOn() {
67+
load();
68+
return cachedGeneratedOn;
69+
}
70+
71+
/** Drops the cache. For tests and for a future in-place catalog refresh. */
72+
public static void invalidate() {
73+
cachedChannels = null;
74+
cachedGeneratedOn = "";
75+
}
76+
77+
private void load() {
78+
if (cachedChannels != null) {
79+
return;
80+
}
81+
synchronized (BundledCatalogSource.class) {
82+
if (cachedChannels != null) {
83+
return;
84+
}
85+
List<Channel> out = new ArrayList<>();
86+
String generated = "";
87+
int skipped = 0;
88+
89+
try (InputStream is = appContext.getAssets().open(ASSET);
90+
BufferedReader r = new BufferedReader(
91+
new InputStreamReader(is, StandardCharsets.UTF_8))) {
92+
String line;
93+
while ((line = r.readLine()) != null) {
94+
String t = line.trim();
95+
if (t.isEmpty()) {
96+
continue;
97+
}
98+
try {
99+
JSONObject o = new JSONObject(t);
100+
if (o.has(HEADER_KEY)) {
101+
generated = o.optString("generated", "");
102+
continue;
103+
}
104+
Channel c = StudioCatalogMapper.channel(o);
105+
if (c == null) {
106+
skipped++;
107+
} else {
108+
out.add(c);
109+
}
110+
} catch (Exception badLine) {
111+
skipped++;
112+
}
113+
}
114+
} catch (Exception e) {
115+
// A missing asset is a build problem, not a runtime one, but it
116+
// must not take the app down: log loudly and show nothing.
117+
Log.w(TAG, "bundled catalog unavailable: " + e.getMessage());
118+
}
119+
120+
if (skipped > 0) {
121+
Log.w(TAG, "bundled catalog: skipped " + skipped + " unusable line(s)");
122+
}
123+
cachedGeneratedOn = generated;
124+
cachedChannels = Collections.unmodifiableList(out);
125+
}
126+
}
127+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* ============================================================================
3+
* Name : CatalogRepositoryImpl.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : Wires the bundled catalog and the live Studio tree behind the
7+
* domain port (ADFA-4954).
8+
* ============================================================================
9+
*/
10+
package org.iiab.controller.kolibri.data;
11+
12+
import android.content.Context;
13+
14+
import org.iiab.controller.kolibri.domain.CatalogRepository;
15+
import org.iiab.controller.kolibri.domain.Channel;
16+
import org.iiab.controller.kolibri.domain.TopicNode;
17+
18+
import java.util.List;
19+
20+
/**
21+
* The Data-layer implementation of {@link CatalogRepository}.
22+
*
23+
* <p>Two sources with different characters, which is the whole point of putting
24+
* them behind one port: channels come from a file in the APK and cannot fail in
25+
* any interesting way, trees come from the internet and can.
26+
*
27+
* <p>Blocking, like its sources. Callers run it on {@code AppExecutors.io()}.
28+
*/
29+
public final class CatalogRepositoryImpl implements CatalogRepository {
30+
31+
private final BundledCatalogSource bundled;
32+
private final StudioTreeSource studio;
33+
34+
public CatalogRepositoryImpl(Context context) {
35+
this(new BundledCatalogSource(context), new StudioTreeSource());
36+
}
37+
38+
/** For tests and for pointing the tree source at a mirror. */
39+
public CatalogRepositoryImpl(BundledCatalogSource bundled, StudioTreeSource studio) {
40+
this.bundled = bundled;
41+
this.studio = studio;
42+
}
43+
44+
@Override
45+
public List<Channel> channels() {
46+
return bundled.channels();
47+
}
48+
49+
@Override
50+
public String catalogGeneratedOn() {
51+
return bundled.generatedOn();
52+
}
53+
54+
@Override
55+
public TopicNode fetchTree(String nodeId) {
56+
return studio.fetchTree(nodeId);
57+
}
58+
}

0 commit comments

Comments
 (0)