From 65a580c8f2d7810b965c674f549634e790466ed8 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 07:52:57 -0600 Subject: [PATCH 1/5] =?UTF-8?q?ADFA-4850:=20dashboard=20REST=20=E2=80=94?= =?UTF-8?q?=20books=20search=20/=20library=20/=20remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the durable REST engine so the Books flow is fully REST (the dashboard is becoming the API). Ports the existing socket.io books handlers: - GET /api/books/search?q=&filter=&limit= → FTS over the offline catalog.db (query MATCH / educational / top-by-downloads); returns {gutenberg_id,title,author,language,download_url, description,cover_url}. cover_url is derived from the Gutenberg id (no image blobs in the DB). - GET /api/books/library → Calibre-Web metadata.db EPUB books {id,title,author,year}. - POST /api/books/library/:id/remove → Calibre-Web delete. Download stays the durable job (POST /api/books/download). Paths don't collide with the generic /:type/* engine routes. tsc --noEmit clean; dist/ is gitignored (built on rootfs push). --- static/dashboard/routes.ts | 38 +++++++ static/dashboard/sockets/books.query.ts | 137 ++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 static/dashboard/sockets/books.query.ts diff --git a/static/dashboard/routes.ts b/static/dashboard/routes.ts index b30e5988..48a355c5 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 } from './sockets/books.query'; const VALID_TYPES: JobType[] = ['kiwix', 'maps', 'books']; function isType(t: string): t is JobType { @@ -31,6 +32,43 @@ 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 limit = parseInt(String(req.query.limit ?? '40'), 10); + res.json(searchCatalog(q, filter, limit)); + } catch (e: any) { + res.status(500).json({ error: e?.message || 'search 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..49f7dfad --- /dev/null +++ b/static/dashboard/sockets/books.query.ts @@ -0,0 +1,137 @@ +// 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. */ +export function searchCatalog(q: string, filter: 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 db = new Database(CATALOG_DB_PATH, { readonly: true }); + try { + let rows: any[]; + if (q && q.trim().length > 0) { + rows = db.prepare( + `SELECT ${cols} FROM catalog WHERE catalog MATCH ? ORDER BY rank LIMIT ?` + ).all(q.trim() + '*', lim); + } else if (filter === 'educational') { + rows = db.prepare( + `SELECT ${cols} FROM catalog WHERE bookshelves LIKE '%Children%' OR bookshelves LIKE '%Education%' ORDER BY downloads DESC LIMIT ?` + ).all(lim); + } else { + rows = db.prepare( + `SELECT ${cols} FROM catalog ORDER BY downloads DESC LIMIT ?` + ).all(lim); + } + return rows.map((r) => ({ ...r, cover_url: coverUrl(r.gutenberg_id) })) as CatalogBook[]; + } 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}`); +} From 480abec8524078c716dbc4d84039475685cfa471 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 08:17:41 -0600 Subject: [PATCH 2/5] ADFA-4850: native Get More Books (search, select, add) + conditional hub cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Books catalog browse/search with a colored-cover grid and multi-select, feeding BooksDownloadService (foreground, one book at a time — kind to Project Gutenberg — continuing past failures, per-book retry, Finish clears the session). BooksClient talks to the dashboard REST endpoints; reading stays on the home Read a Book card. Get More hub cards are now conditional on the backing module: Books shows only when Calibre-Web answers; Kiwix and Maps always show; Courses stays a TBD placeholder. --- controller/app/src/main/AndroidManifest.xml | 5 + .../iiab/controller/redesign/BooksClient.java | 123 ++++++++ .../redesign/BooksDownloadService.java | 272 ++++++++++++++++++ .../redesign/BooksDownloadsFragment.java | 174 +++++++++++ .../redesign/BooksLandingFragment.java | 266 +++++++++++++++++ .../redesign/GetMoreHubFragment.java | 105 ++++++- .../redesign/SetupLibraryActivity.java | 9 + .../layout/fragment_k2go_books_downloads.xml | 101 +++++++ .../layout/fragment_k2go_books_landing.xml | 119 ++++++++ .../app/src/main/res/values/strings_k2go.xml | 24 ++ 10 files changed, 1184 insertions(+), 14 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/BooksClient.java create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadService.java create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/BooksDownloadsFragment.java create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java create mode 100644 controller/app/src/main/res/layout/fragment_k2go_books_downloads.xml create mode 100644 controller/app/src/main/res/layout/fragment_k2go_books_landing.xml 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"; q empty => top-by-downloads. */ + public static void search(String q, String filter, int limit, ArrayCb cb) { + AppExecutors.get().io().execute(() -> { + try { + String url = BASE + "/search?limit=" + limit + + "&filter=" + enc(filter == null ? "" : filter) + + "&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 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..d7a62d21 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java @@ -0,0 +1,266 @@ +/* + * ============================================================================ + * 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). Books already in the Calibre-Web library are marked and + * not selectable. "Add to library" hands the selection to BooksDownloadService + * (one at a time, retry, background). Never "Read" here — reading is the home + * "Read a Book" card. Covers are colored placeholders (title/author) for now. + * ============================================================================ + */ +package org.iiab.controller.redesign; + +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.R; +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; + private Button addBtn; + + private String filter = ""; // "" = Popular, "educational" + private String query = ""; + private final List books = new ArrayList<>(); + private final LinkedHashMap selected = new LinkedHashMap<>(); + private final Set libraryTitles = new HashSet<>(); + + private final int[] palette = {R.color.k2go_teal, R.color.k2go_clay, R.color.k2go_leaf, R.color.k2go_amber, R.color.k2go_ink}; + + 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); + + 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) { + query = s.toString().trim(); loadBooks(); + } + @Override public void afterTextChanged(android.text.Editable s) {} + }); + + buildChips(); + addBtn.setOnClickListener(v -> startDownloads()); + downloadsLink.setOnClickListener(v -> openDownloads()); + + loadLibrary(); + loadBooks(); + return root; + } + + @Override + public void onResume() { + super.onResume(); + loadLibrary(); // a book may have finished adding; refresh badges + refreshFooter(); + } + + private void buildChips() { + chips.removeAllViews(); + chips.addView(chip(getString(R.string.k2go_books_chip_popular), filter.isEmpty(), () -> { filter = ""; query = ""; loadBooks(); })); + View gap = new View(requireContext()); + chips.addView(gap, new LinearLayout.LayoutParams(px(8), 1)); + chips.addView(chip(getString(R.string.k2go_books_chip_edu), "educational".equals(filter), () -> { filter = "educational"; query = ""; 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()); + } + 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.search(query, filter, 40, 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)); + } + }); + } + + private boolean inLibrary(JSONObject b) { + return libraryTitles.contains(b.optString("title", "").toLowerCase(Locale.ROOT).trim()); + } + + private void render() { + grid.removeAllViews(); + status.setVisibility(books.isEmpty() ? View.VISIBLE : View.GONE); + if (books.isEmpty()) status.setText(getString(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) { + String title = b.optString("title", ""); + String author = b.optString("author", ""); + String gid = b.optString("gutenberg_id", ""); + boolean lib = inLibrary(b); + boolean sel = 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 with the title on it (placeholder; real covers load later from cover_url). + LinearLayout cover = new LinearLayout(requireContext()); + cover.setOrientation(LinearLayout.VERTICAL); + cover.setGravity(Gravity.CENTER); + cover.setPadding(px(10), px(12), px(10), px(12)); + 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.setTextColor(0xFFFFFFFF); + tt.setTypeface(tt.getTypeface(), Typeface.BOLD); + tt.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleSmall); + tt.setTextColor(0xFFFFFFFF); + cover.addView(tt); + + TextView badge = new TextView(requireContext()); + badge.setGravity(Gravity.CENTER); + badge.setTextColor(0xCCFFFFFF); + badge.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall); + badge.setTextColor(0xCCFFFFFF); + badge.setText(lib ? getString(R.string.k2go_books_in_library) + : sel ? getString(R.string.k2go_books_selected) : author); + LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + blp.topMargin = px(8); + cover.addView(badge, blp); + + box.addView(cover); + + if (!lib) { + box.setOnClickListener(v -> { + if (selected.containsKey(gid)) selected.remove(gid); else selected.put(gid, b); + render(); + }); + } + box.setAlpha(lib ? 0.7f : 1f); + return box; + } + + private void refreshFooter() { + 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(); + } +} 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..e135ad7c 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,19 @@ * 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: cards are conditional on the backing module being present. You + * can only add a content type if its server exists to receive it, so a + * conditional card (Books → Calibre-Web) is shown only once its endpoint + * answers. Kiwix and Maps ship by default, so their cards are always shown; + * Courses (Kolibri) is still a TBD placeholder. * ============================================================================ */ package org.iiab.controller.redesign; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -26,42 +34,88 @@ 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"). */ + /** One content type on the hub. {@code key} is what SetupLibraryActivity routes on (and, + * for conditional cards, the server endpoint to probe); {@code note} is the second (bold) + * line, {@code amber} tints it (e.g. "In-app: TBD"); {@code conditional} cards appear only + * once their endpoint answers (the module is installed). */ 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 int icon; final int title; final int desc; final int note; + final boolean amber; final boolean conditional; + Item(String k, int i, int t, int d, int n, boolean a, boolean c) { + key = k; icon = i; title = t; desc = d; note = n; amber = a; conditional = c; + } } 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", R.drawable.ic_card_wikipedia, R.string.k2go_gm_wikipedia_title, R.string.k2go_gm_wikipedia_desc, R.string.k2go_gm_wikipedia_note, false, 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, true), + 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, 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, false), }; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Set availableConditional = new HashSet<>(); + 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); + buildCards(); + probeConditional(); return root; } - private void buildCards(LayoutInflater inflater, LinearLayout host) { + /** Probe each conditional card's endpoint; reveal it (and rebuild the grid) once it answers. */ + private void probeConditional() { + for (final Item it : ITEMS) { + if (!it.conditional) continue; + AppExecutors.get().io().execute(() -> { + final boolean ok = reachable(it.key); + main.post(() -> { + if (!isAdded() || !ok) return; + if (availableConditional.add(it.key)) buildCards(); + }); + }); + } + } + + /** The items currently shown: all non-conditional, plus conditional ones confirmed available. */ + private List visibleItems() { + List out = new ArrayList<>(); + for (Item it : ITEMS) { + if (!it.conditional || availableConditional.contains(it.key)) out.add(it); + } + return out; + } + + private void buildCards() { + if (host == null) return; host.removeAllViews(); + List items = visibleItems(); 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 +138,29 @@ 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)); + } + } + } + + /** 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +