diff --git a/.github/workflows/publish-catalogs.yml b/.github/workflows/publish-catalogs.yml new file mode 100644 index 000000000..e5cd25184 --- /dev/null +++ b/.github/workflows/publish-catalogs.yml @@ -0,0 +1,86 @@ +name: Publish content catalogs + +# ADFA-5094 (ADR-5094): generate the Kolibri/Kiwix catalogs once a week off the build and +# publish them + a versioned manifest to Cloudflare R2, so the app can refresh the catalog +# without an APK release. Reuses the R2 pattern the release build uses for update.json +# (android-release-build.yml): aws s3 cp with the CLOUDFLARE_* vars, fixed keys that overwrite +# so a URL always resolves to the latest. Content stays on the Nginx mirror; only the catalog +# metadata is published here, served from k2go-download.appdevforall.org/catalogs/. +on: + schedule: + # Weekly, Monday 06:17 UTC. Off-peak, odd minute (GitHub throttles round-hour crons). + - cron: '17 6 * * 1' + workflow_dispatch: {} + +jobs: + publish-catalogs: + name: Generate catalogs & upload to Cloudflare R2 + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./controller/app + steps: + - name: Checkout Code + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + # The generators query Studio/Kiwix once, here, on a runner with bandwidth — not from + # every release build (ADR-4954 D1). Never fail the job: a blocked fetch keeps the + # committed asset, and we simply skip its upload below. + - name: Generate catalogs + run: | + python3 tools/build_kolibri_catalog.py || echo "::warning::kolibri generator failed; its upload will be skipped" + python3 tools/build_kiwix_catalog.py || echo "::warning::kiwix generator failed; its upload will be skipped" + + - name: Build manifests and upload to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ vars.CLOUDFLARE_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + BUCKET_NAME: "iiaboa-apk-repo" + BASE_URL: "https://k2go-download.appdevforall.org/catalogs" + run: | + set -euo pipefail + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + GENERATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + VERSION="$(date -u +%Y.%m.%d)" + + # publish + # items-mode "jsonl" -> extract per-channel {id,version} for later per-item delta / + # the "a newer version exists" signal; "none" -> file-level hash only (e.g. Kiwix CSV). + publish() { + name="$1"; file="$2"; ctype="$3"; items_mode="$4" + if [ ! -f "$file" ]; then + echo "::warning::$file not found; skipping $name (kept whatever is already published)" + return 0 + fi + base="$(basename "$file")" + hash="sha256:$(sha256sum "$file" | cut -d' ' -f1)" + items='[]' + if [ "$items_mode" = "jsonl" ]; then + items="$(grep -v '"catalog"' "$file" | jq -c '{id, version}' | jq -sc '.')" + fi + jq -n \ + --arg catalog "$name" --arg version "$VERSION" --arg generated "$GENERATED" \ + --arg hash "$hash" --arg url "$BASE_URL/$base" --argjson items "$items" \ + '{catalog:$catalog, version:$version, generated:$generated, hash:$hash, url:$url, items:$items}' \ + > "$name.manifest.json" + echo "----- $name.manifest.json -----"; cat "$name.manifest.json" + + # Upload the catalog first, then the manifest, so the manifest never points at a + # missing file. Fixed keys -> overwrite -> the URL always resolves to the latest. + aws s3 cp "$file" "s3://$BUCKET_NAME/catalogs/$base" \ + --endpoint-url "$ENDPOINT" --content-type "$ctype" + aws s3 cp "$name.manifest.json" "s3://$BUCKET_NAME/catalogs/$name.manifest.json" \ + --endpoint-url "$ENDPOINT" --content-type "application/json" + } + + publish kolibri src/main/assets/kolibri_catalog.jsonl "application/x-ndjson" jsonl + publish kiwix src/main/assets/kiwix_catalog.csv "text/csv" none diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogManifestClient.java b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogManifestClient.java new file mode 100644 index 000000000..1ead6eeb1 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogManifestClient.java @@ -0,0 +1,124 @@ +/* + * ============================================================================ + * Name : CatalogManifestClient.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Fetches a catalog manifest with a + * conditional GET (ETag / If-None-Match -> 304) and downloads the + * catalog body, mirroring the OTA update client's HTTP. Blocking; + * call from an IO thread. Never throws. + * ============================================================================ + */ +package org.iiab.controller.catalog.data; + +import android.util.Log; + +import org.iiab.controller.catalog.domain.CatalogManifest; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +public final class CatalogManifestClient { + + private static final String TAG = "K2Go-Catalog"; + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 8000; + + public enum Status { OK, NOT_MODIFIED, FAILED } + + /** Outcome of a manifest fetch. {@code manifest}/{@code etag} are set only on {@code OK}. */ + public static final class Result { + public final Status status; + public final CatalogManifest manifest; + public final String etag; + + private Result(Status status, CatalogManifest manifest, String etag) { + this.status = status; + this.manifest = manifest; + this.etag = etag; + } + + static Result ok(CatalogManifest m, String etag) { return new Result(Status.OK, m, etag); } + static Result notModified() { return new Result(Status.NOT_MODIFIED, null, null); } + static Result failed() { return new Result(Status.FAILED, null, null); } + } + + /** GET the manifest; sends {@code If-None-Match} when {@code knownEtag} is set. */ + public Result fetchManifest(String url, String knownEtag) { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestMethod("GET"); + if (knownEtag != null && !knownEtag.isEmpty()) { + conn.setRequestProperty("If-None-Match", knownEtag); + } + int code = conn.getResponseCode(); + if (code == HttpURLConnection.HTTP_NOT_MODIFIED) { + return Result.notModified(); + } + if (code != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "manifest fetch HTTP " + code + " for " + url); + return Result.failed(); + } + String etag = conn.getHeaderField("ETag"); + StringBuilder body = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + body.append(line); + } + } + CatalogManifest m = CatalogManifestParser.parse(body.toString()); + return m == null ? Result.failed() : Result.ok(m, etag); + } catch (Throwable t) { + Log.w(TAG, "manifest fetch failed: " + t.getMessage()); + return Result.failed(); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + /** + * Download the catalog body to {@code dest} via a temp file swapped in on success, so a + * partial download never replaces a good overlay. Returns true on success. + */ + public boolean downloadTo(String url, File dest) { + HttpURLConnection conn = null; + File tmp = new File(dest.getAbsolutePath() + ".tmp"); + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestMethod("GET"); + if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) { + return false; + } + try (InputStream in = conn.getInputStream(); + FileOutputStream out = new FileOutputStream(tmp)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + } + return tmp.renameTo(dest); + } catch (Throwable t) { + Log.w(TAG, "catalog download failed: " + t.getMessage()); + tmp.delete(); + return false; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogManifestParser.java b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogManifestParser.java new file mode 100644 index 000000000..212eb08fc --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogManifestParser.java @@ -0,0 +1,58 @@ +/* + * ============================================================================ + * Name : CatalogManifestParser.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Parses the manifest JSON the weekly + * workflow publishes into a CatalogManifest. Never throws — a + * malformed manifest yields null, and the caller keeps the current + * catalog. + * ============================================================================ + */ +package org.iiab.controller.catalog.data; + +import org.iiab.controller.catalog.domain.CatalogManifest; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public final class CatalogManifestParser { + + private CatalogManifestParser() { + } + + /** @return the parsed manifest, or null if the input is missing or malformed. */ + public static CatalogManifest parse(String json) { + if (json == null || json.isEmpty()) { + return null; + } + try { + JSONObject o = new JSONObject(json); + List items = new ArrayList<>(); + JSONArray arr = o.optJSONArray("items"); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + JSONObject it = arr.optJSONObject(i); + if (it == null) { + continue; + } + String id = it.optString("id", ""); + if (!id.isEmpty()) { + items.add(new CatalogManifest.Item(id, it.optInt("version", 0))); + } + } + } + return new CatalogManifest( + o.optString("catalog", ""), + o.optString("version", ""), + o.optString("generated", ""), + o.optString("hash", ""), + o.optString("url", ""), + items); + } catch (Throwable t) { + return null; + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogOverlay.java b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogOverlay.java new file mode 100644 index 000000000..6d0a29911 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogOverlay.java @@ -0,0 +1,38 @@ +/* + * ============================================================================ + * Name : CatalogOverlay.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Where a pulled catalog lands on the + * device. The refresh worker writes the overlay here and the + * catalog source reads it (when present and newer) in place of the + * APK-bundled asset. Same path convention on both sides. + * ============================================================================ + */ +package org.iiab.controller.catalog.data; + +import android.content.Context; + +import java.io.File; + +public final class CatalogOverlay { + + private static final String DIR = "catalogs"; + + private CatalogOverlay() { + } + + /** {@code filesDir/catalogs}, created if missing. */ + public static File dir(Context ctx) { + File d = new File(ctx.getApplicationContext().getFilesDir(), DIR); + if (!d.exists()) { + d.mkdirs(); + } + return d; + } + + /** The overlay file for a catalog whose bundled asset is named {@code basename}. */ + public static File file(Context ctx, String basename) { + return new File(dir(ctx), basename); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshScheduler.java b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshScheduler.java new file mode 100644 index 000000000..81e1e4b7a --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshScheduler.java @@ -0,0 +1,69 @@ +/* + * ============================================================================ + * Name : CatalogRefreshScheduler.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Enqueues the catalog refresh: a weekly, + * network-constrained periodic job, plus an opportunistic one-shot + * (e.g. when the picker opens). The worker's own TTL gate keeps the + * one-shot cheap. Unique per catalog, KEEP so relaunches don't + * reset the schedule. + * ============================================================================ + */ +package org.iiab.controller.catalog.data; + +import android.content.Context; + +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; + +import java.util.concurrent.TimeUnit; + +public final class CatalogRefreshScheduler { + + private CatalogRefreshScheduler() { + } + + private static Data input(String name, String manifestUrl, String basename) { + return new Data.Builder() + .putString(CatalogRefreshWorker.KEY_NAME, name) + .putString(CatalogRefreshWorker.KEY_MANIFEST_URL, manifestUrl) + .putString(CatalogRefreshWorker.KEY_BASENAME, basename) + .build(); + } + + private static Constraints connected() { + return new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + } + + /** Weekly periodic refresh, enqueued once per catalog (KEEP). Safe to call on every launch. */ + public static void scheduleWeekly(Context ctx, String name, String manifestUrl, String basename) { + PeriodicWorkRequest req = new PeriodicWorkRequest.Builder( + CatalogRefreshWorker.class, 7, TimeUnit.DAYS) + .setConstraints(connected()) + .setInputData(input(name, manifestUrl, basename)) + .build(); + WorkManager.getInstance(ctx.getApplicationContext()) + .enqueueUniquePeriodicWork("catalog-refresh-" + name, + ExistingPeriodicWorkPolicy.KEEP, req); + } + + /** Opportunistic one-shot; the worker's TTL gate no-ops it when still fresh. */ + public static void refreshNow(Context ctx, String name, String manifestUrl, String basename) { + OneTimeWorkRequest req = new OneTimeWorkRequest.Builder(CatalogRefreshWorker.class) + .setConstraints(connected()) + .setInputData(input(name, manifestUrl, basename)) + .build(); + WorkManager.getInstance(ctx.getApplicationContext()) + .enqueueUniqueWork("catalog-refresh-now-" + name, + ExistingWorkPolicy.KEEP, req); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshStore.java b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshStore.java new file mode 100644 index 000000000..a1c7b329d --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshStore.java @@ -0,0 +1,65 @@ +/* + * ============================================================================ + * Name : CatalogRefreshStore.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Per-catalog refresh state: last check + * time, the active catalog's hash/generated date, and the cached + * ETag. Prefs-backed; keys namespaced by catalog name so one store + * serves Kolibri, Kiwix, etc. + * ============================================================================ + */ +package org.iiab.controller.catalog.data; + +import android.content.Context; +import android.content.SharedPreferences; + +public final class CatalogRefreshStore { + + private static final String PREFS = "iiab_catalog_refresh"; + + private final SharedPreferences prefs; + + public CatalogRefreshStore(Context ctx) { + this.prefs = ctx.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + private static String key(String catalog, String field) { + return catalog + "." + field; + } + + /** Epoch millis of the last update check (0 if never). */ + public long lastCheckMs(String catalog) { + return prefs.getLong(key(catalog, "last_check"), 0L); + } + + /** Hash of the catalog currently in use (empty until a pull is applied). */ + public String activeHash(String catalog) { + return prefs.getString(key(catalog, "hash"), ""); + } + + /** Generated date of the catalog currently in use (empty until a pull is applied). */ + public String activeGenerated(String catalog) { + return prefs.getString(key(catalog, "generated"), ""); + } + + /** ETag of the last fetched manifest, for the conditional GET (empty if none). */ + public String etag(String catalog) { + return prefs.getString(key(catalog, "etag"), ""); + } + + /** Record that a check happened now (whether or not it changed anything). */ + public void recordChecked(String catalog, long nowMs) { + prefs.edit().putLong(key(catalog, "last_check"), nowMs).apply(); + } + + /** Record a successful swap: the new hash/generated and the manifest ETag, and the check time. */ + public void recordApplied(String catalog, String hash, String generated, String etag, long nowMs) { + prefs.edit() + .putString(key(catalog, "hash"), hash == null ? "" : hash) + .putString(key(catalog, "generated"), generated == null ? "" : generated) + .putString(key(catalog, "etag"), etag == null ? "" : etag) + .putLong(key(catalog, "last_check"), nowMs) + .apply(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshWorker.java b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshWorker.java new file mode 100644 index 000000000..f08e388a9 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/data/CatalogRefreshWorker.java @@ -0,0 +1,114 @@ +/* + * ============================================================================ + * Name : CatalogRefreshWorker.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Catalog-agnostic WorkManager job that + * refreshes one catalog: TTL gate -> conditional manifest fetch + * (ETag) -> if the hash changed, download the catalog, verify its + * sha256, write the overlay. The catalog to refresh is passed as + * input data {name, manifestUrl, basename}, so one worker serves + * Kolibri, Kiwix, etc. + * ============================================================================ + */ +package org.iiab.controller.catalog.data; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import org.iiab.controller.catalog.domain.CatalogFreshness; +import org.iiab.controller.catalog.domain.CatalogManifest; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.security.MessageDigest; +import java.util.Locale; + +public final class CatalogRefreshWorker extends Worker { + + private static final String TAG = "K2Go-Catalog"; + + public static final String KEY_NAME = "name"; + public static final String KEY_MANIFEST_URL = "manifest_url"; + public static final String KEY_BASENAME = "basename"; + + public CatalogRefreshWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @NonNull + @Override + public Result doWork() { + Context ctx = getApplicationContext(); + String name = getInputData().getString(KEY_NAME); + String manifestUrl = getInputData().getString(KEY_MANIFEST_URL); + String basename = getInputData().getString(KEY_BASENAME); + if (name == null || manifestUrl == null || basename == null) { + return Result.success(); // nothing to do; misconfigured request + } + + CatalogRefreshStore store = new CatalogRefreshStore(ctx); + long now = System.currentTimeMillis(); + if (!CatalogFreshness.dueForCheck(store.lastCheckMs(name), now, CatalogFreshness.DEFAULT_TTL_MS)) { + return Result.success(); // still fresh; do not hit the network + } + + CatalogManifestClient client = new CatalogManifestClient(); + CatalogManifestClient.Result res = client.fetchManifest(manifestUrl, store.etag(name)); + if (res.status == CatalogManifestClient.Status.FAILED) { + return Result.retry(); // offline / server hiccup: back off and try again + } + store.recordChecked(name, now); + if (res.status == CatalogManifestClient.Status.NOT_MODIFIED) { + return Result.success(); + } + + CatalogManifest m = res.manifest; + if (!CatalogFreshness.changed(store.activeHash(name), m.hash())) { + // Same content the app already uses; just refresh the ETag so next check is cheaper. + store.recordApplied(name, m.hash(), m.generated(), res.etag, now); + return Result.success(); + } + + File overlay = CatalogOverlay.file(ctx, basename); + if (!client.downloadTo(m.url(), overlay)) { + return Result.retry(); + } + if (!hashMatches(overlay, m.hash())) { + Log.w(TAG, "catalog " + name + " hash mismatch after download; discarding"); + overlay.delete(); + return Result.retry(); + } + store.recordApplied(name, m.hash(), m.generated(), res.etag, now); + Log.d(TAG, "catalog " + name + " updated to " + m.version()); + return Result.success(); + } + + /** True when the file's sha256 equals the manifest hash ({@code sha256:}). */ + private static boolean hashMatches(File file, String manifestHash) { + if (manifestHash == null) { + return false; + } + String expected = manifestHash.startsWith("sha256:") ? manifestHash.substring(7) : manifestHash; + try (InputStream in = new FileInputStream(file)) { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + md.update(buf, 0, n); + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) { + sb.append(String.format(Locale.US, "%02x", b)); + } + return sb.toString().equalsIgnoreCase(expected); + } catch (Throwable t) { + return false; + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/domain/CatalogFreshness.java b/controller/app/src/main/java/org/iiab/controller/catalog/domain/CatalogFreshness.java new file mode 100644 index 000000000..a70476690 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/domain/CatalogFreshness.java @@ -0,0 +1,47 @@ +/* + * ============================================================================ + * Name : CatalogFreshness.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Pure rules for the catalog refresh: + * when to check for an update, and whether the published catalog + * differs from the one in use. The app stores, per catalog, the + * active catalog's hash and the last successful check time; these + * rules decide when to hit the network and when to swap. No + * Android, no I/O => unit-testable. + * ============================================================================ + */ +package org.iiab.controller.catalog.domain; + +public final class CatalogFreshness { + + /** How long before the app bothers checking the manifest again. */ + public static final long DEFAULT_TTL_MS = 7L * 24 * 60 * 60 * 1000; + + private CatalogFreshness() { + } + + /** + * True when the catalog is due for an update check: never checked + * ({@code lastCheckMs <= 0}) or more than {@code ttlMs} has elapsed. A clock that ran + * backwards ({@code now < lastCheck}) is treated as due, so a wrong device clock never wedges + * the check off. + */ + public static boolean dueForCheck(long lastCheckMs, long nowMs, long ttlMs) { + if (lastCheckMs <= 0L) return true; + if (nowMs < lastCheckMs) return true; + return nowMs - lastCheckMs >= ttlMs; + } + + /** + * True when the published catalog differs from the one the app is using, i.e. it should be + * pulled and swapped. The hash is the source of truth (the version string is only a label): + * an absent remote hash means "cannot tell" — do not swap; a local hash that differs from a + * present remote — swap; no local hash yet (never pulled) with a real remote — swap. + */ + public static boolean changed(String localHash, String remoteHash) { + if (remoteHash == null || remoteHash.isEmpty()) return false; + if (localHash == null || localHash.isEmpty()) return true; + return !localHash.equals(remoteHash); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/catalog/domain/CatalogManifest.java b/controller/app/src/main/java/org/iiab/controller/catalog/domain/CatalogManifest.java new file mode 100644 index 000000000..667464127 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/catalog/domain/CatalogManifest.java @@ -0,0 +1,54 @@ +/* + * ============================================================================ + * Name : CatalogManifest.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-5094 (ADR-5094). Immutable description of a published + * catalog, parsed by the data layer from the manifest JSON the + * weekly workflow uploads to Cloudflare. Pure domain: no Android, + * no JSON, no I/O. + * ============================================================================ + */ +package org.iiab.controller.catalog.domain; + +import java.util.Collections; +import java.util.List; + +public final class CatalogManifest { + + /** Per-item version — for a later per-item delta and the "a newer version exists" signal. */ + public static final class Item { + public final String id; + public final int version; + + public Item(String id, int version) { + this.id = id; + this.version = version; + } + } + + private final String catalog; + private final String version; + private final String generated; + private final String hash; + private final String url; + private final List items; + + public CatalogManifest(String catalog, String version, String generated, + String hash, String url, List items) { + this.catalog = catalog; + this.version = version; + this.generated = generated; + this.hash = hash; + this.url = url; + this.items = items == null ? Collections.emptyList() + : Collections.unmodifiableList(items); + } + + public String catalog() { return catalog; } + public String version() { return version; } + public String generated() { return generated; } + public String hash() { return hash; } + public String url() { return url; } + public List items() { return items; } +} diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/data/BundledCatalogSource.java b/controller/app/src/main/java/org/iiab/controller/kolibri/data/BundledCatalogSource.java index 38db656ff..f17ebc400 100644 --- a/controller/app/src/main/java/org/iiab/controller/kolibri/data/BundledCatalogSource.java +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/data/BundledCatalogSource.java @@ -12,10 +12,13 @@ import android.content.Context; import android.util.Log; +import org.iiab.controller.catalog.data.CatalogOverlay; import org.iiab.controller.kolibri.domain.Channel; import org.json.JSONObject; import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; @@ -42,13 +45,18 @@ public final class BundledCatalogSource { private static final String TAG = "K2Go-Kolibri"; - private static final String ASSET = "kolibri_catalog.jsonl"; + // ADFA-5094: also the overlay basename — CatalogRepositoryImpl points the refresh worker at + // this same name, so the worker writes the overlay exactly where this source looks for it. + static final String ASSET = "kolibri_catalog.jsonl"; /** Marks the header line, so it is never mistaken for a channel. */ private static final String HEADER_KEY = "catalog"; private static volatile List cachedChannels; private static volatile String cachedGeneratedOn = ""; + // ADFA-5094: which source the cache came from. -1 = not loaded, 0 = APK asset, + // >0 = the pulled overlay's lastModified. A change here means "reload from the newer source". + private static volatile long cachedOverlayMtime = -1L; private final Context appContext; @@ -56,22 +64,41 @@ public BundledCatalogSource(Context context) { this.appContext = context.getApplicationContext(); } - /** Every channel in the asset, in file order. Never null. */ + /** Every channel in the catalog, in file order. Never null. */ public List channels() { + reloadIfOverlayChanged(); load(); return cachedChannels; } /** ISO-8601 date from the header line, or empty. */ public String generatedOn() { + reloadIfOverlayChanged(); load(); return cachedGeneratedOn; } - /** Drops the cache. For tests and for a future in-place catalog refresh. */ + /** Drops the cache. For tests and for the in-place catalog refresh (ADFA-5094). */ public static void invalidate() { cachedChannels = null; cachedGeneratedOn = ""; + cachedOverlayMtime = -1L; + } + + /** + * ADFA-5094: pick up a freshly pulled overlay (or its removal) without an explicit callback — + * if the overlay file's mtime differs from what the cache was loaded from, drop the cache so + * the next {@link #load()} reads the current source. + */ + private void reloadIfOverlayChanged() { + if (cachedChannels == null) { + return; // not loaded yet; load() picks the source + } + File overlay = CatalogOverlay.file(appContext, ASSET); + long mtime = overlay.exists() ? overlay.lastModified() : 0L; + if (mtime != cachedOverlayMtime) { + invalidate(); + } } private void load() { @@ -86,7 +113,13 @@ private void load() { String generated = ""; int skipped = 0; - try (InputStream is = appContext.getAssets().open(ASSET); + // ADFA-5094: prefer the pulled overlay over the APK asset when it is present. + File overlay = CatalogOverlay.file(appContext, ASSET); + boolean useOverlay = overlay.exists(); + long mtime = useOverlay ? overlay.lastModified() : 0L; + + try (InputStream is = useOverlay + ? new FileInputStream(overlay) : appContext.getAssets().open(ASSET); BufferedReader r = new BufferedReader( new InputStreamReader(is, StandardCharsets.UTF_8))) { String line; @@ -122,6 +155,7 @@ private void load() { } cachedGeneratedOn = generated; cachedChannels = Collections.unmodifiableList(out); + cachedOverlayMtime = mtime; } } } diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/data/CatalogRepositoryImpl.java b/controller/app/src/main/java/org/iiab/controller/kolibri/data/CatalogRepositoryImpl.java index c19289c2b..d83770023 100644 --- a/controller/app/src/main/java/org/iiab/controller/kolibri/data/CatalogRepositoryImpl.java +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/data/CatalogRepositoryImpl.java @@ -28,11 +28,26 @@ */ public final class CatalogRepositoryImpl implements CatalogRepository { + // ADFA-5094: the Kolibri catalog is refreshed from Cloudflare (ADR-5094), overlaying the APK + // asset when a newer version has been pulled. + private static final String CATALOG = "kolibri"; + private static final String MANIFEST_URL = + "https://k2go-download.appdevforall.org/catalogs/kolibri.manifest.json"; + // Single source of truth for the basename: the same name BundledCatalogSource reads the overlay + // from, so the worker writes it exactly where the source looks (they must never drift). + private static final String BASENAME = BundledCatalogSource.ASSET; + private final BundledCatalogSource bundled; private final StudioTreeSource studio; public CatalogRepositoryImpl(Context context) { this(new BundledCatalogSource(context), new StudioTreeSource()); + // Keep the bundled catalog current: a weekly refresh plus an opportunistic (TTL-gated) + // check now. Only the Context constructor schedules — the test constructor below does not. + org.iiab.controller.catalog.data.CatalogRefreshScheduler.scheduleWeekly( + context, CATALOG, MANIFEST_URL, BASENAME); + org.iiab.controller.catalog.data.CatalogRefreshScheduler.refreshNow( + context, CATALOG, MANIFEST_URL, BASENAME); } /** For tests and for pointing the tree source at a mirror. */ diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/presentation/KolibriBrowseFragment.java b/controller/app/src/main/java/org/iiab/controller/kolibri/presentation/KolibriBrowseFragment.java index 80948e49d..b3febe4c3 100644 --- a/controller/app/src/main/java/org/iiab/controller/kolibri/presentation/KolibriBrowseFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/presentation/KolibriBrowseFragment.java @@ -76,6 +76,7 @@ public final class KolibriBrowseFragment extends Fragment { private TextView langSub; private TextView status; private TextView count; + private TextView updated; private TextView storageLabel; private ProgressBar storageBar; private LinearLayout chips; @@ -106,6 +107,7 @@ public void onViewCreated(@NonNull View v, @Nullable Bundle savedInstanceState) langSub = v.findViewById(R.id.k2go_kbrowse_lang_sub); status = v.findViewById(R.id.k2go_kbrowse_status); count = v.findViewById(R.id.k2go_kbrowse_count); + updated = v.findViewById(R.id.k2go_kbrowse_updated); storageLabel = v.findViewById(R.id.k2go_kbrowse_storage_label); storageBar = v.findViewById(R.id.k2go_kbrowse_storage_bar); chips = v.findViewById(R.id.k2go_kbrowse_chips); @@ -245,6 +247,17 @@ private void render(KolibriCatalogUiState s) { count.setText(getString(vm.query().hasKeyword() ? R.string.k2go_zc_count_results : R.string.k2go_zc_count_items, s.channels().size())); + + // ADFA-5094: catalog freshness — the pulled overlay's date once refreshed, else the bundled one. + String on = s.generatedOn(); + if (updated != null) { + if (on == null || on.isEmpty()) { + updated.setVisibility(View.GONE); + } else { + updated.setVisibility(View.VISIBLE); + updated.setText(getString(R.string.k2go_kolibri_catalog_updated, on)); + } + } updateStorage(); } diff --git a/controller/app/src/main/res/layout/fragment_k2go_kolibri_browse.xml b/controller/app/src/main/res/layout/fragment_k2go_kolibri_browse.xml index 04660b6bc..f95863a35 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_kolibri_browse.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_kolibri_browse.xml @@ -164,6 +164,17 @@ android:gravity="end" android:textAppearance="?attr/textAppearanceBodySmall" android:textColor="@color/k2go_muted" /> + + + تم — انتقل إلى مكتبتي %1$d دورة لم تكتمل. أعد محاولة ما فشل قبل المغادرة — المغادرة تمسح هذه القائمة. + تم تحديث الكتالوج في %1$s diff --git a/controller/app/src/main/res/values-az/strings_kolibri.xml b/controller/app/src/main/res/values-az/strings_kolibri.xml index 484f88ec3..d98ab9068 100644 --- a/controller/app/src/main/res/values-az/strings_kolibri.xml +++ b/controller/app/src/main/res/values-az/strings_kolibri.xml @@ -34,4 +34,5 @@ Hazırdır — kitabxanama keç %1$d kurs tamamlanmadı. Getməzdən əvvəl uğursuzları yenidən yoxlayın — çıxış bu siyahını silir. + Kataloq %1$s tarixində yeniləndi diff --git a/controller/app/src/main/res/values-bg/strings_kolibri.xml b/controller/app/src/main/res/values-bg/strings_kolibri.xml index ad1532026..13a309127 100644 --- a/controller/app/src/main/res/values-bg/strings_kolibri.xml +++ b/controller/app/src/main/res/values-bg/strings_kolibri.xml @@ -34,4 +34,5 @@ Готово — към моята библиотека %1$d курса не завършиха. Опитайте отново неуспешните, преди да излезете — излизането изчиства този списък. + Каталогът е обновен на %1$s diff --git a/controller/app/src/main/res/values-bn/strings_kolibri.xml b/controller/app/src/main/res/values-bn/strings_kolibri.xml index 4a9b87401..78b21e4ae 100644 --- a/controller/app/src/main/res/values-bn/strings_kolibri.xml +++ b/controller/app/src/main/res/values-bn/strings_kolibri.xml @@ -34,4 +34,5 @@ সম্পন্ন — আমার লাইব্রেরিতে যান %1$dটি কোর্স শেষ হয়নি। চলে যাওয়ার আগে ব্যর্থগুলো আবার চেষ্টা করুন — চলে গেলে এই তালিকা মুছে যাবে। + ক্যাটালগ %1$s তারিখে আপডেট হয়েছে diff --git a/controller/app/src/main/res/values-cs/strings_kolibri.xml b/controller/app/src/main/res/values-cs/strings_kolibri.xml index 245a169de..074951eee 100644 --- a/controller/app/src/main/res/values-cs/strings_kolibri.xml +++ b/controller/app/src/main/res/values-cs/strings_kolibri.xml @@ -34,4 +34,5 @@ Hotovo — přejít do knihovny %1$d kurzů se nedokončilo. Než odejdete, zkuste neúspěšné znovu — odchodem se tento seznam smaže. + Katalog aktualizován %1$s diff --git a/controller/app/src/main/res/values-de/strings_kolibri.xml b/controller/app/src/main/res/values-de/strings_kolibri.xml index 75430fc5e..3f8712538 100644 --- a/controller/app/src/main/res/values-de/strings_kolibri.xml +++ b/controller/app/src/main/res/values-de/strings_kolibri.xml @@ -34,4 +34,5 @@ Fertig — zu meiner Bibliothek %1$d Kurse wurden nicht fertig. Wiederholen Sie die fehlgeschlagenen, bevor Sie gehen — beim Verlassen wird diese Liste gelöscht. + Katalog aktualisiert am %1$s diff --git a/controller/app/src/main/res/values-el/strings_kolibri.xml b/controller/app/src/main/res/values-el/strings_kolibri.xml index 02e65e11c..c0e2aab3a 100644 --- a/controller/app/src/main/res/values-el/strings_kolibri.xml +++ b/controller/app/src/main/res/values-el/strings_kolibri.xml @@ -34,4 +34,5 @@ Έτοιμο — στη βιβλιοθήκη μου %1$d μαθήματα δεν ολοκληρώθηκαν. Δοκιμάστε ξανά όσα απέτυχαν πριν φύγετε — η έξοδος διαγράφει αυτή τη λίστα. + Ο κατάλογος ενημερώθηκε στις %1$s diff --git a/controller/app/src/main/res/values-es/strings_kolibri.xml b/controller/app/src/main/res/values-es/strings_kolibri.xml index 029a5ee80..9cc69d269 100644 --- a/controller/app/src/main/res/values-es/strings_kolibri.xml +++ b/controller/app/src/main/res/values-es/strings_kolibri.xml @@ -34,4 +34,5 @@ Listo: ir a mi biblioteca %1$d curso(s) no terminaron. Reintenta los que fallaron antes de salir: al salir se borra esta lista. + Catálogo actualizado el %1$s diff --git a/controller/app/src/main/res/values-fa/strings_kolibri.xml b/controller/app/src/main/res/values-fa/strings_kolibri.xml index d3988167f..d8f2a57e0 100644 --- a/controller/app/src/main/res/values-fa/strings_kolibri.xml +++ b/controller/app/src/main/res/values-fa/strings_kolibri.xml @@ -34,4 +34,5 @@ انجام شد — به کتابخانه‌ام برو %1$d دوره کامل نشد. پیش از خروج، موارد ناموفق را دوباره تلاش کنید — خروج این فهرست را پاک می‌کند. + کاتالوگ در %1$s به‌روزرسانی شد diff --git a/controller/app/src/main/res/values-fr/strings_kolibri.xml b/controller/app/src/main/res/values-fr/strings_kolibri.xml index 053f8ee04..0e29c870c 100644 --- a/controller/app/src/main/res/values-fr/strings_kolibri.xml +++ b/controller/app/src/main/res/values-fr/strings_kolibri.xml @@ -34,4 +34,5 @@ Terminé — aller à ma bibliothèque %1$d cours ne se sont pas terminés. Réessayez ceux qui ont échoué avant de partir : quitter efface cette liste. + Catalogue mis à jour le %1$s diff --git a/controller/app/src/main/res/values-gu/strings_kolibri.xml b/controller/app/src/main/res/values-gu/strings_kolibri.xml index 58a8a98b4..148c62f93 100644 --- a/controller/app/src/main/res/values-gu/strings_kolibri.xml +++ b/controller/app/src/main/res/values-gu/strings_kolibri.xml @@ -34,4 +34,5 @@ પૂર્ણ — મારી લાઇબ્રેરીમાં જાઓ %1$d કોર્સ પૂરા થયા નથી. જતાં પહેલાં નિષ્ફળ થયેલા ફરી પ્રયાસ કરો — જવાથી આ યાદી ભૂંસાઈ જશે. + કૅટલૉગ %1$s ના રોજ અપડેટ થયું diff --git a/controller/app/src/main/res/values-hi/strings_kolibri.xml b/controller/app/src/main/res/values-hi/strings_kolibri.xml index 2873d085c..ef8ee44e1 100644 --- a/controller/app/src/main/res/values-hi/strings_kolibri.xml +++ b/controller/app/src/main/res/values-hi/strings_kolibri.xml @@ -34,4 +34,5 @@ हो गया — मेरी लाइब्रेरी पर जाएँ %1$d कोर्स पूरे नहीं हुए। जाने से पहले विफल हुए फिर से आज़माएँ — जाने पर यह सूची मिट जाएगी। + कैटलॉग %1$s को अपडेट हुआ diff --git a/controller/app/src/main/res/values-hu/strings_kolibri.xml b/controller/app/src/main/res/values-hu/strings_kolibri.xml index 56ecf28c5..d99954a5c 100644 --- a/controller/app/src/main/res/values-hu/strings_kolibri.xml +++ b/controller/app/src/main/res/values-hu/strings_kolibri.xml @@ -34,4 +34,5 @@ Kész — a könyvtáramhoz %1$d tanfolyam nem fejeződött be. Távozás előtt próbálja újra a sikerteleneket — a kilépés törli ezt a listát. + Katalógus frissítve: %1$s diff --git a/controller/app/src/main/res/values-in/strings_kolibri.xml b/controller/app/src/main/res/values-in/strings_kolibri.xml index 2e536d123..9d0e0ccd0 100644 --- a/controller/app/src/main/res/values-in/strings_kolibri.xml +++ b/controller/app/src/main/res/values-in/strings_kolibri.xml @@ -34,4 +34,5 @@ Selesai — buka perpustakaan saya %1$d kursus tidak selesai. Coba lagi yang gagal sebelum keluar — keluar akan menghapus daftar ini. + Katalog diperbarui pada %1$s diff --git a/controller/app/src/main/res/values-it/strings_kolibri.xml b/controller/app/src/main/res/values-it/strings_kolibri.xml index 898045aea..d4e42fcdc 100644 --- a/controller/app/src/main/res/values-it/strings_kolibri.xml +++ b/controller/app/src/main/res/values-it/strings_kolibri.xml @@ -34,4 +34,5 @@ Fatto — vai alla mia libreria %1$d corsi non sono stati completati. Riprova quelli falliti prima di uscire: uscendo questa lista viene cancellata. + Catalogo aggiornato il %1$s diff --git a/controller/app/src/main/res/values-ja/strings_kolibri.xml b/controller/app/src/main/res/values-ja/strings_kolibri.xml index 723d2e6fd..a7b359a59 100644 --- a/controller/app/src/main/res/values-ja/strings_kolibri.xml +++ b/controller/app/src/main/res/values-ja/strings_kolibri.xml @@ -34,4 +34,5 @@ 完了 — ライブラリへ %1$d 件のコースが完了しませんでした。 退出する前に失敗したものを再試行してください。退出するとこの一覧は消えます。 + カタログの更新日: %1$s diff --git a/controller/app/src/main/res/values-ko/strings_kolibri.xml b/controller/app/src/main/res/values-ko/strings_kolibri.xml index 42237fd0c..6c2a6b675 100644 --- a/controller/app/src/main/res/values-ko/strings_kolibri.xml +++ b/controller/app/src/main/res/values-ko/strings_kolibri.xml @@ -34,4 +34,5 @@ 완료 — 내 라이브러리로 %1$d개 강의가 완료되지 않았습니다. 나가기 전에 실패한 항목을 다시 시도하세요. 나가면 이 목록이 지워집니다. + 카탈로그 업데이트: %1$s diff --git a/controller/app/src/main/res/values-lt/strings_kolibri.xml b/controller/app/src/main/res/values-lt/strings_kolibri.xml index d3d1d2354..812dc2558 100644 --- a/controller/app/src/main/res/values-lt/strings_kolibri.xml +++ b/controller/app/src/main/res/values-lt/strings_kolibri.xml @@ -34,4 +34,5 @@ Baigta — eiti į biblioteką %1$d kursų nebaigta. Prieš išeidami pakartokite nepavykusius — išėjus šis sąrašas išvalomas. + Katalogas atnaujintas %1$s diff --git a/controller/app/src/main/res/values-nl/strings_kolibri.xml b/controller/app/src/main/res/values-nl/strings_kolibri.xml index 4c6e47182..6cc386f09 100644 --- a/controller/app/src/main/res/values-nl/strings_kolibri.xml +++ b/controller/app/src/main/res/values-nl/strings_kolibri.xml @@ -34,4 +34,5 @@ Klaar — naar mijn bibliotheek %1$d cursussen zijn niet afgerond. Probeer de mislukte opnieuw voordat u weggaat — weggaan wist deze lijst. + Catalogus bijgewerkt op %1$s diff --git a/controller/app/src/main/res/values-no/strings_kolibri.xml b/controller/app/src/main/res/values-no/strings_kolibri.xml index 721415acd..d7651003c 100644 --- a/controller/app/src/main/res/values-no/strings_kolibri.xml +++ b/controller/app/src/main/res/values-no/strings_kolibri.xml @@ -34,4 +34,5 @@ Ferdig — gå til biblioteket %1$d kurs ble ikke ferdige. Prøv de mislykkede på nytt før du går — å gå tømmer denne listen. + Katalog oppdatert %1$s diff --git a/controller/app/src/main/res/values-pl/strings_kolibri.xml b/controller/app/src/main/res/values-pl/strings_kolibri.xml index 44e94e7b4..22a6da3b0 100644 --- a/controller/app/src/main/res/values-pl/strings_kolibri.xml +++ b/controller/app/src/main/res/values-pl/strings_kolibri.xml @@ -34,4 +34,5 @@ Gotowe — przejdź do biblioteki %1$d kursów nie ukończono. Ponów nieudane przed wyjściem — wyjście czyści tę listę. + Katalog zaktualizowano %1$s diff --git a/controller/app/src/main/res/values-pt/strings_kolibri.xml b/controller/app/src/main/res/values-pt/strings_kolibri.xml index 7ad603cbe..0761a85ec 100644 --- a/controller/app/src/main/res/values-pt/strings_kolibri.xml +++ b/controller/app/src/main/res/values-pt/strings_kolibri.xml @@ -34,4 +34,5 @@ Pronto — ir para minha biblioteca %1$d curso(s) não terminaram. Tente de novo os que falharam antes de sair: sair apaga esta lista. + Catálogo atualizado em %1$s diff --git a/controller/app/src/main/res/values-ro/strings_kolibri.xml b/controller/app/src/main/res/values-ro/strings_kolibri.xml index 89784f0b7..0e21954ee 100644 --- a/controller/app/src/main/res/values-ro/strings_kolibri.xml +++ b/controller/app/src/main/res/values-ro/strings_kolibri.xml @@ -34,4 +34,5 @@ Gata — mergi la biblioteca mea %1$d cursuri nu s-au terminat. Reîncearcă-le pe cele eșuate înainte să pleci — plecarea șterge lista. + Catalog actualizat pe %1$s diff --git a/controller/app/src/main/res/values-ru-rRU/strings_kolibri.xml b/controller/app/src/main/res/values-ru-rRU/strings_kolibri.xml index 2b18d67ac..9f161145e 100644 --- a/controller/app/src/main/res/values-ru-rRU/strings_kolibri.xml +++ b/controller/app/src/main/res/values-ru-rRU/strings_kolibri.xml @@ -34,4 +34,5 @@ Готово — в мою библиотеку %1$d курс(ов) не завершились. Повторите неудавшиеся перед выходом — выход очищает этот список. + Каталог обновлён %1$s diff --git a/controller/app/src/main/res/values-sk/strings_kolibri.xml b/controller/app/src/main/res/values-sk/strings_kolibri.xml index ece51518e..628b6e3a1 100644 --- a/controller/app/src/main/res/values-sk/strings_kolibri.xml +++ b/controller/app/src/main/res/values-sk/strings_kolibri.xml @@ -34,4 +34,5 @@ Hotovo — prejsť do knižnice %1$d kurzov sa nedokončilo. Pred odchodom skúste neúspešné znova — odchodom sa tento zoznam vymaže. + Katalóg aktualizovaný %1$s diff --git a/controller/app/src/main/res/values-sr/strings_kolibri.xml b/controller/app/src/main/res/values-sr/strings_kolibri.xml index 130ac15aa..7d7aaba34 100644 --- a/controller/app/src/main/res/values-sr/strings_kolibri.xml +++ b/controller/app/src/main/res/values-sr/strings_kolibri.xml @@ -34,4 +34,5 @@ Готово — иди у моју библиотеку %1$d курсева није завршено. Покушајте поново неуспеле пре него што одете — одлазак брише овај списак. + Каталог ажуриран %1$s diff --git a/controller/app/src/main/res/values-sw/strings_kolibri.xml b/controller/app/src/main/res/values-sw/strings_kolibri.xml index f30fddcb4..6b8683cac 100644 --- a/controller/app/src/main/res/values-sw/strings_kolibri.xml +++ b/controller/app/src/main/res/values-sw/strings_kolibri.xml @@ -34,4 +34,5 @@ Imekamilika — nenda kwenye maktaba yangu Kozi %1$d hazikukamilika. Jaribu tena zilizoshindwa kabla ya kuondoka — kuondoka kunafuta orodha hii. + Katalogi ilisasishwa tarehe %1$s diff --git a/controller/app/src/main/res/values-ta/strings_kolibri.xml b/controller/app/src/main/res/values-ta/strings_kolibri.xml index 4fcd41c91..f963c394d 100644 --- a/controller/app/src/main/res/values-ta/strings_kolibri.xml +++ b/controller/app/src/main/res/values-ta/strings_kolibri.xml @@ -34,4 +34,5 @@ முடிந்தது — எனது நூலகத்திற்குச் செல் %1$d பாடநெறிகள் முடியவில்லை. வெளியேறும் முன் தோல்வியடைந்தவற்றை மீண்டும் முயற்சிக்கவும் — வெளியேறினால் இந்தப் பட்டியல் அழிக்கப்படும். + பட்டியல் %1$s அன்று புதுப்பிக்கப்பட்டது diff --git a/controller/app/src/main/res/values-tr/strings_kolibri.xml b/controller/app/src/main/res/values-tr/strings_kolibri.xml index 7ac5e2e8f..b38d69f9b 100644 --- a/controller/app/src/main/res/values-tr/strings_kolibri.xml +++ b/controller/app/src/main/res/values-tr/strings_kolibri.xml @@ -34,4 +34,5 @@ Tamam — kitaplığıma git %1$d kurs tamamlanmadı. Ayrılmadan önce başarısız olanları yeniden deneyin — ayrılmak bu listeyi siler. + Katalog %1$s tarihinde güncellendi diff --git a/controller/app/src/main/res/values-uk/strings_kolibri.xml b/controller/app/src/main/res/values-uk/strings_kolibri.xml index bd84997f5..a623f569c 100644 --- a/controller/app/src/main/res/values-uk/strings_kolibri.xml +++ b/controller/app/src/main/res/values-uk/strings_kolibri.xml @@ -34,4 +34,5 @@ Готово — до моєї бібліотеки %1$d курс(ів) не завершено. Повторіть невдалі перед виходом — вихід очищає цей список. + Каталог оновлено %1$s diff --git a/controller/app/src/main/res/values-vi/strings_kolibri.xml b/controller/app/src/main/res/values-vi/strings_kolibri.xml index f563be93e..5a036231b 100644 --- a/controller/app/src/main/res/values-vi/strings_kolibri.xml +++ b/controller/app/src/main/res/values-vi/strings_kolibri.xml @@ -34,4 +34,5 @@ Xong — tới thư viện của tôi %1$d khóa học chưa hoàn tất. Hãy thử lại những mục lỗi trước khi rời đi — rời đi sẽ xóa danh sách này. + Danh mục cập nhật ngày %1$s diff --git a/controller/app/src/main/res/values-yo/strings_kolibri.xml b/controller/app/src/main/res/values-yo/strings_kolibri.xml index c660454d2..a6737fcc7 100644 --- a/controller/app/src/main/res/values-yo/strings_kolibri.xml +++ b/controller/app/src/main/res/values-yo/strings_kolibri.xml @@ -34,4 +34,5 @@ Ó parí — lọ sí ibi ìkàwé mi %1$d ìdánilẹ́kọ̀ọ́ kò parí. Tún gbìyànjú àwọn tí ó kùnà kí o tó lọ — lílọ máa pa àtòjọ yìí rẹ́. + A ṣe àfikún àtòjọ ní %1$s diff --git a/controller/app/src/main/res/values-zh-rCN/strings_kolibri.xml b/controller/app/src/main/res/values-zh-rCN/strings_kolibri.xml index 3a3ddb888..794e7b78f 100644 --- a/controller/app/src/main/res/values-zh-rCN/strings_kolibri.xml +++ b/controller/app/src/main/res/values-zh-rCN/strings_kolibri.xml @@ -34,4 +34,5 @@ 完成 — 前往我的图书馆 %1$d 门课程未完成。 离开前请重试失败的项目——离开会清空此列表。 + 目录更新于 %1$s diff --git a/controller/app/src/main/res/values/strings_kolibri.xml b/controller/app/src/main/res/values/strings_kolibri.xml index bc7037c4a..7ece3cc06 100644 --- a/controller/app/src/main/res/values/strings_kolibri.xml +++ b/controller/app/src/main/res/values/strings_kolibri.xml @@ -37,6 +37,8 @@ language, so both catalogs must describe it identically. --> Couldn\'t read the course catalog included with the app. No courses match your search. + + Catalog updated on %1$s Size unknown The courses you chose.