Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2163aa3
ADFA-4853: offline Books catalog asset + wishlist (foundation)
luisguzman-adfa Jul 24, 2026
2b110b7
ADFA-4850: let book covers breathe + avoid adjacent same-color
luisguzman-adfa Jul 24, 2026
8ee5c7f
ADFA-4850: apply the book-cover gutter on the effective LayoutParams
luisguzman-adfa Jul 24, 2026
424aa7c
ADFA-4853: Books screen in wizard mode (offline catalog -> wishlist)
luisguzman-adfa Jul 24, 2026
e3e9bf8
ADFA-4853: wire Get More hub into the wizard as the content step
luisguzman-adfa Jul 24, 2026
7703e61
ADFA-4853: ZIM in wizard mode -> wishlist; Maps reuses the existing flow
luisguzman-adfa Jul 24, 2026
a4b383f
ADFA-4853: wizard hub retitled 'Set up your library' + styled Continue
luisguzman-adfa Jul 24, 2026
a42b924
ADFA-4853: wizard hub shows the step spine + storage bar (wizard only)
luisguzman-adfa Jul 24, 2026
dcb6888
ADFA-4853: diagnostics for the offline Books catalog
luisguzman-adfa Jul 24, 2026
21e4ce4
ADFA-4853: ship Books catalog as plain .jsonl (fixes FileNotFound in …
luisguzman-adfa Jul 24, 2026
21aef81
ADFA-4853: post-install Books provisioning drain (live)
luisguzman-adfa Jul 24, 2026
fa8b19e
ADFA-4853: post-install ZIM provisioning drain (live)
luisguzman-adfa Jul 24, 2026
b44d0bb
ADFA-4853: wizard Continue installs directly, replacing the old Step 2
luisguzman-adfa Jul 24, 2026
9fcb03e
ADFA-4853: log the post-install provisioning drain (diagnostics)
luisguzman-adfa Jul 24, 2026
78f3f87
ADFA-4853: log each ZIM job's filename + outcome (diagnostics)
luisguzman-adfa Jul 24, 2026
7462d8b
ADFA-4853: log each Books job's id/url + outcome (diagnostics)
luisguzman-adfa Jul 24, 2026
29386e1
ADFA-4853: gate post-install drain on the REST API being ready (fix 502)
luisguzman-adfa Jul 24, 2026
b7bfacc
ADFA-4853: Finishing setup becomes a light index that links to detail
luisguzman-adfa Jul 25, 2026
a5dc5c4
ADFA-4853: delete the orphaned old wizard content flows (Step 2 A/B)
luisguzman-adfa Jul 25, 2026
d1bdfed
ADFA-4853: auto-retry transient content failures (max 3 per item)
luisguzman-adfa Jul 25, 2026
c3467f6
ADFA-4853: index opens the REAL detail cards + Back/Finish semantics
luisguzman-adfa Jul 25, 2026
e10fa16
ADFA-4853: record follow-ups (background jobs monitor; Maps not backg…
luisguzman-adfa Jul 25, 2026
a9bba46
ADFA-4853: drop follow-up notes from the ADR (tracked locally instead)
luisguzman-adfa Jul 25, 2026
86cc552
ADFA-4853: rebuild Finishing setup (status dot, spinners, auto-redire…
luisguzman-adfa Jul 25, 2026
e3124f2
ADFA-4853: go straight from install to Finishing setup (skip the home…
luisguzman-adfa Jul 25, 2026
a52dab5
ADFA-4853: split the status line from the fixed context text
luisguzman-adfa Jul 25, 2026
fd4a8ba
ADFA-4853: fix index stalling after returning from a detail card
luisguzman-adfa Jul 25, 2026
275d3b3
ADFA-4853: 'Finishing setup' screen — visible per-stream provisioning…
luisguzman-adfa Jul 25, 2026
a645a08
ADFA-4853: extract one shared provisioning checklist (single source o…
luisguzman-adfa Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions controller/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
android:exported="false"
android:foregroundServiceType="specialUse" />

<activity
android:name=".redesign.SetupProgressActivity"
android:exported="false"
android:theme="@style/Theme.K2Go" />

<service
android:name=".TerminalSessionService"
android:exported="false"
Expand Down
1,100 changes: 1,100 additions & 0 deletions controller/app/src/main/assets/books_catalog.jsonl

Large diffs are not rendered by default.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* ============================================================================
* Name : BooksCatalogAsset.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : ADFA-4853. OFFLINE Books catalog, read from the bundled asset
* assets/books_catalog.jsonl (plain JSONL — a ".gz" double-extension asset got
* EOL-normalized and dropped from the APK; single-extension plain text is safe),
* (generated from the dashboard catalog.db by
* tools/gen-books-catalog-asset.py). This is the wizard/pre-install data source:
* the user can search + pick books before the system (and Calibre-Web) exist. It
* emits the SAME row shape as the live REST search (gutenberg_id, title, author,
* language, download_url), so BooksLandingFragment renders it identically. Parsed
* once and cached in memory. "Educational" isn't available offline (no bookshelves
* in the trimmed asset) — offline browse is Popular + text search + language filter.
* ============================================================================
*/
package org.iiab.controller.redesign;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import org.iiab.controller.util.AppExecutors;
import org.json.JSONArray;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public final class BooksCatalogAsset {
private BooksCatalogAsset() {}

private static final String TAG = "K2Go-BooksAsset";
private static final String ASSET = "books_catalog.jsonl";
private static final Handler MAIN = new Handler(Looper.getMainLooper());

// Parsed once, kept in memory (already popularity-ordered by the generator).
private static volatile List<JSONObject> CACHE;

/** Search the offline catalog. lang: ""=all or an ISO code; q empty => popularity order. */
public static void search(Context ctx, String q, String lang, int limit, BooksClient.ArrayCb cb) {
final Context app = ctx.getApplicationContext();
AppExecutors.get().io().execute(() -> {
try {
List<JSONObject> all = ensureLoaded(app);
String term = q == null ? "" : q.trim().toLowerCase(Locale.ROOT);
String lc = lang == null ? "" : lang.trim();
JSONArray out = new JSONArray();
int n = 0;
for (JSONObject b : all) {
if (!lc.isEmpty() && !lc.equalsIgnoreCase(b.optString("language"))) continue;
if (!term.isEmpty()) {
String hay = (b.optString("title") + " " + b.optString("author")).toLowerCase(Locale.ROOT);
if (!hay.contains(term)) continue;
}
out.put(b);
if (++n >= Math.max(1, limit)) break;
}
MAIN.post(() -> cb.onOk(out));
} catch (Exception e) {
Log.w(TAG, "offline catalog read failed for asset '" + ASSET + "'", e);
MAIN.post(() -> cb.onErr("couldn't read the offline catalog"));
}
});
}

/** Distinct languages present in the asset (rows {code, count}), most-stocked first. */
public static void languages(Context ctx, BooksClient.ArrayCb cb) {
final Context app = ctx.getApplicationContext();
AppExecutors.get().io().execute(() -> {
try {
List<JSONObject> all = ensureLoaded(app);
Map<String, Integer> counts = new LinkedHashMap<>();
for (JSONObject b : all) {
String c = b.optString("language", "").trim();
if (c.isEmpty()) continue;
counts.merge(c, 1, Integer::sum);
}
List<Map.Entry<String, Integer>> entries = new ArrayList<>(counts.entrySet());
entries.sort((a, b) -> b.getValue() - a.getValue());
JSONArray out = new JSONArray();
for (Map.Entry<String, Integer> e : entries) {
out.put(new JSONObject().put("code", e.getKey()).put("count", e.getValue()));
}
MAIN.post(() -> cb.onOk(out));
} catch (Exception e) {
Log.w(TAG, "offline catalog read failed for asset '" + ASSET + "'", e);
MAIN.post(() -> cb.onErr("couldn't read the offline catalog"));
}
});
}

private static synchronized List<JSONObject> ensureLoaded(Context app) throws Exception {
if (CACHE != null) return CACHE;
List<JSONObject> list = new ArrayList<>();
try (InputStream raw = app.getAssets().open(ASSET);
BufferedReader r = new BufferedReader(new InputStreamReader(raw, StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
JSONObject j = new JSONObject(line);
// Map the compact asset keys to the live-REST row shape so the UI is source-agnostic.
JSONObject row = new JSONObject();
row.put("gutenberg_id", j.opt("id"));
row.put("title", j.optString("title", ""));
row.put("author", j.optString("author", ""));
row.put("language", j.optString("lang", ""));
row.put("download_url", j.optString("url", ""));
list.add(row);
}
}
Log.i(TAG, "loaded " + list.size() + " books from asset '" + ASSET + "'");
CACHE = list;
return CACHE;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,13 @@ public static void finishSession() {
sIndex = 0; sRunning = false;
}

private static final int MAX_ATTEMPTS = 3; // total tries per book before marking it failed
private static final long RETRY_DELAY_MS = 4000L;

private final Handler main = new Handler(Looper.getMainLooper());
private volatile String currentJobId;
private volatile boolean canceled = false;
private int attempts = 0; // attempts for the item currently in flight

@Override public void onCreate() { super.onCreate(); createNotificationChannel(); }
@Nullable @Override public IBinder onBind(Intent intent) { return null; }
Expand Down Expand Up @@ -148,22 +152,38 @@ private void processNext() {
int i = firstPending();
if (i < 0) { sessionComplete(); return; }
sIndex = i; sStatus[i] = ACTIVE;
attempts = 0;
publish();
updateNotification(sTitles[i]);
AppExecutors.get().io().execute(() -> startJob(i));
}

/** Transient failure (nginx 502 while the engine warms up, a flaky Gutenberg fetch, etc.):
* retry the same book a couple times before giving up, so the wizard doesn't stall on a blip. */
private void retryOrFail(int i) {
attempts++;
if (attempts < MAX_ATTEMPTS) {
android.util.Log.w("K2Go-Provision", "books job [" + i + "] transient failure, retry " + attempts + "/" + (MAX_ATTEMPTS - 1));
main.postDelayed(() -> AppExecutors.get().io().execute(() -> startJob(i)), RETRY_DELAY_MS);
} else {
android.util.Log.w("K2Go-Provision", "books job [" + i + "] failed after " + attempts + " attempts");
fail(i);
}
}

private void startJob(int i) {
android.util.Log.i("K2Go-Provision", "books job start [" + i + "] id=" + sIds[i] + " title='" + sTitles[i] + "' url=" + sUrls[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; }
if (id.isEmpty()) { android.util.Log.w("K2Go-Provision", "books job [" + i + "] no job id in response: " + resp); retryOrFail(i); return; }
currentJobId = id;
main.postDelayed(() -> poll(i), POLL_MS);
} catch (Exception e) {
fail(i);
android.util.Log.w("K2Go-Provision", "books job [" + i + "] POST failed: " + e.getMessage());
retryOrFail(i);
}
}

Expand All @@ -175,10 +195,12 @@ private void poll(int i) {
String phase = j.optString("phase", "");
switch (phase) {
case "done":
android.util.Log.i("K2Go-Provision", "books job done [" + i + "]");
sStatus[i] = DONE; publish(); main.post(BooksDownloadService.this::processNext); return;
case "error":
case "canceled":
fail(i); return;
android.util.Log.w("K2Go-Provision", "books job [" + i + "] phase=" + phase + " err=" + j.optString("error"));
retryOrFail(i); return;
case "processing":
if (sStatus[i] != ADDING) { sStatus[i] = ADDING; publish(); }
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,24 @@ public class BooksDownloadsFragment extends Fragment {
private TextView detail;
private LinearLayout listv;
private Button finishBtn, runBgBtn;
private boolean fromIndex; // hosted by the Finishing-setup index: hide own buttons, only observe

/** Open as a detail card inside the Finishing-setup index (host owns Back/Finish; observe only). */
public static BooksDownloadsFragment newInstance(boolean fromIndex) {
BooksDownloadsFragment f = new BooksDownloadsFragment();
Bundle b = new Bundle();
b.putBoolean("fromIndex", fromIndex);
f.setArguments(b);
return f;
}

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);
fromIndex = getArguments() != null && getArguments().getBoolean("fromIndex", false);

detail = root.findViewById(R.id.k2go_bdl_detail);
listv = root.findViewById(R.id.k2go_bdl_list);
Expand All @@ -61,6 +72,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
requireActivity().getSupportFragmentManager().popBackStack();
});

if (fromIndex) { // the index host provides Back/Finish; this card only observes
finishBtn.setVisibility(View.GONE);
runBgBtn.setVisibility(View.GONE);
}

BooksDownloadService.setListener(this::render);
render();
return root;
Expand All @@ -79,81 +95,21 @@ private void render() {

drawChecklist(titles, status);

boolean complete = BooksDownloadService.isComplete();
finishBtn.setEnabled(complete);
runBgBtn.setVisibility(complete ? View.GONE : View.VISIBLE);
if (!fromIndex) {
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);
}
ProvisioningChecklist.render(requireContext(), listv, titles.length, status,
BooksDownloadService.DONE, BooksDownloadService.FAILED,
new ProvisioningChecklist.RowText() {
@Override public String main(int i) { return titles[i]; }
@Override public String sub(int i) { return stateLabel(status[i]); }
},
i -> BooksDownloadService.retry(requireContext().getApplicationContext(), i));
}

private String stateLabel(int st) {
Expand Down
Loading