Skip to content

Commit c578a04

Browse files
Merge pull request #554 from appdevforall/feat/K2GO-390-kiwix-catalog-freshness
K2GO-390 fix(kiwix): self-healing catalog to end the stale-download loop
2 parents 7fb3219 + b730d06 commit c578a04

8 files changed

Lines changed: 348 additions & 19 deletions

File tree

‎controller/app/src/main/java/org/appdevforall/k2go/catalog/data/CatalogRefreshScheduler.java‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,15 @@ private CatalogRefreshScheduler() {
3131
}
3232

3333
private static Data input(String name, String manifestUrl, String basename) {
34+
return input(name, manifestUrl, basename, false);
35+
}
36+
37+
private static Data input(String name, String manifestUrl, String basename, boolean force) {
3438
return new Data.Builder()
3539
.putString(CatalogRefreshWorker.KEY_NAME, name)
3640
.putString(CatalogRefreshWorker.KEY_MANIFEST_URL, manifestUrl)
3741
.putString(CatalogRefreshWorker.KEY_BASENAME, basename)
42+
.putBoolean(CatalogRefreshWorker.KEY_FORCE, force)
3843
.build();
3944
}
4045

@@ -86,4 +91,22 @@ public static void refreshNow(Context ctx, String name, String manifestUrl, Stri
8691
.enqueueUniqueWork("catalog-refresh-now-" + name,
8792
ExistingWorkPolicy.KEEP, req);
8893
}
94+
95+
/**
96+
* K2GO-390: force an on-demand check that bypasses the worker's TTL gate (for a 404 self-heal --
97+
* the catalog may have rolled to a newer dated file within the TTL window). The ETag conditional
98+
* GET still makes it cheap. Its own unique name (KEEP) coalesces a burst of failures into one run.
99+
*/
100+
public static void forceRefresh(Context ctx, String name, String manifestUrl, String basename) {
101+
// K2GO-390: EXPEDITED so it dispatches promptly -- a 404 self-heal must land before the caller's
102+
// bounded retries give up (falls back to a normal request if the expedited quota is spent).
103+
OneTimeWorkRequest req = new OneTimeWorkRequest.Builder(CatalogRefreshWorker.class)
104+
.setConstraints(constraints(NetworkType.CONNECTED))
105+
.setExpedited(androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
106+
.setInputData(input(name, manifestUrl, basename, true))
107+
.build();
108+
WorkManager.getInstance(ctx.getApplicationContext())
109+
.enqueueUniqueWork("catalog-refresh-force-" + name,
110+
ExistingWorkPolicy.KEEP, req);
111+
}
89112
}

‎controller/app/src/main/java/org/appdevforall/k2go/catalog/data/CatalogRefreshWorker.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public final class CatalogRefreshWorker extends Worker {
3636
public static final String KEY_NAME = "name";
3737
public static final String KEY_MANIFEST_URL = "manifest_url";
3838
public static final String KEY_BASENAME = "basename";
39+
// K2GO-390: bypass the TTL gate for an on-demand check (e.g. a 404 self-heal needs to look now,
40+
// even if the weekly check ran recently). The ETag conditional GET still keeps it cheap.
41+
public static final String KEY_FORCE = "force";
3942

4043
public CatalogRefreshWorker(@NonNull Context context, @NonNull WorkerParameters params) {
4144
super(context, params);
@@ -54,7 +57,8 @@ public Result doWork() {
5457

5558
CatalogRefreshStore store = new CatalogRefreshStore(ctx);
5659
long now = System.currentTimeMillis();
57-
if (!CatalogFreshness.dueForCheck(store.lastCheckMs(name), now, CatalogFreshness.DEFAULT_TTL_MS)) {
60+
boolean force = getInputData().getBoolean(KEY_FORCE, false);
61+
if (!force && !CatalogFreshness.dueForCheck(store.lastCheckMs(name), now, CatalogFreshness.DEFAULT_TTL_MS)) {
5862
return Result.success(); // still fresh; do not hit the network
5963
}
6064

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/ContentDownloadSession.java‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public interface Host {
3939
void notify(String label); // update the foreground notification for the current item
4040
void stop(); // stopForeground(true) + stopSelf()
4141
void onItemDone(String key); // item confirmed DONE -> drop its wishlist entry (ADFA-4897)
42+
// K2GO-390: item gave up (FAILED). The host may self-heal (refresh the catalog and re-resolve)
43+
// and must bound retries so a stale/gone entry is not re-drained forever. Default no-op.
44+
default void onItemError(String key) {}
4245
}
4346

4447
private final String type; // "kiwix" / "books" -> /api/<type>
@@ -235,7 +238,12 @@ private void startItem(final int i) {
235238
@Override public void onError(String message) {
236239
// ADFA-4893: server owns reconnection (visible); on give-up, FAILED for a manual Retry.
237240
android.util.Log.w("K2Go-Provision", "[" + type + "] job [" + i + "] error: " + message);
238-
status[i] = FAILED; reconnectAttempt = 0; publish(); pump();
241+
status[i] = FAILED; reconnectAttempt = 0; publish();
242+
// K2GO-390: let the host self-heal (refresh the catalog, re-resolve) and bound retries,
243+
// so a stale/gone item does not re-drain forever.
244+
String k = key(i);
245+
if (host != null && !k.isEmpty()) host.onItemError(k);
246+
pump();
239247
}
240248
});
241249
}

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/KiwixCatalog.java‎

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
* background refresh (re-downloading an updated CSV) can be layered on later.
1414
*
1515
* Shape built in memory:
16-
* { project: { lang: { "<creator><flavour>": {creator,flavour,size,date,file} } } }
17-
* Files with no language token are bucketed under "mul" (language-agnostic).
16+
* { project: { lang: { "<creator><KEY_SEP><flavour>": {creator,flavour,size,date,file} } } }
17+
* The entry key joins creator and flavour with KEY_SEP (see below). Files with no language
18+
* token are bucketed under "mul" (language-agnostic).
1819
* ============================================================================
1920
*/
2021
package org.appdevforall.k2go.redesign;
@@ -26,7 +27,14 @@
2627

2728
import org.json.JSONObject;
2829

30+
import org.appdevforall.k2go.catalog.data.CatalogOverlay;
31+
import org.appdevforall.k2go.catalog.data.CatalogRefreshScheduler;
32+
import org.appdevforall.k2go.config.DownloadEndpoints;
33+
2934
import java.io.BufferedReader;
35+
import java.io.File;
36+
import java.io.FileInputStream;
37+
import java.io.InputStream;
3038
import java.io.InputStreamReader;
3139
import java.util.Iterator;
3240
import java.util.LinkedHashSet;
@@ -38,6 +46,22 @@ private KiwixCatalog() {}
3846
private static final String TAG = "KiwixCatalog";
3947
private static final String CSV_ASSET = "kiwix_catalog.csv";
4048

49+
// ADFA-4849/K2GO-390: entry-key delimiter joining creator and flavour into the map key. A
50+
// U+0001 control char is used because it can never appear in a creator or flavour token, so
51+
// "<creator><flavour>" keys cannot collide across rows. The cart, wishlist and resolver all copy
52+
// this key verbatim, so the delimiter stays internal. It is spelled out here (it used to be an
53+
// invisible char inside "") so it is visible and greppable -- do not change it without migrating
54+
// any persisted wishlist keys, which embed it.
55+
private static final String KEY_SEP = "\u0001";
56+
57+
// K2GO-390 (ADR-390): the catalog is refreshed like Kolibri's -- a hosted manifest + overlay,
58+
// ETag/hash-gated -- reusing the catalog-agnostic core. Flat, so no tree machinery. The overlay
59+
// (when a newer CSV has been pulled) is preferred over the APK asset; the asset is the offline
60+
// baseline. CATALOG name namespaces its refresh state; BASENAME is shared with the overlay so the
61+
// worker writes exactly where loadCsv reads.
62+
private static final String CATALOG_NAME = "kiwix";
63+
private static final String MANIFEST_URL = DownloadEndpoints.APK_REPO + "/catalogs/kiwix.manifest.json";
64+
4165
/** Language-agnostic bucket (files whose name carries no language token, e.g. many videos). */
4266
public static final String MUL = "mul";
4367

@@ -47,27 +71,93 @@ public interface Listener {
4771
}
4872

4973
private static volatile JSONObject inMemory;
50-
51-
/** Loads the baked CSV (once per process) off the main thread; posts back on the main thread. */
74+
// Which source the cache came from: -1 = not loaded, 0 = APK asset, >0 = the overlay's lastModified.
75+
private static volatile long cachedOverlayMtime = -1L;
76+
77+
/**
78+
* Loads the catalog (overlay if pulled, else the baked asset) off the main thread; posts back on the
79+
* main thread. K2GO-390: also nudges the freshness refresh (weekly + an opportunistic TTL-gated
80+
* check now, since the picker is opening). Both refreshes are network-constrained WorkManager jobs,
81+
* so offline is a silent no-op -- the asset/overlay stays the offline baseline. See ADR-390.
82+
*/
5283
public static void getOrFetch(Context context, Listener listener) {
53-
JSONObject mem = inMemory;
84+
final Context app = context.getApplicationContext();
85+
nudgeRefresh(app);
86+
87+
JSONObject mem;
88+
synchronized (KiwixCatalog.class) {
89+
reloadIfOverlayChanged(app); // drop the cache if a newer overlay landed
90+
mem = inMemory;
91+
}
5492
if (mem != null) { post(() -> listener.onReady(mem)); return; }
5593

5694
new Thread(() -> {
57-
JSONObject db = loadCsv(context);
58-
if (db != null && db.length() > 0) {
59-
inMemory = db;
60-
post(() -> listener.onReady(db));
61-
} else {
62-
post(() -> listener.onError("Catalog unavailable"));
95+
JSONObject db;
96+
synchronized (KiwixCatalog.class) { // one loader wins; the rest reuse the cache
97+
if (inMemory == null) inMemory = loadCsv(app);
98+
db = inMemory;
6399
}
100+
if (db != null && db.length() > 0) post(() -> listener.onReady(db));
101+
else post(() -> listener.onError("Catalog unavailable"));
64102
}).start();
65103
}
66104

105+
// Nudge the freshness refresh once per process (K2GO-390): weekly (KEEP) + an opportunistic,
106+
// TTL-gated check. Network-constrained, so offline is a no-op. A 404 forces its own check
107+
// (forceRefresh), so this need not run on every catalog open (the drain opens it every ~2 s).
108+
private static volatile boolean refreshNudged = false;
109+
110+
private static void nudgeRefresh(Context app) {
111+
if (refreshNudged) return;
112+
refreshNudged = true;
113+
CatalogRefreshScheduler.scheduleWeekly(app, CATALOG_NAME, MANIFEST_URL, CSV_ASSET);
114+
CatalogRefreshScheduler.refreshNow(app, CATALOG_NAME, MANIFEST_URL, CSV_ASSET);
115+
}
116+
117+
/**
118+
* K2GO-390: force a freshness check that bypasses the TTL gate. Called when a download 404s -- the
119+
* catalog may have rolled to a newer dated file within the TTL window. Network-constrained, so
120+
* offline is a silent no-op. Once the overlay lands, the next {@link #getOrFetch} adopts it and the
121+
* drain re-resolves the (date-free) key to the current file. See ADR-390.
122+
*/
123+
public static void forceRefresh(Context context) {
124+
CatalogRefreshScheduler.forceRefresh(context.getApplicationContext(), CATALOG_NAME, MANIFEST_URL, CSV_ASSET);
125+
}
126+
127+
/**
128+
* K2GO-390: the current catalog version tag -- the overlay's mtime, or 0 for the baked asset. The
129+
* self-heal counts failures against this ({@link ZimWishlist#bumpAttempts}): a refresh that replaces
130+
* the overlay moves the tag and renews the retry budget; an unchanging catalog keeps it stable so the
131+
* budget can reach its cap and drop a genuinely-gone item. Kept here so "which catalog version" has a
132+
* single owner (the overlay basename lives only in this class). See ADR-390.
133+
*/
134+
public static long catalogVersionTag(Context context) {
135+
File overlay = CatalogOverlay.file(context.getApplicationContext(), CSV_ASSET);
136+
return overlay.exists() ? overlay.lastModified() : 0L;
137+
}
138+
139+
/** Drop the cache so the next load re-reads. K2GO-390: called after a refresh pulls a new overlay. */
140+
public static void invalidate() {
141+
inMemory = null;
142+
cachedOverlayMtime = -1L;
143+
}
144+
145+
/** If the overlay's mtime differs from what the cache was loaded from, drop the cache (ADR-390). */
146+
private static void reloadIfOverlayChanged(Context ctx) {
147+
if (inMemory == null) return;
148+
File overlay = CatalogOverlay.file(ctx, CSV_ASSET);
149+
long mtime = overlay.exists() ? overlay.lastModified() : 0L;
150+
if (mtime != cachedOverlayMtime) invalidate();
151+
}
152+
67153
private static JSONObject loadCsv(Context context) {
68154
JSONObject db = new JSONObject();
69-
try (BufferedReader r = new BufferedReader(
70-
new InputStreamReader(context.getAssets().open(CSV_ASSET)))) {
155+
// K2GO-390: prefer the pulled overlay over the APK asset; the asset is the offline baseline.
156+
File overlay = CatalogOverlay.file(context, CSV_ASSET);
157+
boolean useOverlay = overlay.exists();
158+
long mtime = useOverlay ? overlay.lastModified() : 0L;
159+
try (InputStream in = useOverlay ? new FileInputStream(overlay) : context.getAssets().open(CSV_ASSET);
160+
BufferedReader r = new BufferedReader(new InputStreamReader(in))) {
71161
String line;
72162
boolean header = true;
73163
while ((line = r.readLine()) != null) {
@@ -95,12 +185,13 @@ private static JSONObject loadCsv(Context context) {
95185
v.put("size", bytes);
96186
v.put("date", date);
97187
v.put("file", file);
98-
langObj.put(creator + "" + flavour, v);
188+
langObj.put(creator + KEY_SEP + flavour, v);
99189
}
100190
} catch (Exception e) {
101191
Log.w(TAG, "kiwix_catalog.csv not read: " + e.getMessage());
102192
return null;
103193
}
194+
cachedOverlayMtime = mtime; // remember which source (asset=0 / overlay mtime) fed the cache
104195
return db;
105196
}
106197

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimDownloadService.java‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,29 @@ public int onStartCommand(Intent intent, int flags, int startId) {
163163
if (key != null && !key.isEmpty()) ZimWishlist.remove(getApplicationContext(), key);
164164
}
165165

166+
// K2GO-390: bound the self-heal. On a failure we force a catalog refresh; the drain then re-resolves
167+
// the date-free key ("project|lang|flavour") to the current dated file and retries. After this many
168+
// failed drains for one key, it is genuinely gone (or there is no fresh source) -- drop it so it
169+
// stops re-draining. ~5 * the ~2s drain cadence gives the refresh time to land. See ADR-390.
170+
private static final int MAX_HEAL_ATTEMPTS = 5;
171+
172+
/** ADR-390: item gave up (likely a stale catalog -> 404). Force a freshness check and bound retries,
173+
* so a rolled-over ZIM heals to its current file and a genuinely-gone one stops looping. */
174+
@Override public void onItemError(String key) {
175+
if (key == null || key.isEmpty()) return;
176+
Context app = getApplicationContext();
177+
KiwixCatalog.forceRefresh(app); // network-constrained; offline is a silent no-op
178+
// Count failures against the current catalog version (KiwixCatalog owns what that is). A refresh
179+
// that changes the catalog resets the budget (see ZimWishlist.bumpAttempts); only an unchanging
180+
// catalog climbs to the cap = genuinely gone / no fresh source.
181+
int attempts = ZimWishlist.bumpAttempts(app, key, KiwixCatalog.catalogVersionTag(app));
182+
if (attempts >= MAX_HEAL_ATTEMPTS) {
183+
android.util.Log.w("K2Go-Provision",
184+
"kiwix item still failing after " + attempts + " attempts; dropping " + key);
185+
ZimWishlist.remove(app, key);
186+
}
187+
}
188+
166189
private void createNotificationChannel() {
167190
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
168191
NotificationChannel channel = new NotificationChannel(

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimWishlist.java‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,28 @@ public static void remove(Context ctx, String key) {
7979
public static void clear(Context ctx) {
8080
prefs(ctx).edit().remove(KEY).apply();
8181
}
82+
83+
/**
84+
* K2GO-390: record one failed download attempt for a key, counted AGAINST a catalog version
85+
* ({@code catalogTag} = the overlay's mtime, 0 for the asset). If the catalog changed since the last
86+
* failure, the count RESETS to 1 -- a refreshed catalog gives the current file a fresh budget; only
87+
* failures against an unchanging catalog climb toward the cap (= genuinely gone, or no fresh source).
88+
* The count rides in the entry; a confirmed DONE removes the entry via {@link #remove}. Returns the
89+
* new count, or 0 if the key is absent.
90+
*/
91+
public static int bumpAttempts(Context ctx, String key, long catalogTag) {
92+
if (key == null) return 0;
93+
JSONArray cur = all(ctx);
94+
int count = 0;
95+
for (int i = 0; i < cur.length(); i++) {
96+
JSONObject o = cur.optJSONObject(i);
97+
if (o != null && key.equals(o.optString("key"))) {
98+
count = (o.optLong("catTag", Long.MIN_VALUE) == catalogTag) ? o.optInt("attempts", 0) + 1 : 1;
99+
try { o.put("attempts", count).put("catTag", catalogTag); } catch (Exception ignored) {}
100+
break;
101+
}
102+
}
103+
prefs(ctx).edit().putString(KEY, cur.toString()).apply();
104+
return count;
105+
}
82106
}

0 commit comments

Comments
 (0)