Skip to content
Merged
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
7 changes: 7 additions & 0 deletions controller/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ dependencies {
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
implementation 'org.nanohttpd:nanohttpd:2.3.1'

// ADFA-4832: socket.io client to drive the in-server dashboard channel (live content
// add/index over localhost) without spawning a second proot. org.json is provided by
// the Android platform, so exclude the bundled copy to avoid a duplicate-class clash.
implementation('io.socket:socket.io-client:2.1.0') {
exclude group: 'org.json', module: 'json'
}

// ADFA-4466 Phase 1: Firebase Analytics (online-first). BoM aligns versions;
// firebase-analytics (not -ktx) exposes the Java API used by AnalyticsClient.
implementation platform('com.google.firebase:firebase-bom:33.7.0')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,15 @@ public void startDownload(Context context, String url, DownloadListener listener
: Aria2NetworkProfiler.shouldForceIpv4(aria2Bin, downloadDir, dhtFile, url, mainHandler, listener);
// ----------------------------------------------------

// 3. Build the command dynamically
// 3. Build the command dynamically.
// ADFA-4832 — SYNC WITH THE DASHBOARD. This flag set is the reference for the
// in-server aria2 (static/dashboard/sockets/kiwix.socket.ts), which runs the live
// "Get more" downloads. Keep both aligned so behaviour matches; if you change flags
// here, mirror them there (and note any intentional divergence). Known divergences:
// the server uses Debian's system CA (no bundled cacert), the container resolver
// (--async-dns=false) instead of ApplyDnsUseCase + the IPv4 profiler, and has no
// DHT/BitTorrent yet; only this app path pre-reconciles/resumes via MetalinkSplit +
// DownloadVerifier before invoking aria2.
java.util.List<String> command = new java.util.ArrayList<>();
command.add(aria2Bin.getAbsolutePath());
command.add("--dir=" + downloadDir.getAbsolutePath());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* ============================================================================
* Name : LiveContentClient.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : ADFA-4832 (Phase 1). App-side client of the in-server dashboard
* socket.io channel (nginx localhost:8085 -> Node :4000). It asks
* the ALREADY-RUNNING server to download + index a ZIM in-process,
* so we never spawn a second proot over the live rootfs (which was
* breaking Kiwix on "Get more"). Must be driven by the foreground
* InstallService: the server kills the job if this socket drops, so
* the connection has to outlive UI/config changes.
*
* Verified contract (static/dashboard/sockets/kiwix.socket.ts):
* emit start_kiwix_download <- ["<file>.zim", ...]
* on kiwix_terminal_output <- raw aria2/index stdout (String)
* on kiwix_process_status <- { isRunning: boolean }
* on refresh_kiwix_catalog <- (indexing finished / done)
* There is no explicit done/error event, so success is inferred
* from "Indexing complete"/refresh and failure from an error line
* or an isRunning:false that isn't followed by a refresh.
* ============================================================================
*/
package org.iiab.controller.content;

import android.os.Handler;
import android.os.Looper;

import androidx.annotation.NonNull;

import org.iiab.controller.config.BoxEndpoints;
import org.json.JSONArray;
import org.json.JSONObject;

import java.net.URISyntaxException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import io.socket.client.IO;
import io.socket.client.Socket;

public final class LiveContentClient {

/** All callbacks arrive on the socket.io worker thread; InstallProgressRepository posts are
* thread-safe, so the caller can forward them directly. Exactly one terminal call
* (onDone or onError) is guaranteed. */
public interface Listener {
void onProgress(int percent, String speed); // download phase (aria2)
void onIndexing(); // indexing phase started (indeterminate)
void onLog(String line); // raw terminal line (for logs)
void onDone(); // success (terminal)
void onError(String message); // failure / unreachable (terminal)
}

// Same shape aria2 prints and that Aria2Manager already parses: "...(37%)...DL:34MiB..."
private static final Pattern DL = Pattern.compile("\\((\\d+)%\\).*?DL:([^\\s]+)");
private static final long END_GRACE_MS = 1500L; // wait for a refresh after isRunning:false

private final Handler main = new Handler(Looper.getMainLooper());
private Socket socket;
private Listener listener;
private volatile boolean indexing = false;
private volatile boolean finished = false;
private Runnable pendingFail;

/** Connects and asks the running server to add (download + index) one ZIM. */
public void addZim(@NonNull String zimFilename, @NonNull Listener l) {
this.listener = l;
try {
// Default path is "/socket.io" and reconnection is on — matches the nginx proxy.
socket = IO.socket(BoxEndpoints.BASE);
} catch (URISyntaxException e) {
l.onError("bad dashboard URI: " + e.getMessage());
return;
}

socket.on(Socket.EVENT_CONNECT, a ->
socket.emit("start_kiwix_download", new JSONArray().put(zimFilename)));

socket.on(Socket.EVENT_CONNECT_ERROR, a ->
fail("Couldn't reach the content service"));

socket.on("kiwix_terminal_output", a -> {
String line = (a != null && a.length > 0 && a[0] != null) ? a[0].toString() : "";
if (line.isEmpty()) return;
listener.onLog(line);

if (line.contains("already running")) { fail("Another content job is already running"); return; }
if (line.contains("❌") || line.contains("[ERROR]")) { fail(line.trim()); return; }

if (!indexing && (line.contains("Starting indexing") || line.contains("iiab-make-kiwix-lib"))) {
indexing = true;
listener.onIndexing();
}
if (line.contains("Indexing complete")) { done(); return; }

if (!indexing) {
// A single event can carry many buffered aria2 lines at once, so take the LAST
// "(NN%)...DL:<rate>" in the blob — that's the most recent progress, not a stale one.
Matcher m = DL.matcher(line);
int lastPct = -1; String lastSpeed = null;
while (m.find()) {
try { lastPct = Integer.parseInt(m.group(1)); lastSpeed = m.group(2); }
catch (NumberFormatException ignore) { /* partial line */ }
}
if (lastPct >= 0) listener.onProgress(lastPct, lastSpeed);
}
});

// Definitive success: the server refreshes the catalog after (re)indexing.
socket.on("refresh_kiwix_catalog", a -> done());

// isRunning:false is ambiguous — it fires both right before a successful refresh and on a
// plain download failure. Wait a short grace for a refresh; if none arrives, it's a failure.
socket.on("kiwix_process_status", a -> {
boolean running = false;
if (a != null && a.length > 0 && a[0] instanceof JSONObject) {
running = ((JSONObject) a[0]).optBoolean("isRunning", false);
}
if (running) { cancelPendingFail(); return; }
if (finished) return;
cancelPendingFail();
pendingFail = () -> { if (!finished) fail("Download did not complete"); };
main.postDelayed(pendingFail, END_GRACE_MS);
});

socket.connect();
}

/** Best-effort cancel of an in-flight add. */
public void cancel() {
if (socket != null && socket.connected()) socket.emit("cancel_kiwix_download");
teardown();
}

private void done() {
if (finished) return;
finished = true;
cancelPendingFail();
Listener l = listener;
teardown();
if (l != null) l.onDone();
}

private void fail(String message) {
if (finished) return;
finished = true;
cancelPendingFail();
Listener l = listener;
teardown();
if (l != null) l.onError(message);
}

private void cancelPendingFail() {
if (pendingFail != null) { main.removeCallbacks(pendingFail); pendingFail = null; }
}

private void teardown() {
if (socket != null) {
socket.off();
socket.disconnect();
socket.close();
socket = null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public final class InstallService extends Service {

private Aria2Manager aria2Manager;
private PRootEngine prootEngine;
private org.iiab.controller.content.LiveContentClient liveContentClient; // ADFA-4832

private volatile boolean cancelled = false;
private volatile boolean finished = false;
Expand Down Expand Up @@ -350,6 +351,16 @@ public void onError(String error) {
}

private void downloadAndIndexKiwix(String zimFilename) {
// ADFA-4832: on an already-running system, adding a ZIM via a second proot
// (iiab-make-kiwix-lib) collides with the live server and breaks Kiwix. Route the add
// through the in-server dashboard channel — the running server downloads + indexes
// in-process, no new proot. The app-side proot path below stays for the fresh-install
// case (server down), which safely owns the rootfs exclusively.
if (org.iiab.controller.ServerStateRepository.get().current().alive) {
addZimViaLiveChannel(zimFilename);
return;
}

postProvisioning(getString(R.string.install_status_preparing_kiwix));

String zimUrl = "https://download.kiwix.org/zim/wikipedia/" + zimFilename;
Expand Down Expand Up @@ -389,6 +400,44 @@ public void onError(String error) {
});
}

/**
* ADFA-4832: add a ZIM on the LIVE system through the in-server dashboard channel (socket.io),
* so the running server does the download + index in-process (no second proot). This service is
* foreground, so it owns the connection for the whole job even across UI/config changes.
* On success we finish here rather than running the maps proot — maps on a live system needs the
* same channel migration (follow-up), and spawning that proot would re-introduce the collision.
*/
private void addZimViaLiveChannel(String zimFilename) {
postProvisioning(getString(R.string.install_status_preparing_kiwix));
liveContentClient = new org.iiab.controller.content.LiveContentClient();
liveContentClient.addZim(zimFilename, new org.iiab.controller.content.LiveContentClient.Listener() {
@Override public void onProgress(int percent, String speed) {
if (cancelled) return;
// ADFA-4830: install_status_zim_download no longer bakes the unit — the rate carries
// its own localized "/s". The live channel gives a raw rate, so append it here.
String rate = speed + getString(R.string.k2go_rate_per_second);
String text = getString(R.string.install_status_zim_download, percent, rate);
postProvisioning(text);
updateNotification(text);
}
@Override public void onIndexing() {
if (cancelled) return;
postProvisioning(getString(R.string.install_status_indexing_zim));
}
@Override public void onLog(String line) { log("[Kiwix-live] " + line); }
@Override public void onDone() {
if (cancelled) return;
finishSuccess();
}
@Override public void onError(String message) {
if (cancelled) return;
log("[Kiwix-live] add failed: " + message);
// Safe degrade: never spawn a colliding proot on the live system — surface the failure.
fail(getString(R.string.install_error_download, message));
}
});
}

private void runMapsAnsible() {
if (cancelled) return;

Expand Down Expand Up @@ -716,6 +765,7 @@ private void doCancel() {
finished = true;
try {
if (aria2Manager != null) aria2Manager.stopDownload();
if (liveContentClient != null) liveContentClient.cancel(); // ADFA-4832
} catch (Exception ignored) {
}
if (moduleMode) {
Expand Down
34 changes: 34 additions & 0 deletions controller/docs/ADR-4832-live-content-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ADR — Content management on the live system (app ↔ in-server channel)

Status: Accepted. Phase 1 (socket.io bridge) in progress; Phase 2 (REST + durable jobs) planned. Ticket: ADFA-4832.

## Context

- The IIAB server runs inside a **single long-lived proot**, co-located in the Android app process — it is **not** a remote 24/7 server. Keeping work alive across Android's lifecycle (backgrounding, low memory, battery/Doze) is the job of a **foreground service**, not of any network connection. (This is the lesson from the v1 design, where the app was a front-end to a separate Termux app that Android backgrounded and killed.)
- `PRootEngine` has no mutual exclusion. Adding content on a running system spawned a **second proot** (`iiab-make-kiwix-lib`) over the same rootfs, which collided with the live server (shared `/tmp`, `/dev/shm`, kiwix index, ports, service restarts, and a global `killall -9 proot`) and broke Kiwix.
- proot **cannot be entered after start** (ptrace sandbox, no nsenter/attach). Commands on the live system must be initiated by a process **already inside** the running proot. That process exists: the in-server **Node dashboard** (Express + socket.io on localhost:8085 → :4000, started by `pdsm`) already downloads + indexes ZIM/maps/books in-process.

## Decision

1. Content ops on the live system go through an **in-server control channel**, never app-side proot.
2. **Phase 1 (bridge):** the app becomes a client of the **existing dashboard socket.io** channel for ZIM add; the **foreground `InstallService` owns the connection** so UI/config changes don't drop it and kill the job. This fixes existing installs via an APK update (server code is provisioned, not APK-shipped). Scope: ZIM only.
3. **Phase 2 (target):** a proper **REST start + poll** (or SSE) contract with **durable, resumable jobs** that live in the server independent of any connection. This is the Android-appropriate model: short disposable localhost calls; the job is owned by the foreground service / proot and survives UI churn and even a socket/service restart. Ships via provisioning.
4. **Do NOT build a separate command server (cmdsrv).** It would replicate the dashboard's orchestration over the same Debian tools (`aria2c`, `iiab-make-kiwix-lib`, `tile-extract.py`, calibre-web) with no functional gain. The "Node is fragile" concern is really the `ts-node` dev-mode packaging → addressed in Phase 2 by compiling to JS + `pdsm` supervision/health, not by a parallel server. Keep cmdsrv as a last resort only if the dashboard proves too coupled after hardening.

## Why socket.io for Phase 1 but REST for Phase 2

- **socket.io** holds ONE long-lived connection for the whole job and, as currently built, ties job lifetime to the socket (`disconnect` kills it) — fragile on Android. Used in Phase 1 only because it is already deployed; mitigated by the foreground service owning the socket.
- **REST start+poll** uses short, independent, localhost calls; the long-running job runs detached in the proot owned by the foreground service — there is no long-lived connection to lose. Preferred end state.
- Real-time is not a reliability requirement here: localhost polling every ~1s is effectively free and "live enough".

## Consequences / caveats (Phase 1)

- Progress arrives as raw terminal text; we reuse the aria2 progress regex to extract `%`. Indexing is shown as indeterminate.
- No explicit done/error event: success is inferred from "Indexing complete"/`refresh_kiwix_catalog`; failure from an error line or an `isRunning:false` not followed by a refresh.
- The app-side IPv4/IPv6 network profiler is not used for the ZIM download (the server's `aria2c` is a plain spawn). Acceptable now; port in Phase 2 if needed.
- Maps/books on a live system still need the same migration (follow-up). Phase 1 skips the maps proot on the live path to avoid re-introducing a collision.
- If the app process is fully killed, the job dies (server kills on disconnect). Phase 2 durable jobs remove this.

## References

`PRootEngine.java`, `InstallService.downloadAndIndexKiwix`, `ServerController`, `static/dashboard/server.ts` + `sockets/kiwix.socket.ts`, `LiveContentClient.java`. Ticket ADFA-4832.
33 changes: 32 additions & 1 deletion static/dashboard/sockets/kiwix.socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,38 @@ export const handleKiwixEvents = (socket: Socket) => {
socket.emit('kiwix_terminal_output', `\n[System] Starting download...\n`);
socket.emit('kiwix_process_status', { isRunning: true });

const args = ['-d', ZIMS_DIR, '-c', '-Z', '-x', '4', '-s', '4', '-j', '5', '--async-dns=false', ...urls];
// ADFA-4832 — SYNC WITH THE APP. These aria2 flags mirror the app-side downloader
// (controller/app/.../Aria2Manager.java), which is the better-tested reference, so an
// in-server "Get more" behaves like an app-side download. Keep the two lists aligned; if
// you change one, change the other and document any new divergence.
// Aligned: --continue, --allow-overwrite, --auto-file-renaming=false,
// --max-connection-per-server=4 (MetalinkSplit.CONNECTIONS_PER_MIRROR),
// --split=16 (MetalinkSplit.MAX_SPLIT; kiwix metalinks are multi-mirror),
// --follow-metalink=mem, --check-integrity=true (verify the .meta4 SHA-256s),
// --console-log-level=warn, --summary-interval=1 (real-time progress when piped),
// --download-result=hide.
// Intentional divergences (different environment): TLS uses Debian's system CA here vs a
// bundled cacert in the app; DNS is the container resolver (--async-dns=false) vs the
// app's ApplyDnsUseCase + IPv4 profiler; no DHT/BitTorrent yet (app enables it — future
// alignment, needs a writable dht path); the app also pre-reconciles/resumes via
// MetalinkSplit + DownloadVerifier before invoking aria2.
const args = [
'-d', ZIMS_DIR,
'--continue=true',
'--allow-overwrite=true',
'--auto-file-renaming=false',
'--max-connection-per-server=4',
'--split=16',
'--follow-metalink=mem',
'--check-integrity=true',
'--console-log-level=warn',
'--summary-interval=1',
'--download-result=hide',
'--async-dns=false',
'-Z', // sequential when several ZIMs are queued (the dashboard can batch)
'-j', '5',
...urls,
];
downloadProcess = spawn('/usr/bin/aria2c', args);

downloadProcess.stdout?.on('data', (data) => socket.emit('kiwix_terminal_output', data.toString()));
Expand Down