diff --git a/controller/app/src/main/AndroidManifest.xml b/controller/app/src/main/AndroidManifest.xml
index 946096c7..e191d112 100644
--- a/controller/app/src/main/AndroidManifest.xml
+++ b/controller/app/src/main/AndroidManifest.xml
@@ -126,6 +126,11 @@
android:exported="false"
android:foregroundServiceType="specialUse" />
+
+
catalog rows (offline FTS)
+ * GET /api/books/library -> Calibre-Web library rows
+ * POST /api/books/library/:id/remove -> delete
+ * (The actual download is a durable job driven by BooksDownloadService.)
+ * Search works offline (the catalog is synced on the server); covers + the EPUB
+ * download need internet.
+ * ============================================================================
+ */
+package org.iiab.controller.redesign;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import org.iiab.controller.config.BoxEndpoints;
+import org.iiab.controller.util.AppExecutors;
+import org.json.JSONArray;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+public final class BooksClient {
+ private BooksClient() {}
+
+ private static final String BASE = BoxEndpoints.BASE + "/api/books";
+ private static final Handler MAIN = new Handler(Looper.getMainLooper());
+
+ public interface ArrayCb { void onOk(JSONArray rows); void onErr(String message); }
+ public interface OkCb { void onOk(); void onErr(String message); }
+
+ /** Search the offline Gutenberg catalog. filter: ""|"educational"; lang: ""=all or an ISO code;
+ * q empty => top-by-downloads. */
+ public static void search(String q, String filter, String lang, int limit, ArrayCb cb) {
+ AppExecutors.get().io().execute(() -> {
+ try {
+ String url = BASE + "/search?limit=" + limit
+ + "&filter=" + enc(filter == null ? "" : filter)
+ + "&lang=" + enc(lang == null ? "" : lang)
+ + "&q=" + enc(q == null ? "" : q);
+ JSONArray a = new JSONArray(httpGet(url));
+ MAIN.post(() -> cb.onOk(a));
+ } catch (Exception e) {
+ MAIN.post(() -> cb.onErr("couldn't reach the content service"));
+ }
+ });
+ }
+
+ /** The distinct languages available in the catalog (rows: {code, count}), most-stocked first. */
+ public static void languages(ArrayCb cb) {
+ AppExecutors.get().io().execute(() -> {
+ try {
+ JSONArray a = new JSONArray(httpGet(BASE + "/languages"));
+ MAIN.post(() -> cb.onOk(a));
+ } catch (Exception e) {
+ MAIN.post(() -> cb.onErr("couldn't reach the content service"));
+ }
+ });
+ }
+
+ /** The local Calibre-Web library (books already added). */
+ public static void library(ArrayCb cb) {
+ AppExecutors.get().io().execute(() -> {
+ try {
+ JSONArray a = new JSONArray(httpGet(BASE + "/library"));
+ MAIN.post(() -> cb.onOk(a));
+ } catch (Exception e) {
+ MAIN.post(() -> cb.onErr("couldn't reach the content service"));
+ }
+ });
+ }
+
+ /** Remove a book from the Calibre-Web library by its id. */
+ public static void remove(int id, OkCb cb) {
+ AppExecutors.get().io().execute(() -> {
+ try {
+ httpPostEmpty(BASE + "/library/" + id + "/remove");
+ MAIN.post(cb::onOk);
+ } catch (Exception e) {
+ MAIN.post(() -> cb.onErr("remove failed"));
+ }
+ });
+ }
+
+ private static String enc(String s) {
+ try { return URLEncoder.encode(s, "UTF-8"); } catch (Exception e) { return ""; }
+ }
+
+ private static String httpGet(String urlStr) throws Exception {
+ HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
+ try {
+ c.setUseCaches(false);
+ c.setConnectTimeout(5000);
+ c.setReadTimeout(8000);
+ c.setRequestProperty("Accept", "application/json");
+ int code = c.getResponseCode();
+ String text = readAll(code >= 200 && code < 400 ? c.getInputStream() : c.getErrorStream());
+ if (code < 200 || code >= 400) throw new Exception("HTTP " + code + ": " + text);
+ return text.isEmpty() ? "[]" : text;
+ } finally {
+ c.disconnect();
+ }
+ }
+
+ private static void httpPostEmpty(String urlStr) throws Exception {
+ HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
+ try {
+ c.setUseCaches(false);
+ c.setConnectTimeout(5000);
+ c.setReadTimeout(15000);
+ c.setRequestMethod("POST");
+ c.setRequestProperty("Accept", "application/json");
+ int code = c.getResponseCode();
+ if (code < 200 || code >= 400) throw new Exception("HTTP " + code);
+ } finally {
+ c.disconnect();
+ }
+ }
+
+ private static String readAll(InputStream is) throws Exception {
+ if (is == null) return "";
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ byte[] chunk = new byte[4096];
+ int n;
+ while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n);
+ is.close();
+ return buf.toString(StandardCharsets.UTF_8.name());
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadService.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadService.java
new file mode 100644
index 00000000..3e06662b
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadService.java
@@ -0,0 +1,272 @@
+/*
+ * ============================================================================
+ * Name : BooksDownloadService.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : ADFA-4850. Foreground download manager for Books, following the ZIM pattern
+ * (CLAUDE.md): sequential, ONE AT A TIME (kind to Project Gutenberg), continuing
+ * past a failed item, per-item retry, "Finish" to clear the session. Each book is
+ * its own durable REST job: POST /api/books/download {items:[{id,title,url}]} then
+ * poll /api/books/jobs/:id (the server downloads the EPUB from Gutenberg and
+ * uploads it into Calibre-Web). The device only POSTs + polls.
+ * ============================================================================
+ */
+package org.iiab.controller.redesign;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.ContextCompat;
+
+import org.iiab.controller.MainActivity;
+import org.iiab.controller.R;
+import org.iiab.controller.config.BoxEndpoints;
+import org.iiab.controller.util.AppExecutors;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+public final class BooksDownloadService extends Service {
+
+ private static final String CHANNEL_ID = "books_download_channel";
+ private static final int NOTIFICATION_ID = 6;
+ private static final String BASE = BoxEndpoints.BASE + "/api/books";
+ private static final long POLL_MS = 1000L;
+
+ public static final String ACTION_START = "org.iiab.controller.BOOKS_DOWNLOAD_START";
+ public static final String ACTION_RETRY = "org.iiab.controller.BOOKS_DOWNLOAD_RETRY";
+ public static final String ACTION_CANCEL = "org.iiab.controller.BOOKS_DOWNLOAD_CANCEL";
+ public static final String EXTRA_IDS = "ids";
+ public static final String EXTRA_TITLES = "titles";
+ public static final String EXTRA_URLS = "urls";
+
+ public static final int PENDING = 0, ACTIVE = 1, ADDING = 2, DONE = 3, FAILED = 4;
+
+ public interface Listener { void onUpdate(); }
+
+ // ---- shared session state (observed by the Downloads screen) ----
+ private static volatile boolean sRunning = false;
+ private static String[] sIds = new String[0];
+ private static String[] sTitles = new String[0];
+ private static String[] sUrls = new String[0];
+ private static int[] sStatus = new int[0];
+ private static int sIndex = 0;
+ private static Listener sListener;
+
+ public static boolean isRunning() { return sRunning; }
+ public static boolean hasSession() { return sIds.length > 0; }
+ public static boolean isComplete() {
+ if (sIds.length == 0 || sRunning) return false;
+ for (int st : sStatus) if (st == PENDING || st == ACTIVE || st == ADDING) return false;
+ return true;
+ }
+ public static String[] titles() { return sTitles; }
+ public static int[] status() { return sStatus; }
+ public static int index() { return sIndex; }
+ public static void setListener(Listener l) { sListener = l; }
+
+ public static void start(Context ctx, String[] ids, String[] titles, String[] urls) {
+ Intent i = new Intent(ctx, BooksDownloadService.class).setAction(ACTION_START)
+ .putExtra(EXTRA_IDS, ids).putExtra(EXTRA_TITLES, titles).putExtra(EXTRA_URLS, urls);
+ ContextCompat.startForegroundService(ctx, i);
+ }
+
+ public static void retry(Context ctx, int i) {
+ if (i < 0 || i >= sStatus.length) return;
+ sStatus[i] = PENDING;
+ if (!sRunning) ContextCompat.startForegroundService(ctx,
+ new Intent(ctx, BooksDownloadService.class).setAction(ACTION_RETRY));
+ }
+
+ public static void finishSession() {
+ sIds = new String[0]; sTitles = new String[0]; sUrls = new String[0]; sStatus = new int[0];
+ sIndex = 0; sRunning = false;
+ }
+
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private volatile String currentJobId;
+ private volatile boolean canceled = false;
+
+ @Override public void onCreate() { super.onCreate(); createNotificationChannel(); }
+ @Nullable @Override public IBinder onBind(Intent intent) { return null; }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ String action = intent != null ? intent.getAction() : null;
+ if (ACTION_CANCEL.equals(action)) {
+ canceled = true; sRunning = false; publish();
+ main.post(() -> { stopForeground(true); stopSelf(); });
+ return START_NOT_STICKY;
+ }
+ if (sRunning) return START_NOT_STICKY;
+
+ if (ACTION_RETRY.equals(action)) {
+ if (!hasSession()) { stopSelf(); return START_NOT_STICKY; }
+ } else {
+ String[] ids = intent.getStringArrayExtra(EXTRA_IDS);
+ if (ids == null || ids.length == 0) { stopSelf(); return START_NOT_STICKY; }
+ sIds = ids;
+ sTitles = intent.getStringArrayExtra(EXTRA_TITLES);
+ sUrls = intent.getStringArrayExtra(EXTRA_URLS);
+ if (sTitles == null) sTitles = ids;
+ if (sUrls == null) sUrls = new String[ids.length];
+ sStatus = new int[ids.length];
+ sIndex = 0;
+ }
+ canceled = false;
+ sRunning = true;
+ startForeground(NOTIFICATION_ID, buildNotification(currentTitle()));
+ processNext();
+ return START_NOT_STICKY;
+ }
+
+ private static int firstPending() {
+ for (int i = 0; i < sStatus.length; i++) if (sStatus[i] == PENDING) return i;
+ return -1;
+ }
+
+ private String currentTitle() { return sIndex >= 0 && sIndex < sTitles.length ? sTitles[sIndex] : ""; }
+
+ private void processNext() {
+ int i = firstPending();
+ if (i < 0) { sessionComplete(); return; }
+ sIndex = i; sStatus[i] = ACTIVE;
+ publish();
+ updateNotification(sTitles[i]);
+ AppExecutors.get().io().execute(() -> startJob(i));
+ }
+
+ private void startJob(int i) {
+ try {
+ JSONObject item = new JSONObject().put("id", sIds[i]).put("title", sTitles[i]).put("url", sUrls[i]);
+ JSONObject body = new JSONObject().put("items", new JSONArray().put(item));
+ JSONObject resp = httpJson("POST", BASE + "/download", body);
+ String id = resp.optString("id", "");
+ if (id.isEmpty()) { fail(i); return; }
+ currentJobId = id;
+ main.postDelayed(() -> poll(i), POLL_MS);
+ } catch (Exception e) {
+ fail(i);
+ }
+ }
+
+ private void poll(int i) {
+ if (canceled) return;
+ AppExecutors.get().io().execute(() -> {
+ try {
+ JSONObject j = httpJson("GET", BASE + "/jobs/" + currentJobId, null);
+ String phase = j.optString("phase", "");
+ switch (phase) {
+ case "done":
+ sStatus[i] = DONE; publish(); main.post(BooksDownloadService.this::processNext); return;
+ case "error":
+ case "canceled":
+ fail(i); return;
+ case "processing":
+ if (sStatus[i] != ADDING) { sStatus[i] = ADDING; publish(); }
+ break;
+ default: break; // queued
+ }
+ main.postDelayed(() -> poll(i), POLL_MS);
+ } catch (Exception e) {
+ main.postDelayed(() -> poll(i), POLL_MS); // tolerate transient blips
+ }
+ });
+ }
+
+ private void fail(int i) {
+ sStatus[i] = FAILED; publish();
+ main.post(this::processNext);
+ }
+
+ private void sessionComplete() {
+ sRunning = false; publish();
+ main.post(() -> { stopForeground(true); stopSelf(); });
+ }
+
+ private void publish() { main.post(() -> { if (sListener != null) sListener.onUpdate(); }); }
+
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel ch = new NotificationChannel(
+ CHANNEL_ID, getString(R.string.k2go_books_dl_channel_name), NotificationManager.IMPORTANCE_LOW);
+ NotificationManager m = getSystemService(NotificationManager.class);
+ if (m != null) m.createNotificationChannel(ch);
+ }
+ }
+
+ private Notification buildNotification(String title) {
+ PendingIntent open = PendingIntent.getActivity(this, 0,
+ new Intent(this, MainActivity.class), PendingIntent.FLAG_IMMUTABLE);
+ PendingIntent cancel = PendingIntent.getService(this, 1,
+ new Intent(this, BooksDownloadService.class).setAction(ACTION_CANCEL),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ return new NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.k2go_books_dl_notif_title))
+ .setContentText(title)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentIntent(open)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOnlyAlertOnce(true)
+ .addAction(0, getString(R.string.k2go_zim_notif_cancel), cancel)
+ .build();
+ }
+
+ private void updateNotification(String title) {
+ if (!sRunning) return;
+ NotificationManager m = getSystemService(NotificationManager.class);
+ if (m != null) m.notify(NOTIFICATION_ID, buildNotification(title));
+ }
+
+ private static JSONObject httpJson(String method, String urlStr, JSONObject body) throws Exception {
+ HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
+ try {
+ c.setUseCaches(false);
+ c.setConnectTimeout(5000);
+ c.setReadTimeout(8000);
+ c.setRequestMethod(method);
+ c.setRequestProperty("Accept", "application/json");
+ if (body != null) {
+ c.setDoOutput(true);
+ c.setRequestProperty("Content-Type", "application/json");
+ byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8);
+ try (OutputStream os = c.getOutputStream()) { os.write(payload); }
+ }
+ int code = c.getResponseCode();
+ boolean ok = code >= 200 && code < 400;
+ String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
+ if (!ok) throw new Exception("HTTP " + code + ": " + text);
+ return new JSONObject(text.isEmpty() ? "{}" : text);
+ } finally {
+ c.disconnect();
+ }
+ }
+
+ private static String readAll(InputStream is) throws Exception {
+ if (is == null) return "";
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ byte[] chunk = new byte[4096];
+ int n;
+ while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n);
+ is.close();
+ return buf.toString(StandardCharsets.UTF_8.name());
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadsFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadsFragment.java
new file mode 100644
index 00000000..dfa8aaad
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadsFragment.java
@@ -0,0 +1,174 @@
+/*
+ * ============================================================================
+ * Name : BooksDownloadsFragment.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : ADFA-4850. Books download manager screen (sibling of ZimPreparingFragment).
+ * Observes BooksDownloadService — which adds books ONE AT A TIME (kind to Project
+ * Gutenberg) and continues past failures — and shows a per-book checklist
+ * (round check when done, teal dot while downloading/adding, amber + Retry when
+ * failed, gray when queued) plus "X of N books". The service is the source of truth,
+ * so this screen re-attaches to an in-flight session; "Run in background" leaves it
+ * running and "Finish" clears the session. Reading happens on the home "Read a Book"
+ * card, never here.
+ * ============================================================================
+ */
+package org.iiab.controller.redesign;
+
+import android.content.res.ColorStateList;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+
+import org.iiab.controller.R;
+
+public class BooksDownloadsFragment extends Fragment {
+
+ private TextView detail;
+ private LinearLayout listv;
+ private Button finishBtn, runBgBtn;
+
+ private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle s) {
+ View root = inflater.inflate(R.layout.fragment_k2go_books_downloads, container, false);
+
+ detail = root.findViewById(R.id.k2go_bdl_detail);
+ listv = root.findViewById(R.id.k2go_bdl_list);
+
+ runBgBtn = root.findViewById(R.id.k2go_bdl_run_bg);
+ runBgBtn.setOnClickListener(v -> {
+ BooksDownloadService.setListener(null); // stop observing; the server keeps going
+ requireActivity().getSupportFragmentManager().popBackStack();
+ });
+ finishBtn = root.findViewById(R.id.k2go_bdl_finish);
+ finishBtn.setOnClickListener(v -> {
+ BooksDownloadService.finishSession(); // clear the session; free it for a new list
+ requireActivity().getSupportFragmentManager().popBackStack();
+ });
+
+ BooksDownloadService.setListener(this::render);
+ render();
+ return root;
+ }
+
+ private void render() {
+ if (!isAdded()) return;
+ String[] titles = BooksDownloadService.titles();
+ int[] status = BooksDownloadService.status();
+ int n = titles.length;
+ if (n == 0) { requireActivity().getSupportFragmentManager().popBackStack(); return; }
+
+ int done = 0;
+ for (int st : status) if (st == BooksDownloadService.DONE) done++;
+ detail.setText(getString(R.string.k2go_books_dl_detail_fmt, done, n));
+
+ drawChecklist(titles, status);
+
+ boolean complete = BooksDownloadService.isComplete();
+ finishBtn.setEnabled(complete);
+ runBgBtn.setVisibility(complete ? View.GONE : View.VISIBLE);
+ }
+
+ private void drawChecklist(String[] titles, int[] status) {
+ listv.removeAllViews();
+ for (int i = 0; i < titles.length; i++) {
+ int st = status[i];
+ boolean done = st == BooksDownloadService.DONE;
+ boolean failed = st == BooksDownloadService.FAILED;
+ boolean active = st == BooksDownloadService.ACTIVE || st == BooksDownloadService.ADDING;
+
+ LinearLayout r = new LinearLayout(requireContext());
+ r.setOrientation(LinearLayout.HORIZONTAL);
+ r.setGravity(Gravity.CENTER_VERTICAL);
+ r.setPadding(0, px(6), 0, px(6));
+
+ if (done) {
+ ImageView chk = new ImageView(requireContext());
+ chk.setImageResource(R.drawable.ic_check_circle);
+ chk.setColorFilter(ContextCompat.getColor(requireContext(), R.color.k2go_leaf));
+ LinearLayout.LayoutParams clp = new LinearLayout.LayoutParams(px(16), px(16));
+ clp.rightMargin = px(8);
+ r.addView(chk, clp);
+ } else {
+ View dot = new View(requireContext());
+ dot.setBackgroundResource(R.drawable.k2go_dot);
+ int c = failed ? R.color.k2go_amber : (active ? R.color.k2go_teal : R.color.k2go_hairline);
+ dot.setBackgroundTintList(ColorStateList.valueOf(ContextCompat.getColor(requireContext(), c)));
+ LinearLayout.LayoutParams dlp = new LinearLayout.LayoutParams(px(10), px(10));
+ dlp.leftMargin = px(3);
+ dlp.rightMargin = px(11);
+ r.addView(dot, dlp);
+ }
+
+ LinearLayout col = new LinearLayout(requireContext());
+ col.setOrientation(LinearLayout.VERTICAL);
+
+ TextView t = new TextView(requireContext());
+ t.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
+ t.setText(titles[i]);
+ t.setMaxLines(2);
+ int tc = failed ? R.color.k2go_amber_text : (done || active ? R.color.k2go_ink : R.color.k2go_muted);
+ t.setTextColor(ContextCompat.getColor(requireContext(), tc));
+ col.addView(t);
+
+ TextView state = new TextView(requireContext());
+ state.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
+ state.setText(stateLabel(st));
+ state.setTextColor(ContextCompat.getColor(requireContext(),
+ failed ? R.color.k2go_amber_text : R.color.k2go_muted));
+ col.addView(state);
+
+ r.addView(col, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
+
+ if (failed) {
+ final int idx = i;
+ TextView retry = new TextView(requireContext());
+ retry.setText(R.string.k2go_zim_retry);
+ retry.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
+ retry.setTypeface(retry.getTypeface(), Typeface.BOLD);
+ retry.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal));
+ retry.setPadding(px(12), px(6), px(12), px(6));
+ retry.setBackgroundResource(R.drawable.k2go_getmore_bg);
+ retry.setClickable(true);
+ retry.setOnClickListener(v -> BooksDownloadService.retry(requireContext().getApplicationContext(), idx));
+ LinearLayout.LayoutParams retryLp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ retryLp.leftMargin = px(8);
+ r.addView(retry, retryLp);
+ }
+
+ listv.addView(r);
+ }
+ }
+
+ private String stateLabel(int st) {
+ switch (st) {
+ case BooksDownloadService.ACTIVE: return getString(R.string.k2go_books_state_downloading);
+ case BooksDownloadService.ADDING: return getString(R.string.k2go_books_state_adding);
+ case BooksDownloadService.DONE: return getString(R.string.k2go_books_state_done);
+ case BooksDownloadService.FAILED: return getString(R.string.k2go_books_state_failed);
+ default: return getString(R.string.k2go_books_state_queued);
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ BooksDownloadService.setListener(null);
+ super.onDestroyView();
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java
new file mode 100644
index 00000000..8df4d7a3
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java
@@ -0,0 +1,382 @@
+/*
+ * ============================================================================
+ * Name : BooksLandingFragment.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : ADFA-4850. Get More Books. Browse/search the offline Gutenberg catalog
+ * (/api/books/search), a 2-column cover grid with multi-select; category chips
+ * (Popular / Educational / My books). "Add to library" hands the selection to
+ * BooksDownloadService (one at a time, retry, background). The "My books" chip lists
+ * the local Calibre-Web library (/api/books/library); tapping a local book opens its
+ * Calibre-Web page (all details + Read / Download) — reading itself lives there and on
+ * the home "Read a Book" card, never here.
+ *
+ * State is shown with a band UNDER the title (not a whole-card tint, which was hard to
+ * read): a green "In your books" band for library titles, a gray "Selected" band for
+ * picks. Covers use a fixed palette (k2go_cover_*) with white text so contrast holds in
+ * both light and dark themes — semantic tokens flipped to near-white in dark mode.
+ * ============================================================================
+ */
+package org.iiab.controller.redesign;
+
+import android.content.Intent;
+import android.graphics.Typeface;
+import android.graphics.drawable.GradientDrawable;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+
+import org.iiab.controller.PortalActivity;
+import org.iiab.controller.R;
+import org.iiab.controller.config.BoxEndpoints;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+public class BooksLandingFragment extends Fragment {
+
+ private LinearLayout grid, chips;
+ private TextView status, downloadsLink, langPill;
+ private Button addBtn;
+
+ private String filter = ""; // "" = Popular, "educational", "local" = My books
+ private String query = "";
+ private String lang = ""; // "" = all languages, else an ISO code from the catalog
+ private final List langCodes = new ArrayList<>(); // languages present in the catalog
+ private final List books = new ArrayList<>();
+ private final LinkedHashMap selected = new LinkedHashMap<>();
+ private final Set libraryTitles = new HashSet<>();
+
+ // Fixed cover palette: dark enough for white text, identical in light + dark themes.
+ private final int[] palette = {
+ R.color.k2go_cover_1, R.color.k2go_cover_2, R.color.k2go_cover_3,
+ R.color.k2go_cover_4, R.color.k2go_cover_5, R.color.k2go_cover_6};
+
+ private boolean isLocal() { return "local".equals(filter); }
+
+ private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle s) {
+ View root = inflater.inflate(R.layout.fragment_k2go_books_landing, container, false);
+
+ TextView back = root.findViewById(R.id.k2go_books_back);
+ back.setText("‹ " + getString(R.string.k2go_gm_hub_title));
+ back.setOnClickListener(v -> requireActivity().getSupportFragmentManager().popBackStack());
+
+ grid = root.findViewById(R.id.k2go_books_grid);
+ chips = root.findViewById(R.id.k2go_books_chips);
+ status = root.findViewById(R.id.k2go_books_status);
+ addBtn = root.findViewById(R.id.k2go_books_add);
+ downloadsLink = root.findViewById(R.id.k2go_books_downloads_link);
+ langPill = root.findViewById(R.id.k2go_books_lang);
+ langPill.setOnClickListener(v -> openLanguagePicker());
+ updateLangPill();
+
+ android.widget.EditText search = root.findViewById(R.id.k2go_books_search);
+ search.addTextChangedListener(new android.text.TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
+ @Override public void onTextChanged(CharSequence s, int a, int b, int c) {
+ if (isLocal()) return; // search applies to the catalog, not the local list
+ query = s.toString().trim(); loadBooks();
+ }
+ @Override public void afterTextChanged(android.text.Editable s) {}
+ });
+
+ buildChips();
+ addBtn.setOnClickListener(v -> startDownloads());
+ downloadsLink.setOnClickListener(v -> openDownloads());
+
+ loadLanguages();
+ loadLibrary();
+ loadBooks();
+ return root;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ loadLibrary(); // a book may have finished adding; refresh badges
+ if (isLocal()) loadBooks();
+ refreshFooter();
+ }
+
+ private void buildChips() {
+ chips.removeAllViews();
+ chips.addView(chip(getString(R.string.k2go_books_chip_popular), filter.isEmpty(), () -> { filter = ""; query = ""; onFilterChanged(); }));
+ chips.addView(gap());
+ chips.addView(chip(getString(R.string.k2go_books_chip_edu), "educational".equals(filter), () -> { filter = "educational"; query = ""; onFilterChanged(); }));
+ chips.addView(gap());
+ chips.addView(chip(getString(R.string.k2go_books_chip_local), isLocal(), () -> { filter = "local"; query = ""; onFilterChanged(); }));
+ }
+
+ private View gap() {
+ View g = new View(requireContext());
+ g.setLayoutParams(new LinearLayout.LayoutParams(px(8), 1));
+ return g;
+ }
+
+ private void onFilterChanged() {
+ buildChips(); // repaint active state
+ updateLangPill(); // hidden for the local library (not catalog-language filtered)
+ loadBooks();
+ refreshFooter();
+ }
+
+ private void loadLanguages() {
+ BooksClient.languages(new BooksClient.ArrayCb() {
+ @Override public void onOk(JSONArray rows) {
+ if (!isAdded()) return;
+ langCodes.clear();
+ for (int i = 0; i < rows.length(); i++) {
+ JSONObject r = rows.optJSONObject(i);
+ String code = r != null ? r.optString("code", "").trim() : "";
+ if (!code.isEmpty()) langCodes.add(code);
+ }
+ }
+ @Override public void onErr(String m) { /* leave the picker empty; "All" still works */ }
+ });
+ }
+
+ private String langName(String code) {
+ if (code == null || code.isEmpty()) return code;
+ String n = new Locale(code).getDisplayLanguage();
+ return (n == null || n.isEmpty() || n.equalsIgnoreCase(code)) ? code : n;
+ }
+
+ private void updateLangPill() {
+ if (langPill == null) return;
+ langPill.setVisibility(isLocal() ? View.GONE : View.VISIBLE);
+ langPill.setText(lang.isEmpty()
+ ? getString(R.string.k2go_books_lang_all)
+ : getString(R.string.k2go_books_lang_fmt, langName(lang)));
+ }
+
+ private void openLanguagePicker() {
+ ZimLanguageDialog.show(requireContext(), getString(R.string.k2go_books_lang_title),
+ langCodes, this::langName, lang,
+ code -> { lang = code; updateLangPill(); loadBooks(); },
+ getString(R.string.k2go_books_lang_all),
+ () -> { lang = ""; updateLangPill(); loadBooks(); });
+ }
+
+ private TextView chip(String text, boolean on, Runnable onClick) {
+ TextView t = new TextView(requireContext());
+ t.setText(text);
+ t.setPadding(px(14), px(8), px(14), px(8));
+ t.setBackgroundResource(on ? R.drawable.k2go_chip_bg : R.drawable.k2go_pill_bg);
+ t.setTextColor(ContextCompat.getColor(requireContext(), on ? R.color.k2go_on_teal : R.color.k2go_ink));
+ t.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
+ t.setClickable(true);
+ t.setOnClickListener(v -> onClick.run());
+ return t;
+ }
+
+ private void loadLibrary() {
+ BooksClient.library(new BooksClient.ArrayCb() {
+ @Override public void onOk(JSONArray rows) {
+ if (!isAdded()) return;
+ libraryTitles.clear();
+ for (int i = 0; i < rows.length(); i++) {
+ JSONObject b = rows.optJSONObject(i);
+ if (b != null) libraryTitles.add(b.optString("title", "").toLowerCase(Locale.ROOT).trim());
+ }
+ if (!isLocal()) render();
+ }
+ @Override public void onErr(String m) { /* keep whatever we had */ }
+ });
+ }
+
+ private void loadBooks() {
+ status.setVisibility(View.VISIBLE);
+ status.setText(getString(R.string.k2go_books_loading));
+ grid.removeAllViews();
+ BooksClient.ArrayCb cb = new BooksClient.ArrayCb() {
+ @Override public void onOk(JSONArray rows) {
+ if (!isAdded()) return;
+ books.clear();
+ for (int i = 0; i < rows.length(); i++) { JSONObject b = rows.optJSONObject(i); if (b != null) books.add(b); }
+ render();
+ }
+ @Override public void onErr(String m) {
+ if (!isAdded()) return;
+ status.setVisibility(View.VISIBLE);
+ status.setText(getString(R.string.k2go_books_unavailable));
+ }
+ };
+ if (isLocal()) BooksClient.library(cb);
+ else BooksClient.search(query, filter, lang, 40, cb);
+ }
+
+ private boolean inLibrary(JSONObject b) {
+ return libraryTitles.contains(b.optString("title", "").toLowerCase(Locale.ROOT).trim());
+ }
+
+ private void render() {
+ grid.removeAllViews();
+ boolean empty = books.isEmpty();
+ status.setVisibility(empty ? View.VISIBLE : View.GONE);
+ if (empty) status.setText(getString(isLocal() ? R.string.k2go_books_local_none : R.string.k2go_books_none));
+
+ for (int i = 0; i < books.size(); i += 2) {
+ LinearLayout row = new LinearLayout(requireContext());
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ grid.addView(row, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ for (int k = i; k < i + 2 && k < books.size(); k++) {
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
+ row.addView(cell(books.get(k)), lp);
+ }
+ if (i + 1 >= books.size()) { // pad a lone last cell so it stays half-width
+ View pad = new View(requireContext());
+ row.addView(pad, new LinearLayout.LayoutParams(0, 1, 1f));
+ }
+ }
+ refreshFooter();
+ }
+
+ private View cell(JSONObject b) {
+ boolean local = isLocal();
+ String title = b.optString("title", "");
+ String author = b.optString("author", "");
+ String gid = b.optString("gutenberg_id", "");
+ boolean lib = !local && inLibrary(b);
+ boolean sel = !local && selected.containsKey(gid);
+
+ LinearLayout box = new LinearLayout(requireContext());
+ box.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams boxLp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ boxLp.setMargins(px(6), px(6), px(6), px(6));
+ box.setLayoutParams(boxLp);
+
+ // Colored cover (placeholder; real covers load later from cover_url).
+ LinearLayout cover = new LinearLayout(requireContext());
+ cover.setOrientation(LinearLayout.VERTICAL);
+ cover.setGravity(Gravity.CENTER);
+ cover.setPadding(px(12), px(14), px(12), px(14));
+ int color = ContextCompat.getColor(requireContext(), palette[Math.abs(title.hashCode()) % palette.length]);
+ GradientDrawable bg = new GradientDrawable();
+ bg.setColor(color);
+ bg.setCornerRadius(px(10));
+ if (sel) bg.setStroke(px(3), ContextCompat.getColor(requireContext(), R.color.k2go_teal));
+ cover.setBackground(bg);
+ cover.setMinimumHeight(px(150));
+
+ TextView tt = new TextView(requireContext());
+ tt.setText(title);
+ tt.setMaxLines(4);
+ tt.setGravity(Gravity.CENTER);
+ tt.setTypeface(tt.getTypeface(), Typeface.BOLD);
+ tt.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleSmall);
+ tt.setTextColor(0xFFFFFFFF);
+ cover.addView(tt);
+
+ // State band UNDER the title (green = in your books, gray = selected); else the author.
+ if (lib) {
+ cover.addView(band(R.color.k2go_band_library, getString(R.string.k2go_books_in_library)));
+ } else if (sel) {
+ cover.addView(band(R.color.k2go_band_selected, getString(R.string.k2go_books_selected)));
+ } else {
+ TextView sub = new TextView(requireContext());
+ sub.setGravity(Gravity.CENTER);
+ sub.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
+ sub.setTextColor(0xCCFFFFFF);
+ sub.setText(author);
+ LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ slp.topMargin = px(8);
+ cover.addView(sub, slp);
+ }
+
+ box.addView(cover);
+
+ if (local) {
+ final int id = b.optInt("id", -1);
+ box.setOnClickListener(v -> openLocalBook(id));
+ } else if (!lib) {
+ box.setOnClickListener(v -> {
+ if (selected.containsKey(gid)) selected.remove(gid); else selected.put(gid, b);
+ render();
+ });
+ }
+ return box;
+ }
+
+ /** A pill band under the title. A faint white stroke keeps it legible on any cover color. */
+ private TextView band(int colorRes, String text) {
+ TextView t = new TextView(requireContext());
+ t.setText(text);
+ t.setGravity(Gravity.CENTER);
+ t.setPadding(px(12), px(4), px(12), px(4));
+ t.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
+ t.setTextColor(0xFFFFFFFF);
+ GradientDrawable d = new GradientDrawable();
+ d.setColor(ContextCompat.getColor(requireContext(), colorRes));
+ d.setCornerRadius(px(8));
+ d.setStroke(px(1), 0x4DFFFFFF);
+ t.setBackground(d);
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ lp.topMargin = px(8);
+ t.setLayoutParams(lp);
+ return t;
+ }
+
+ private void refreshFooter() {
+ if (isLocal()) {
+ addBtn.setVisibility(View.GONE);
+ } else {
+ addBtn.setVisibility(View.VISIBLE);
+ int n = selected.size();
+ addBtn.setEnabled(n > 0);
+ addBtn.setText(n > 0 ? getString(R.string.k2go_books_add_fmt, n) : getString(R.string.k2go_books_add_none));
+ }
+ boolean active = BooksDownloadService.hasSession();
+ downloadsLink.setVisibility(active ? View.VISIBLE : View.GONE);
+ if (active) downloadsLink.setText(getString(R.string.k2go_books_view_downloads));
+ }
+
+ private void startDownloads() {
+ if (selected.isEmpty()) return;
+ List ids = new ArrayList<>(), titles = new ArrayList<>(), urls = new ArrayList<>();
+ for (JSONObject b : selected.values()) {
+ ids.add(b.optString("gutenberg_id", ""));
+ titles.add(b.optString("title", ""));
+ urls.add(b.optString("download_url", ""));
+ }
+ BooksDownloadService.start(requireContext().getApplicationContext(),
+ ids.toArray(new String[0]), titles.toArray(new String[0]), urls.toArray(new String[0]));
+ selected.clear();
+ openDownloads();
+ }
+
+ private void openDownloads() {
+ if (getActivity() instanceof SetupLibraryActivity) ((SetupLibraryActivity) getActivity()).openBooksDownloads();
+ }
+
+ /** Open a local book's Calibre-Web page (details + Read / Download) in the in-app portal. */
+ private void openLocalBook(int id) {
+ if (id < 0) return;
+ Intent i = new Intent(requireContext(), PortalActivity.class);
+ i.putExtra("TARGET_URL", BoxEndpoints.BASE + "/books/book/" + id);
+ startActivity(i);
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java
index 2ea234fd..06d93f8e 100644
--- a/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java
+++ b/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java
@@ -9,11 +9,20 @@
* from the Library "Get more" entry (and, later, the wizard content step —
* the same screens, two doors). Cards are built into a 2-column grid,
* mirroring LibraryHomeFragment so translated labels never skew the layout.
+ *
+ * ADFA-4850: every card is gated on its backing module being present. You can
+ * only add a content type if its server exists to receive it, so a card shows
+ * only once its endpoint answers (Wikipedia→kiwix, Books→calibre-web at /books,
+ * Maps→maps, Courses→kolibri). The hub probes all endpoints on open and reveals
+ * the ones that respond; nothing installed → an empty-state message.
* ============================================================================
*/
package org.iiab.controller.redesign;
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -26,42 +35,100 @@
import androidx.fragment.app.Fragment;
import org.iiab.controller.R;
+import org.iiab.controller.config.BoxEndpoints;
+import org.iiab.controller.util.AppExecutors;
+
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
public class GetMoreHubFragment extends Fragment {
/** One content type on the hub. {@code key} is what SetupLibraryActivity routes on;
- * {@code note} is the second (bold) line, {@code amber} tints it (e.g. "In-app: TBD"). */
+ * {@code endpoint} is the server path probed to decide whether the module is installed
+ * (so the card shows); {@code note} is the second (bold) line, {@code amber} tints it. */
private static final class Item {
- final String key; final int icon; final int title; final int desc; final int note; final boolean amber;
- Item(String k, int i, int t, int d, int n, boolean a) { key = k; icon = i; title = t; desc = d; note = n; amber = a; }
+ final String key; final String endpoint; final int icon;
+ final int title; final int desc; final int note; final boolean amber;
+ Item(String k, String e, int i, int t, int d, int n, boolean a) {
+ key = k; endpoint = e; icon = i; title = t; desc = d; note = n; amber = a;
+ }
}
private static final Item[] ITEMS = {
- new Item("wikipedia", R.drawable.ic_card_wikipedia, R.string.k2go_gm_wikipedia_title, R.string.k2go_gm_wikipedia_desc, R.string.k2go_gm_wikipedia_note, false),
- new Item("books", R.drawable.ic_card_book, R.string.k2go_gm_books_title, R.string.k2go_gm_books_desc, R.string.k2go_gm_books_note, false),
- new Item("maps", R.drawable.ic_card_maps, R.string.k2go_gm_maps_title, R.string.k2go_gm_maps_desc, R.string.k2go_gm_maps_note, false),
- new Item("courses", R.drawable.ic_card_courses, R.string.k2go_gm_courses_title, R.string.k2go_gm_courses_desc, R.string.k2go_gm_courses_note, true),
+ new Item("wikipedia", "kiwix", R.drawable.ic_card_wikipedia, R.string.k2go_gm_wikipedia_title, R.string.k2go_gm_wikipedia_desc, R.string.k2go_gm_wikipedia_note, false),
+ new Item("books", "books", R.drawable.ic_card_book, R.string.k2go_gm_books_title, R.string.k2go_gm_books_desc, R.string.k2go_gm_books_note, false),
+ new Item("maps", "maps", R.drawable.ic_card_maps, R.string.k2go_gm_maps_title, R.string.k2go_gm_maps_desc, R.string.k2go_gm_maps_note, false),
+ new Item("courses", "kolibri", R.drawable.ic_card_courses, R.string.k2go_gm_courses_title, R.string.k2go_gm_courses_desc, R.string.k2go_gm_courses_note, true),
};
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final Set available = new HashSet<>();
+ private int probesPending = 0;
+ private LayoutInflater inflater;
+ private LinearLayout host;
+
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle s) {
+ this.inflater = inflater;
View root = inflater.inflate(R.layout.fragment_k2go_getmore_hub, container, false);
- LinearLayout host = root.findViewById(R.id.k2go_gm_cards);
- buildCards(inflater, host);
+ host = root.findViewById(R.id.k2go_gm_cards);
+ probeAll();
+ buildCards(); // shows the "checking…" state until probes resolve
return root;
}
- private void buildCards(LayoutInflater inflater, LinearLayout host) {
+ /** Probe every card's endpoint; reveal the ones that answer (module installed). */
+ private void probeAll() {
+ probesPending = ITEMS.length;
+ for (final Item it : ITEMS) {
+ AppExecutors.get().io().execute(() -> {
+ final boolean ok = reachable(it.endpoint);
+ main.post(() -> {
+ if (!isAdded()) return;
+ if (ok) available.add(it.key);
+ probesPending--;
+ buildCards();
+ });
+ });
+ }
+ }
+
+ /** The items to show: those whose module answered, in the declared order. */
+ private List- visibleItems() {
+ List
- out = new ArrayList<>();
+ for (Item it : ITEMS) if (available.contains(it.key)) out.add(it);
+ return out;
+ }
+
+ private void buildCards() {
+ if (host == null) return;
host.removeAllViews();
+ List
- items = visibleItems();
+
+ if (items.isEmpty()) { // still checking, or nothing installed
+ TextView msg = new TextView(requireContext());
+ msg.setGravity(Gravity.CENTER);
+ msg.setPadding(0, dp(24), 0, dp(24));
+ msg.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
+ msg.setTextColor(androidx.core.content.ContextCompat.getColor(requireContext(), R.color.k2go_muted));
+ msg.setText(probesPending > 0 ? R.string.k2go_gm_checking : R.string.k2go_gm_none);
+ host.addView(msg);
+ return;
+ }
+
final int cardH = getResources().getDimensionPixelSize(R.dimen.k2go_gm_card_height);
- for (int i = 0; i < ITEMS.length; i += 2) {
+ for (int i = 0; i < items.size(); i += 2) {
LinearLayout row = new LinearLayout(requireContext());
row.setOrientation(LinearLayout.HORIZONTAL);
host.addView(row, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
- for (int k = i; k < i + 2 && k < ITEMS.length; k++) {
- final Item it = ITEMS[k];
+ for (int k = i; k < i + 2 && k < items.size(); k++) {
+ final Item it = items.get(k);
View card = inflater.inflate(R.layout.view_k2go_getmore_card, row, false);
((ImageView) card.findViewById(R.id.k2go_gm_card_icon)).setImageResource(it.icon);
((TextView) card.findViewById(R.id.k2go_gm_card_title)).setText(it.title);
@@ -84,6 +151,31 @@ private void buildCards(LayoutInflater inflater, LinearLayout host) {
lp.weight = 1f;
row.addView(card, lp);
}
+ if (i + 1 >= items.size() && items.size() % 2 == 1) { // keep a lone last card half-width
+ View pad = new View(requireContext());
+ row.addView(pad, new LinearLayout.LayoutParams(0, cardH, 1f));
+ }
+ }
+ }
+
+ private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
+
+ /** A module is "installed" for Get More purposes when its server endpoint answers. */
+ private static boolean reachable(String endpoint) {
+ HttpURLConnection c = null;
+ try {
+ URL u = new URL(BoxEndpoints.BASE + "/" + endpoint + "/");
+ c = (HttpURLConnection) u.openConnection();
+ c.setUseCaches(false);
+ c.setConnectTimeout(1500);
+ c.setReadTimeout(1500);
+ c.setRequestMethod("GET");
+ int code = c.getResponseCode();
+ return code >= 200 && code < 400;
+ } catch (Exception e) {
+ return false;
+ } finally {
+ if (c != null) c.disconnect();
}
}
}
diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java
index 103c9ec7..0d977cff 100644
--- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java
+++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java
@@ -119,6 +119,7 @@ public void openContentType(String key, String title) {
androidx.fragment.app.Fragment f;
if ("maps".equals(key)) f = new MapsLandingFragment();
else if ("wikipedia".equals(key)) f = new ZimLandingFragment(); // Wikipedia & ZIM content
+ else if ("books".equals(key)) f = new BooksLandingFragment(); // ADFA-4850: Books / Gutenberg
else f = PlaceholderFragment.newInstance(title);
getSupportFragmentManager().beginTransaction()
.replace(R.id.k2go_setup_host, f)
@@ -156,6 +157,14 @@ public void backToGetMoreHubZim() {
androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE);
}
+ /** ADFA-4850: Books landing -> the download manager screen (per-book checklist + retry). */
+ public void openBooksDownloads() {
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.k2go_setup_host, new BooksDownloadsFragment())
+ .addToBackStack("books_downloads")
+ .commit();
+ }
+
/** ADFA-4848: Maps landing -> "Choose layers & quality" (Option B). */
public void openMapsChoose() {
getSupportFragmentManager().beginTransaction()
diff --git a/controller/app/src/main/res/layout/fragment_k2go_books_downloads.xml b/controller/app/src/main/res/layout/fragment_k2go_books_downloads.xml
new file mode 100644
index 00000000..779b7e0e
--- /dev/null
+++ b/controller/app/src/main/res/layout/fragment_k2go_books_downloads.xml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/controller/app/src/main/res/layout/fragment_k2go_books_landing.xml b/controller/app/src/main/res/layout/fragment_k2go_books_landing.xml
new file mode 100644
index 00000000..54341fcb
--- /dev/null
+++ b/controller/app/src/main/res/layout/fragment_k2go_books_landing.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/controller/app/src/main/res/values/colors_k2go.xml b/controller/app/src/main/res/values/colors_k2go.xml
index eb974c8c..00daa7f6 100644
--- a/controller/app/src/main/res/values/colors_k2go.xml
+++ b/controller/app/src/main/res/values/colors_k2go.xml
@@ -22,4 +22,18 @@
#5C6360
#D9D4C8
#FBF8F3
+
+
+ #0E5C63
+ #3A5A8C
+ #6E3B7A
+ #B5432E
+ #2E7D4F
+ #4A5568
+ #2E9E5B
+ #4A504E
diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml
index 6c29d5da..af5d30e5 100644
--- a/controller/app/src/main/res/values/strings_k2go.xml
+++ b/controller/app/src/main/res/values/strings_k2go.xml
@@ -447,4 +447,38 @@
· Failed
Finish
Retry
+
+
+ Checking installed modules…
+ No content modules are installed yet.
+
+
+ Books
+ Search books
+ Popular
+ Educational
+ My books
+ All languages
+ Language: %1$s
+ Choose a language
+ No books in your library yet.
+ Tap a book to open it in the library.
+ Loading…
+ Couldn\'t reach the content service.
+ No books found.
+ In your books
+ Selected
+ Add %1$d to library
+ Select books to add
+ View downloads
+ Downloads
+ Added one at a time — kind to Project Gutenberg.
+ %1$d of %2$d books
+ Queued
+ Downloading…
+ Adding to your library…
+ In your books
+ Couldn\'t add — tap Retry
+ Book downloads
+ Adding books to your library
diff --git a/static/dashboard/routes.ts b/static/dashboard/routes.ts
index b30e5988..c291ecf5 100644
--- a/static/dashboard/routes.ts
+++ b/static/dashboard/routes.ts
@@ -6,6 +6,7 @@
// none of these calls hold state — a client can drop and re-attach by polling the id.
import express, { Router, Request, Response } from 'express';
import { jobs, Job, JobType } from './sockets/jobs';
+import { searchCatalog, listLibrary, removeBook, listLanguages } from './sockets/books.query';
const VALID_TYPES: JobType[] = ['kiwix', 'maps', 'books'];
function isType(t: string): t is JobType {
@@ -31,6 +32,53 @@ function toApi(job: Job) {
export const apiRouter: Router = express.Router();
+// --- Books: direct (non-job) queries over the offline catalog + Calibre-Web library ---------
+// ADFA-4850. Ported from the socket.io handlers; the download itself stays a durable job
+// (POST /books/download). These paths don't collide with the generic /:type/* routes below.
+
+// Search the offline Gutenberg catalog. ?q= (FTS) | ?filter=educational | (default) top-by-downloads.
+apiRouter.get('/books/search', (req: Request, res: Response): void => {
+ try {
+ const q = String(req.query.q ?? '');
+ const filter = String(req.query.filter ?? '');
+ const lang = String(req.query.lang ?? '');
+ const limit = parseInt(String(req.query.limit ?? '40'), 10);
+ res.json(searchCatalog(q, filter, lang, limit));
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || 'search failed' });
+ }
+});
+
+// The distinct languages present in the catalog (for the language picker).
+apiRouter.get('/books/languages', (_req: Request, res: Response): void => {
+ try {
+ res.json(listLanguages());
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || 'languages read failed' });
+ }
+});
+
+// The local Calibre-Web library (EPUB books) — for "Your books" / Read a Book.
+apiRouter.get('/books/library', (_req: Request, res: Response): void => {
+ try {
+ res.json(listLibrary());
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || 'library read failed' });
+ }
+});
+
+// Remove a book from the Calibre-Web library.
+apiRouter.post('/books/library/:id/remove', async (req: Request, res: Response): Promise => {
+ const id = parseInt(String(req.params.id), 10);
+ if (!Number.isFinite(id)) { res.status(400).json({ error: 'bad id' }); return; }
+ try {
+ await removeBook(id);
+ res.json({ ok: true });
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || 'remove failed' });
+ }
+});
+
// Start a content job → 202 { ...job }
apiRouter.post('/:type/download', (req: Request, res: Response): void => {
const type = String(req.params.type);
diff --git a/static/dashboard/sockets/books.query.ts b/static/dashboard/sockets/books.query.ts
new file mode 100644
index 00000000..e1166c99
--- /dev/null
+++ b/static/dashboard/sockets/books.query.ts
@@ -0,0 +1,158 @@
+// sockets/books.query.ts — ADFA-4850
+//
+// Direct (non-job) REST helpers for Books, ported from the socket.io handlers in
+// books.socket.ts so the durable REST engine covers the whole flow:
+// - searchCatalog(): FTS over the synced OFFLINE Gutenberg catalog.db (no internet needed
+// to search; only covers + the actual EPUB download need internet).
+// - listLibrary(): the local Calibre-Web library (EPUB books) for "Your books" / Read a Book.
+// - removeBook(): delete from Calibre-Web.
+// The cover URL is DERIVED from the Gutenberg id (standard Gutenberg cover path) so we never
+// bloat the catalog with image blobs; the client loads it online with an offline fallback.
+import Database from 'better-sqlite3';
+import fs from 'fs';
+import path from 'path';
+
+const CALIBRE_LIB_PATH = '/library/calibre-web/';
+const CALIBRE_DB_PATH = path.join(CALIBRE_LIB_PATH, 'metadata.db');
+const BOOKS_DIR = '/library/dashboard/books/';
+const CATALOG_DB_PATH = path.join(BOOKS_DIR, 'catalog.db');
+
+const CALIBRE_WEB_LOCAL_URL = 'http://127.0.0.1:8083';
+const CALIBRE_WEB_USER = 'Admin';
+const CALIBRE_WEB_PASS = 'changeme';
+
+export interface CatalogBook {
+ gutenberg_id: number | string;
+ title: string;
+ author: string;
+ language: string;
+ download_url: string;
+ description: string;
+ cover_url: string;
+}
+
+function coverUrl(id: number | string): string {
+ return `https://www.gutenberg.org/cache/epub/${id}/pg${id}.cover.medium.jpg`;
+}
+
+/** FTS/browse the offline catalog. query MATCH (prefix) | 'educational' | top-by-downloads.
+ * An optional ISO language code (e.g. "en") narrows every branch to that language. */
+export function searchCatalog(q: string, filter: string, lang: string, limit: number): CatalogBook[] {
+ if (!fs.existsSync(CATALOG_DB_PATH)) throw new Error('catalog database not found (sync first)');
+ const lim = Math.max(1, Math.min(200, Number.isFinite(limit) ? limit : 40));
+ const cols = 'gutenberg_id, title, author, language, download_url, description';
+ const useLang = typeof lang === 'string' && lang.trim().length > 0;
+ const langArg: any[] = useLang ? [lang.trim()] : [];
+ const db = new Database(CATALOG_DB_PATH, { readonly: true });
+ try {
+ let rows: any[];
+ if (q && q.trim().length > 0) {
+ const langClause = useLang ? ' AND language = ?' : '';
+ rows = db.prepare(
+ `SELECT ${cols} FROM catalog WHERE catalog MATCH ?${langClause} ORDER BY rank LIMIT ?`
+ ).all(q.trim() + '*', ...langArg, lim);
+ } else if (filter === 'educational') {
+ const langClause = useLang ? ' AND language = ?' : '';
+ rows = db.prepare(
+ `SELECT ${cols} FROM catalog WHERE (bookshelves LIKE '%Children%' OR bookshelves LIKE '%Education%')${langClause} ORDER BY downloads DESC LIMIT ?`
+ ).all(...langArg, lim);
+ } else {
+ const whereLang = useLang ? ' WHERE language = ?' : '';
+ rows = db.prepare(
+ `SELECT ${cols} FROM catalog${whereLang} ORDER BY downloads DESC LIMIT ?`
+ ).all(...langArg, lim);
+ }
+ return rows.map((r) => ({ ...r, cover_url: coverUrl(r.gutenberg_id) })) as CatalogBook[];
+ } finally {
+ db.close();
+ }
+}
+
+/** The distinct languages present in the catalog, most-stocked first — so the picker only ever
+ * offers languages that actually have books (Gutenberg is mostly English). */
+export function listLanguages(): any[] {
+ if (!fs.existsSync(CATALOG_DB_PATH)) throw new Error('catalog database not found (sync first)');
+ const db = new Database(CATALOG_DB_PATH, { readonly: true });
+ try {
+ return db.prepare(
+ `SELECT language AS code, COUNT(*) AS count FROM catalog
+ WHERE language IS NOT NULL AND language != '' GROUP BY language ORDER BY count DESC`
+ ).all();
+ } finally {
+ db.close();
+ }
+}
+
+/** The local Calibre-Web library — books that have an EPUB, newest first. */
+export function listLibrary(): any[] {
+ if (!fs.existsSync(CALIBRE_DB_PATH)) return [];
+ const db = new Database(CALIBRE_DB_PATH, { readonly: true });
+ try {
+ return db.prepare(`
+ SELECT
+ books.id,
+ books.title,
+ strftime('%Y', books.pubdate) as year,
+ (SELECT name FROM authors
+ JOIN books_authors_link ON authors.id = books_authors_link.author
+ WHERE book = books.id LIMIT 1) as author
+ FROM books
+ WHERE EXISTS (
+ SELECT 1 FROM data WHERE data.book = books.id AND data.format = 'EPUB'
+ )
+ ORDER BY books.id DESC
+ `).all();
+ } finally {
+ db.close();
+ }
+}
+
+async function getCalibreSession(): Promise<{ cookie: string; csrfToken: string }> {
+ const loginPageRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/login`);
+ const initialCookies = loginPageRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
+ const loginHtml = await loginPageRes.text();
+ const csrfMatch = loginHtml.match(/name="csrf_token" value="(.*?)"/);
+ if (!csrfMatch) throw new Error('Could not find CSRF token on login page');
+ const csrfToken = csrfMatch[1];
+
+ const loginData = new URLSearchParams();
+ loginData.append('csrf_token', csrfToken);
+ loginData.append('username', CALIBRE_WEB_USER);
+ loginData.append('password', CALIBRE_WEB_PASS);
+
+ const authRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/login`, {
+ method: 'POST',
+ headers: {
+ Cookie: initialCookies,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Referer: `${CALIBRE_WEB_LOCAL_URL}/login`,
+ },
+ body: loginData,
+ redirect: 'manual',
+ });
+ if (authRes.status !== 302 && authRes.status !== 303) throw new Error('Invalid Calibre-Web credentials');
+
+ const authCookieString = authRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
+ const homeHtml = await (await fetch(`${CALIBRE_WEB_LOCAL_URL}/`, { headers: { Cookie: authCookieString } })).text();
+ const finalCsrfMatch =
+ homeHtml.match(/name="csrf_token"\s+value="([^"]+)"/i) ||
+ homeHtml.match(/value="([^"]+)"\s+name="csrf_token"/i);
+ return { cookie: authCookieString, csrfToken: finalCsrfMatch ? finalCsrfMatch[1] : csrfToken };
+}
+
+/** Remove a book from Calibre-Web by its library id. */
+export async function removeBook(id: number): Promise {
+ const s = await getCalibreSession();
+ const body = new URLSearchParams();
+ body.append('csrf_token', s.csrfToken);
+ const res = await fetch(`${CALIBRE_WEB_LOCAL_URL}/delete/${id}`, {
+ method: 'POST',
+ headers: {
+ Cookie: s.cookie,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Referer: `${CALIBRE_WEB_LOCAL_URL}/`,
+ },
+ body,
+ });
+ if (!res.ok) throw new Error(`Calibre-Web rejected deletion: ${res.status}`);
+}