Skip to content

Commit 211310e

Browse files
Merge pull request #313 from appdevforall/feat/ADFA-4954-kolibri-android-seeding
ADFA-4954 feat(kolibri): domain layer for content seeding — ids, selection, plan
2 parents b561ff8 + 4417eb2 commit 211310e

7 files changed

Lines changed: 1357 additions & 0 deletions

File tree

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
/*
2+
* ============================================================================
3+
* Name : KolibriRestClient.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : ADFA-4954. App-side client of the Kolibri seeding endpoints on
7+
* the in-server REST core (nginx localhost:8085 -> Node :4000,
8+
* /k2go-api/kolibri). Same shape as RestContentClient: POST to
9+
* start, then poll ~1s. The job is durable in the dashboard
10+
* process, so a dropped client can re-attach by polling the id.
11+
*
12+
* Contract (static/dashboard/routes.ts, ADFA-4949):
13+
* GET /k2go-api/kolibri/ready -> { ready, blockers[] }
14+
* POST /k2go-api/kolibri/download -> { id, ... }
15+
* body { items:[{channelId,nodeIds,allThumbnails}] }
16+
* GET /k2go-api/kolibri/jobs/:id -> { phase, percent,
17+
* speed, detail, error }
18+
* POST /k2go-api/kolibri/jobs/:id/cancel -> { ok:true }
19+
* phase: queued|downloading|indexing|processing|done|error|canceled.
20+
* ============================================================================
21+
*/
22+
package org.iiab.controller.kolibri.data;
23+
24+
import android.os.Handler;
25+
import android.os.Looper;
26+
27+
import androidx.annotation.NonNull;
28+
29+
import org.iiab.controller.config.BoxEndpoints;
30+
import org.iiab.controller.kolibri.domain.ChannelSelection;
31+
import org.iiab.controller.kolibri.domain.SeedPlan;
32+
import org.iiab.controller.util.AppExecutors;
33+
import org.json.JSONArray;
34+
import org.json.JSONObject;
35+
36+
import java.io.ByteArrayOutputStream;
37+
import java.io.InputStream;
38+
import java.io.OutputStream;
39+
import java.net.HttpURLConnection;
40+
import java.net.URL;
41+
import java.nio.charset.StandardCharsets;
42+
import java.util.List;
43+
44+
public final class KolibriRestClient {
45+
46+
/** Mirrors {@code RestContentClient.Listener} so the foreground service that
47+
* drives it can be a structural copy of the ZIM one.
48+
* {@code speed} is a display token WITHOUT the per-second suffix. */
49+
public interface Listener {
50+
void onProgress(int percent, String speed); // downloading phase
51+
void onIndexing(); // indexing/processing (indeterminate)
52+
void onLog(String line); // free-form detail line
53+
void onDone(); // success (terminal)
54+
void onError(String message); // failure (terminal)
55+
}
56+
57+
/** Result of the readiness gate. */
58+
public interface ReadyListener {
59+
void onReady();
60+
/** @param reason the first blocker reported by the box, already human-readable */
61+
void onNotReady(String reason);
62+
}
63+
64+
private static final String BASE = BoxEndpoints.API + "/kolibri";
65+
private static final long POLL_MS = 1000L;
66+
private static final int MAX_POLL_ERRORS = 10; // ~10s of transient blips
67+
68+
/** The gate can be slow on a cold box: it logs into Kolibri to answer. */
69+
private static final int READY_TIMEOUT_MS = 15000;
70+
private static final int CALL_TIMEOUT_MS = 4000;
71+
72+
private final Handler main = new Handler(Looper.getMainLooper());
73+
private Listener listener;
74+
private volatile String jobId;
75+
private volatile boolean finished = false;
76+
private volatile boolean indexing = false;
77+
private int pollErrors = 0;
78+
79+
private final Runnable pollTask = () -> AppExecutors.get().io().execute(this::pollOnce);
80+
81+
/**
82+
* Asks the box whether seeding can start at all.
83+
*
84+
* <p>Worth calling before {@link #seed}: the gate reports in one shot whether
85+
* Kolibri is up, whether the stored credentials still authenticate, whether the
86+
* user can manage content and whether the content origin resolves. Without it
87+
* the first failure surfaces as a job error several seconds in, with the cause
88+
* buried.
89+
*
90+
* <p>Always answers HTTP 200 — it reports state rather than performing an
91+
* operation — so a non-200 here means the REST core itself is unreachable.
92+
*/
93+
public void checkReady(@NonNull ReadyListener l) {
94+
AppExecutors.get().io().execute(() -> {
95+
try {
96+
JSONObject j = httpJson("GET", BASE + "/ready", null, READY_TIMEOUT_MS);
97+
if (j.optBoolean("ready", false)) {
98+
main.post(l::onReady);
99+
return;
100+
}
101+
final String reason = firstBlocker(j);
102+
main.post(() -> l.onNotReady(reason));
103+
} catch (Exception e) {
104+
main.post(() -> l.onNotReady("couldn't reach the content service"));
105+
}
106+
});
107+
}
108+
109+
/** First entry of the {@code blockers} array, or a generic fallback. */
110+
private static String firstBlocker(JSONObject readyResponse) {
111+
JSONArray blockers = readyResponse.optJSONArray("blockers");
112+
if (blockers != null && blockers.length() > 0) {
113+
String first = blockers.optString(0, "");
114+
if (!first.isEmpty()) {
115+
return first;
116+
}
117+
}
118+
return "Kolibri is not ready yet";
119+
}
120+
121+
/**
122+
* Starts seeding everything in {@code plan} as one job and polls to completion.
123+
*
124+
* <p>The plan is taken rather than a bare list because it guarantees one entry
125+
* per channel: the dashboard runner walks them sequentially, and two imports of
126+
* the same channel would only contend on the same SQLite file.
127+
*
128+
* <p>The channel <em>name</em> is deliberately not sent. The box resolves it
129+
* from its own cached proxy of Studio, so the app does not have to carry it and
130+
* cannot send a stale one.
131+
*/
132+
public void seed(@NonNull SeedPlan plan, @NonNull Listener l) {
133+
this.listener = l;
134+
resetRunState();
135+
if (plan.isEmpty()) {
136+
// Nothing queued is a success, not an error: the caller drained an
137+
// empty wishlist. Routed through done() so the double-callback guard is
138+
// armed rather than left open.
139+
done();
140+
return;
141+
}
142+
AppExecutors.get().io().execute(() -> {
143+
try {
144+
JSONObject body = new JSONObject().put("items", itemsOf(plan));
145+
JSONObject resp = httpJson("POST", BASE + "/download", body, CALL_TIMEOUT_MS);
146+
String id = resp.optString("id", "");
147+
if (id.isEmpty()) {
148+
fail("content service did not start the job");
149+
return;
150+
}
151+
jobId = id;
152+
main.postDelayed(pollTask, POLL_MS);
153+
} catch (Exception e) {
154+
fail("couldn't reach the content service");
155+
}
156+
});
157+
}
158+
159+
/**
160+
* Re-attaches to a job started earlier, e.g. after the service was restarted.
161+
*
162+
* <p>Resets the run state first, and that ordering matters: {@code finished}
163+
* short-circuits both {@link #pollOnce()} and {@link #fail(String)}, so on an
164+
* instance that already completed a seed this method would otherwise go
165+
* completely silent — no polling, no error, no callback.
166+
*/
167+
public void attach(@NonNull String existingJobId, @NonNull Listener l) {
168+
this.listener = l;
169+
resetRunState();
170+
171+
String id = existingJobId.trim();
172+
if (id.isEmpty()) {
173+
// Fail with the real cause. Left to the poller, an empty id would 404
174+
// ten times and surface as "lost contact with the content service",
175+
// pointing at the network instead of at the missing id.
176+
fail("no job to re-attach to");
177+
return;
178+
}
179+
this.jobId = id;
180+
main.postDelayed(pollTask, POLL_MS);
181+
}
182+
183+
/** Clears the per-run flags so the instance can start or re-attach again. */
184+
private void resetRunState() {
185+
finished = false;
186+
indexing = false;
187+
pollErrors = 0;
188+
}
189+
190+
/** The id of the in-flight job, or null. Persist it to survive a restart. */
191+
public String jobId() {
192+
return jobId;
193+
}
194+
195+
/** Translates the domain selections into the runner's item shape. */
196+
private static JSONArray itemsOf(SeedPlan plan) throws Exception {
197+
JSONArray items = new JSONArray();
198+
for (ChannelSelection s : plan.selections()) {
199+
JSONObject item = new JSONObject().put("channelId", s.channelId());
200+
List<String> nodes = s.nodeIds();
201+
if (!nodes.isEmpty()) {
202+
// Only send node_ids when narrowing. An empty array would mean
203+
// "zero nodes" to Kolibri, which finishes clean and downloads
204+
// nothing — the trap ChannelSelection exists to prevent.
205+
JSONArray arr = new JSONArray();
206+
for (String n : nodes) {
207+
arr.put(n);
208+
}
209+
item.put("nodeIds", arr);
210+
}
211+
if (s.wantsAllThumbnails()) {
212+
item.put("allThumbnails", true);
213+
}
214+
items.put(item);
215+
}
216+
return items;
217+
}
218+
219+
private void pollOnce() {
220+
if (finished) return;
221+
try {
222+
JSONObject j = httpJson("GET", BASE + "/jobs/" + jobId, null, CALL_TIMEOUT_MS);
223+
pollErrors = 0;
224+
String phase = j.optString("phase", "");
225+
int percent = j.optInt("percent", -1);
226+
long speed = j.optLong("speed", 0L);
227+
String detail = j.isNull("detail") ? null : j.optString("detail", null);
228+
String error = j.isNull("error") ? null : j.optString("error", null);
229+
230+
if (detail != null && !detail.isEmpty()) {
231+
final String line = detail;
232+
deliver(() -> listener.onLog(line));
233+
}
234+
235+
switch (phase) {
236+
case "downloading":
237+
// Cleared on the way in, not only set on the way out: a job that
238+
// seeds several channels re-enters processing once per channel,
239+
// and a latching flag would announce the phase for the first
240+
// channel only. The ZIM client can latch because it handles one
241+
// item; this one cannot.
242+
indexing = false;
243+
if (percent >= 0) {
244+
final int p = percent;
245+
final String rate = formatRate(speed);
246+
deliver(() -> listener.onProgress(p, rate));
247+
}
248+
break;
249+
case "indexing":
250+
case "processing":
251+
if (!indexing) {
252+
indexing = true;
253+
deliver(() -> listener.onIndexing());
254+
}
255+
break;
256+
case "done":
257+
done();
258+
return;
259+
case "error":
260+
fail(error != null ? error : "seeding failed");
261+
return;
262+
case "canceled":
263+
fail("canceled");
264+
return;
265+
default:
266+
break; // queued / unknown: keep polling
267+
}
268+
main.postDelayed(pollTask, POLL_MS);
269+
} catch (Exception e) {
270+
if (++pollErrors > MAX_POLL_ERRORS) {
271+
fail("lost contact with the content service");
272+
return;
273+
}
274+
main.postDelayed(pollTask, POLL_MS);
275+
}
276+
}
277+
278+
/** Best-effort cancel of the in-flight job. */
279+
public void cancel() {
280+
final String id = jobId;
281+
if (id != null && !id.isEmpty()) {
282+
AppExecutors.get().io().execute(() -> {
283+
try {
284+
httpJson("POST", BASE + "/jobs/" + id + "/cancel", null, CALL_TIMEOUT_MS);
285+
} catch (Exception ignore) {
286+
// best effort
287+
}
288+
});
289+
}
290+
teardown();
291+
}
292+
293+
private void done() {
294+
if (finished) return;
295+
finished = true;
296+
final Listener l = listener;
297+
teardown();
298+
if (l != null) deliver(l::onDone);
299+
}
300+
301+
private void fail(String message) {
302+
if (finished) return;
303+
finished = true;
304+
final Listener l = listener;
305+
teardown();
306+
if (l != null) deliver(() -> l.onError(message));
307+
}
308+
309+
private void teardown() {
310+
main.removeCallbacks(pollTask);
311+
}
312+
313+
private void deliver(Runnable r) {
314+
if (listener == null) return;
315+
main.post(r);
316+
}
317+
318+
/** bytes/sec -> a short display token ("3.4 MB"); caller appends the localized "/s". */
319+
private static String formatRate(long bps) {
320+
if (bps <= 0) return "0 B";
321+
final String[] units = {"B", "KB", "MB", "GB"};
322+
double v = bps;
323+
int i = 0;
324+
while (v >= 1024 && i < units.length - 1) {
325+
v /= 1024;
326+
i++;
327+
}
328+
return i == 0
329+
? String.format(java.util.Locale.US, "%.0f %s", v, units[i])
330+
: String.format(java.util.Locale.US, "%.1f %s", v, units[i]);
331+
}
332+
333+
private static JSONObject httpJson(String method, String urlStr, JSONObject body, int timeoutMs)
334+
throws Exception {
335+
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
336+
try {
337+
c.setUseCaches(false);
338+
c.setConnectTimeout(timeoutMs);
339+
c.setReadTimeout(timeoutMs);
340+
c.setRequestMethod(method);
341+
c.setRequestProperty("Accept", "application/json");
342+
if (body != null) {
343+
c.setDoOutput(true);
344+
c.setRequestProperty("Content-Type", "application/json");
345+
byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8);
346+
try (OutputStream os = c.getOutputStream()) {
347+
os.write(payload);
348+
}
349+
}
350+
int code = c.getResponseCode();
351+
boolean ok = code >= 200 && code < 400;
352+
String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
353+
if (!ok) throw new Exception("HTTP " + code + ": " + text);
354+
return new JSONObject(text.isEmpty() ? "{}" : text);
355+
} finally {
356+
c.disconnect();
357+
}
358+
}
359+
360+
private static String readAll(InputStream is) throws Exception {
361+
if (is == null) return "";
362+
ByteArrayOutputStream buf = new ByteArrayOutputStream();
363+
byte[] chunk = new byte[4096];
364+
int n;
365+
while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n);
366+
is.close();
367+
return buf.toString(StandardCharsets.UTF_8.name());
368+
}
369+
}

0 commit comments

Comments
 (0)