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 */
2021package org .appdevforall .k2go .redesign ;
2627
2728import 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+
2934import java .io .BufferedReader ;
35+ import java .io .File ;
36+ import java .io .FileInputStream ;
37+ import java .io .InputStream ;
3038import java .io .InputStreamReader ;
3139import java .util .Iterator ;
3240import 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
0 commit comments