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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +