Skip to content

Commit 0db7bf4

Browse files
Merge pull request #252 from appdevforall/feat/ADFA-4840-android-rest-client
ADFA-4840: Android REST content client (foreground poll) replacing socket.io
2 parents e2e9ab4 + b8ca9ac commit 0db7bf4

4 files changed

Lines changed: 219 additions & 181 deletions

File tree

‎controller/app/build.gradle‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,6 @@ dependencies {
212212
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
213213
implementation 'org.nanohttpd:nanohttpd:2.3.1'
214214

215-
// ADFA-4832: socket.io client to drive the in-server dashboard channel (live content
216-
// add/index over localhost) without spawning a second proot. org.json is provided by
217-
// the Android platform, so exclude the bundled copy to avoid a duplicate-class clash.
218-
implementation('io.socket:socket.io-client:2.1.0') {
219-
exclude group: 'org.json', module: 'json'
220-
}
221-
222215
// ADFA-4466 Phase 1: Firebase Analytics (online-first). BoM aligns versions;
223216
// firebase-analytics (not -ktx) exposes the Java API used by AnalyticsClient.
224217
implementation platform('com.google.firebase:firebase-bom:33.7.0')

‎controller/app/src/main/java/org/iiab/controller/content/LiveContentClient.java‎

Lines changed: 0 additions & 166 deletions
This file was deleted.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* ============================================================================
3+
* Name : RestContentClient.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : ADFA-4840 (Phase 2 of ADFA-4832). App-side client of the in-server
7+
* durable REST job engine (nginx localhost:8085 -> Node :4000, /api).
8+
* Replaces the Phase 1 socket.io LiveContentClient: instead of holding
9+
* a long-lived connection whose drop kills the job, it POSTs to start a
10+
* job and then POLLs its structured status every ~1s. The job lives in
11+
* the dashboard process (durable, resumable), so there is no connection
12+
* to lose — the app can drop and re-attach by polling the same jobId.
13+
* Still driven by the foreground InstallService so polling continues
14+
* across UI/config changes.
15+
*
16+
* Contract (static/dashboard/routes.ts + sockets/jobs.ts):
17+
* POST /api/kiwix/download {ids:[...]} -> { id, ... }
18+
* GET /api/kiwix/jobs/:id -> { phase, percent,
19+
* speed(bytes/s),
20+
* detail, error }
21+
* POST /api/kiwix/jobs/:id/cancel -> { ok:true }
22+
* phase is one of: queued|downloading|indexing|processing|done|error|canceled.
23+
* ============================================================================
24+
*/
25+
package org.iiab.controller.content;
26+
27+
import android.os.Handler;
28+
import android.os.Looper;
29+
30+
import androidx.annotation.NonNull;
31+
32+
import org.iiab.controller.config.BoxEndpoints;
33+
import org.iiab.controller.util.AppExecutors;
34+
import org.json.JSONArray;
35+
import org.json.JSONObject;
36+
37+
import java.io.ByteArrayOutputStream;
38+
import java.io.InputStream;
39+
import java.io.OutputStream;
40+
import java.net.HttpURLConnection;
41+
import java.net.URL;
42+
import java.nio.charset.StandardCharsets;
43+
44+
public final class RestContentClient {
45+
46+
/** Same shape as the Phase 1 client so callers swap in with no behavior change.
47+
* {@code speed} is a display token WITHOUT the per-second suffix (caller appends it). */
48+
public interface Listener {
49+
void onProgress(int percent, String speed); // download phase
50+
void onIndexing(); // indexing phase (indeterminate)
51+
void onLog(String line); // free-form detail line (for logs)
52+
void onDone(); // success (terminal)
53+
void onError(String message); // failure (terminal)
54+
}
55+
56+
private static final String BASE = BoxEndpoints.BASE + "/api/kiwix";
57+
private static final long POLL_MS = 1000L;
58+
private static final int MAX_POLL_ERRORS = 10; // tolerate ~10s of transient network blips
59+
60+
private final Handler main = new Handler(Looper.getMainLooper());
61+
private Listener listener;
62+
private volatile String jobId;
63+
private volatile boolean finished = false;
64+
private volatile boolean indexing = false;
65+
private int pollErrors = 0;
66+
67+
private final Runnable pollTask = () -> AppExecutors.get().io().execute(this::pollOnce);
68+
69+
/** Start (download + index) one ZIM on the running server and poll to completion. */
70+
public void addZim(@NonNull String zimFilename, @NonNull Listener l) {
71+
this.listener = l;
72+
AppExecutors.get().io().execute(() -> {
73+
try {
74+
JSONObject body = new JSONObject().put("ids", new JSONArray().put(zimFilename));
75+
JSONObject resp = httpJson("POST", BASE + "/download", body);
76+
String id = resp.optString("id", "");
77+
if (id.isEmpty()) { fail("content service did not start the job"); return; }
78+
jobId = id;
79+
main.postDelayed(pollTask, POLL_MS);
80+
} catch (Exception e) {
81+
fail("couldn't reach the content service");
82+
}
83+
});
84+
}
85+
86+
private void pollOnce() {
87+
if (finished) return;
88+
try {
89+
JSONObject j = httpJson("GET", BASE + "/jobs/" + jobId, null);
90+
pollErrors = 0;
91+
String phase = j.optString("phase", "");
92+
int percent = j.optInt("percent", -1);
93+
long speed = j.optLong("speed", 0L);
94+
String detail = j.isNull("detail") ? null : j.optString("detail", null);
95+
String error = j.isNull("error") ? null : j.optString("error", null);
96+
97+
if (detail != null && !detail.isEmpty()) deliver(() -> listener.onLog(detail));
98+
99+
switch (phase) {
100+
case "downloading":
101+
if (percent >= 0) {
102+
final int p = percent; final String rate = formatRate(speed);
103+
deliver(() -> listener.onProgress(p, rate));
104+
}
105+
break;
106+
case "indexing":
107+
case "processing":
108+
if (!indexing) { indexing = true; deliver(() -> listener.onIndexing()); }
109+
break;
110+
case "done":
111+
done();
112+
return;
113+
case "error":
114+
fail(error != null ? error : "download failed");
115+
return;
116+
case "canceled":
117+
fail("canceled");
118+
return;
119+
default:
120+
break; // queued / unknown: keep polling
121+
}
122+
main.postDelayed(pollTask, POLL_MS);
123+
} catch (Exception e) {
124+
if (++pollErrors > MAX_POLL_ERRORS) { fail("lost contact with the content service"); return; }
125+
main.postDelayed(pollTask, POLL_MS);
126+
}
127+
}
128+
129+
/** Best-effort cancel of the in-flight job. */
130+
public void cancel() {
131+
final String id = jobId;
132+
if (id != null && !id.isEmpty()) {
133+
AppExecutors.get().io().execute(() -> {
134+
try { httpJson("POST", BASE + "/jobs/" + id + "/cancel", null); } catch (Exception ignore) { /* best effort */ }
135+
});
136+
}
137+
teardown();
138+
}
139+
140+
private void done() {
141+
if (finished) return;
142+
finished = true;
143+
final Listener l = listener;
144+
teardown();
145+
if (l != null) deliver(l::onDone);
146+
}
147+
148+
private void fail(String message) {
149+
if (finished) return;
150+
finished = true;
151+
final Listener l = listener;
152+
teardown();
153+
if (l != null) deliver(() -> l.onError(message));
154+
}
155+
156+
private void teardown() {
157+
main.removeCallbacks(pollTask);
158+
}
159+
160+
private void deliver(Runnable r) {
161+
if (listener == null) return;
162+
main.post(r);
163+
}
164+
165+
/** bytes/sec -> a short display token ("3.4 MB"); the caller appends the localized "/s". */
166+
private static String formatRate(long bps) {
167+
if (bps <= 0) return "0 B";
168+
final String[] units = {"B", "KB", "MB", "GB"};
169+
double v = bps;
170+
int i = 0;
171+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
172+
return i == 0
173+
? String.format(java.util.Locale.US, "%.0f %s", v, units[i])
174+
: String.format(java.util.Locale.US, "%.1f %s", v, units[i]);
175+
}
176+
177+
private static JSONObject httpJson(String method, String urlStr, JSONObject body) throws Exception {
178+
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
179+
try {
180+
c.setUseCaches(false);
181+
c.setConnectTimeout(4000);
182+
c.setReadTimeout(4000);
183+
c.setRequestMethod(method);
184+
c.setRequestProperty("Accept", "application/json");
185+
if (body != null) {
186+
c.setDoOutput(true);
187+
c.setRequestProperty("Content-Type", "application/json");
188+
byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8);
189+
try (OutputStream os = c.getOutputStream()) { os.write(payload); }
190+
}
191+
int code = c.getResponseCode();
192+
boolean ok = code >= 200 && code < 400;
193+
String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
194+
if (!ok) throw new Exception("HTTP " + code + ": " + text);
195+
return new JSONObject(text.isEmpty() ? "{}" : text);
196+
} finally {
197+
c.disconnect();
198+
}
199+
}
200+
201+
private static String readAll(InputStream is) throws Exception {
202+
if (is == null) return "";
203+
ByteArrayOutputStream buf = new ByteArrayOutputStream();
204+
byte[] chunk = new byte[4096];
205+
int n;
206+
while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n);
207+
is.close();
208+
return buf.toString(StandardCharsets.UTF_8.name());
209+
}
210+
}

0 commit comments

Comments
 (0)