Skip to content

Commit 8c86e6c

Browse files
Merge pull request #358 from appdevforall/feat/ADFA-4954-kolibri-studio-catalog-client
ADFA-4954 feat(kolibri): channel catalog and Studio tree client
2 parents b7fa5f5 + 81bbe22 commit 8c86e6c

19 files changed

Lines changed: 2869 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)